diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index f4c0d29ddc..e5194ac476 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -6,6 +6,7 @@ export const API = { agents: `${API_PREFIX}/agents`, projects: `${API_PREFIX}/projects`, environments: `${API_PREFIX}/environments`, + environmentDeleteBlastRadius: `${API_PREFIX}/environments/:id/delete-blast-radius`, environmentCustomImageTemplate: `${API_PREFIX}/environments/:environmentId/custom-image-template`, environmentCustomImageTemplateDisable: `${API_PREFIX}/environments/:environmentId/custom-image-template`, environmentCustomImageTemplateRollback: `${API_PREFIX}/environments/:environmentId/custom-image-template/rollback`, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f771ed6cf2..d888cea22c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -415,6 +415,8 @@ export { export type { Company, Environment, + EnvironmentDeleteBlastRadius, + EnvironmentDeleteBlockedReason, EnvironmentLease, EnvironmentProbeResult, FakeSandboxEnvironmentConfig, diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 2f3c9668d9..9e38f2d558 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -71,6 +71,28 @@ export interface Environment { updatedAt: Date; } +export type EnvironmentDeleteBlockedReason = "managed_local" | "instance_default"; + +export interface EnvironmentDeleteBlastRadius { + environmentId: string; + canDelete: boolean; + deleteBlockedReasons: EnvironmentDeleteBlockedReason[]; + staticReferences: { + isManagedLocal: boolean; + isInstanceDefault: boolean; + agentDefaultCount: number; + executionWorkspaceSelectionCount: number; + issueSelectionCount: number; + projectSelectionCount: number; + secretBindingCount: number; + }; + activeRuntimeUse: { + activeLeaseCount: number; + activeCustomImageSetupSessionCount: number; + hasActiveRuntimeUse: boolean; + }; +} + export interface EnvironmentLease { id: string; companyId: string; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c0245d1f84..3ad6dc17ba 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,6 +1,8 @@ export type { Company } from "./company.js"; export type { Environment, + EnvironmentDeleteBlastRadius, + EnvironmentDeleteBlockedReason, EnvironmentLease, EnvironmentProbeResult, FakeSandboxEnvironmentConfig, diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index d830c8cdaf..c59ecc6f74 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -17,10 +17,12 @@ const mockAgentService = vi.hoisted(() => ({ const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), + clearExecutionWorkspaceEnvironmentSelection: vi.fn(), })); const mockProjectService = vi.hoisted(() => ({ getById: vi.fn(), + clearExecutionWorkspaceEnvironmentSelection: vi.fn(), })); const mockInstanceSettingsService = vi.hoisted(() => ({ @@ -32,6 +34,8 @@ const mockEnvironmentService = vi.hoisted(() => ({ getById: vi.fn(), create: vi.fn(), update: vi.fn(), + removeIfDeletable: vi.fn(), + getDeleteBlastRadius: vi.fn(), listLeases: vi.fn(), getLeaseById: vi.fn(), })); @@ -70,7 +74,9 @@ const mockGetPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn()); const mockCapturePluginEnvironmentTemplate = vi.hoisted(() => vi.fn()); const mockCancelPluginEnvironmentInteractiveSetup = vi.hoisted(() => vi.fn()); const mockDeletePluginEnvironmentTemplate = vi.hoisted(() => vi.fn()); -const mockExecutionWorkspaceService = vi.hoisted(() => ({})); +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + clearEnvironmentSelection: vi.fn(), +})); vi.mock("../services/index.js", () => ({ issueService: () => mockIssueService, @@ -126,6 +132,46 @@ function createEnvironment() { }; } +function createDeleteBlastRadius(overrides: Partial<{ + isManagedLocal: boolean; + isInstanceDefault: boolean; + agentDefaultCount: number; + executionWorkspaceSelectionCount: number; + issueSelectionCount: number; + projectSelectionCount: number; + secretBindingCount: number; + activeLeaseCount: number; + activeCustomImageSetupSessionCount: number; +}> = {}) { + const staticReferences = { + isManagedLocal: overrides.isManagedLocal ?? false, + isInstanceDefault: overrides.isInstanceDefault ?? false, + agentDefaultCount: overrides.agentDefaultCount ?? 0, + executionWorkspaceSelectionCount: overrides.executionWorkspaceSelectionCount ?? 0, + issueSelectionCount: overrides.issueSelectionCount ?? 0, + projectSelectionCount: overrides.projectSelectionCount ?? 0, + secretBindingCount: overrides.secretBindingCount ?? 0, + }; + const activeRuntimeUse = { + activeLeaseCount: overrides.activeLeaseCount ?? 0, + activeCustomImageSetupSessionCount: overrides.activeCustomImageSetupSessionCount ?? 0, + hasActiveRuntimeUse: + (overrides.activeLeaseCount ?? 0) > 0 + || (overrides.activeCustomImageSetupSessionCount ?? 0) > 0, + }; + const deleteBlockedReasons = [ + ...(staticReferences.isManagedLocal ? ["managed_local" as const] : []), + ...(staticReferences.isInstanceDefault ? ["instance_default" as const] : []), + ]; + return { + environmentId: "env-1", + canDelete: deleteBlockedReasons.length === 0, + deleteBlockedReasons, + staticReferences, + activeRuntimeUse, + }; +} + let server: Server | null = null; let currentActor: Record = { type: "board", @@ -178,15 +224,20 @@ describe("environment routes", () => { mockAccessService.decide.mockReset(); mockAgentService.getById.mockReset(); mockIssueService.getById.mockReset(); + mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); mockProjectService.getById.mockReset(); + mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); mockInstanceSettingsService.listCompanyIds.mockReset(); mockEnvironmentService.list.mockReset(); mockEnvironmentService.list.mockResolvedValue([]); mockEnvironmentService.getById.mockReset(); mockEnvironmentService.create.mockReset(); mockEnvironmentService.update.mockReset(); + mockEnvironmentService.removeIfDeletable.mockReset(); + mockEnvironmentService.getDeleteBlastRadius.mockReset(); mockEnvironmentService.listLeases.mockReset(); mockEnvironmentService.getLeaseById.mockReset(); + mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset(); Object.values(mockEnvironmentCustomImageService).forEach((mock) => mock.mockReset()); mockEnvironmentCustomImageService.getOverview.mockResolvedValue({ activeTemplate: null, @@ -209,6 +260,9 @@ describe("environment routes", () => { id: "11111111-1111-1111-1111-111111111111", }); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockResolvedValue(0); + mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockResolvedValue(0); + mockExecutionWorkspaceService.clearEnvironmentSelection.mockResolvedValue(0); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env ?? {}); mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue([]); mockSecretService.syncEnvBindingsForTarget.mockResolvedValue([]); @@ -329,6 +383,79 @@ describe("environment routes", () => { }); }); + it("rejects non-admin blast-radius reads for instance-scoped environments", async () => { + const app = createApp({ + type: "board", + userId: "user-2", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }], + isInstanceAdmin: false, + }); + + const res = await request(app).get("/api/environments/env-1/delete-blast-radius"); + + expect(res.status).toBe(403); + expect(mockEnvironmentService.getDeleteBlastRadius).not.toHaveBeenCalled(); + }); + + it("returns delete blast radius counts for instance admins", async () => { + mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ + agentDefaultCount: 2, + secretBindingCount: 3, + activeLeaseCount: 1, + })); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "session", + companyIds: ["company-1"], + isInstanceAdmin: true, + }); + + const res = await request(app).get("/api/environments/env-1/delete-blast-radius"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + environmentId: "env-1", + canDelete: true, + deleteBlockedReasons: [], + staticReferences: { + isManagedLocal: false, + isInstanceDefault: false, + agentDefaultCount: 2, + executionWorkspaceSelectionCount: 0, + issueSelectionCount: 0, + projectSelectionCount: 0, + secretBindingCount: 3, + }, + activeRuntimeUse: { + activeLeaseCount: 1, + activeCustomImageSetupSessionCount: 0, + hasActiveRuntimeUse: true, + }, + }); + expect(res.body).not.toHaveProperty("config"); + expect(res.body).not.toHaveProperty("envVars"); + expect(res.body).not.toHaveProperty("metadata"); + }); + + it("returns 404 for missing delete blast radius targets", async () => { + mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(null); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "session", + companyIds: ["company-1"], + isInstanceAdmin: true, + }); + + const res = await request(app).get("/api/environments/missing/delete-blast-radius"); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Environment not found"); + }); + it("returns provider capabilities for the company", async () => { const app = createApp({ type: "board", @@ -597,6 +724,129 @@ describe("environment routes", () => { expect(mockEnvironmentService.create).not.toHaveBeenCalled(); }); + it("rejects deleting the managed local environment", async () => { + const environment = createEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ + isManagedLocal: true, + })); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "local_implicit", + }); + + const res = await request(app).delete("/api/environments/env-1"); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("Cannot delete the managed local environment."); + expect(res.body.details).toEqual({ deleteBlockedReasons: ["managed_local"] }); + expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled(); + expect(mockExecutionWorkspaceService.clearEnvironmentSelection).not.toHaveBeenCalled(); + }); + + it("rejects deleting the current instance default environment", async () => { + const environment = { + ...createEnvironment(), + driver: "ssh" as const, + name: "SSH Fixture", + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ + isInstanceDefault: true, + })); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "local_implicit", + }); + + const res = await request(app).delete("/api/environments/env-1"); + + expect(res.status).toBe(409); + expect(res.body.error).toBe( + "Cannot delete the current instance default environment. Set a new default environment before deleting this one.", + ); + expect(res.body.details).toEqual({ deleteBlockedReasons: ["instance_default"] }); + expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled(); + }); + + it("clears environment selections and secret bindings across all companies when deleting an environment", async () => { + const environment = { + ...createEnvironment(), + id: "env-ssh", + name: "SSH Fixture", + driver: "ssh" as const, + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + version: "latest", + }, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius()); + mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "local_implicit", + }); + + const res = await request(app).delete("/api/environments/env-ssh"); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.removeIfDeletable).toHaveBeenCalledWith("env-ssh"); + for (const companyId of ["company-1", "company-2"]) { + expect(mockExecutionWorkspaceService.clearEnvironmentSelection) + .toHaveBeenCalledWith(companyId, "env-ssh"); + expect(mockIssueService.clearExecutionWorkspaceEnvironmentSelection) + .toHaveBeenCalledWith(companyId, "env-ssh"); + expect(mockProjectService.clearExecutionWorkspaceEnvironmentSelection) + .toHaveBeenCalledWith(companyId, "env-ssh"); + expect(mockSecretService.syncEnvBindingsForTarget).toHaveBeenCalledWith( + companyId, + { targetType: "environment", targetId: "env-ssh" }, + {}, + ); + expect(mockSecretService.syncSecretRefsForTarget).toHaveBeenCalledWith( + companyId, + { targetType: "environment", targetId: "env-ssh" }, + [], + { replaceAll: true }, + ); + } + expect(mockSecretService.remove).toHaveBeenCalledWith("11111111-1111-1111-1111-111111111111"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "company-1", + action: "environment.deleted", + entityType: "environment", + entityId: "env-ssh", + }), + ); + }); + it("rejects invalid SSH config on create", async () => { const app = createApp({ type: "board", diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index c03dac9e41..eff92293fd 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -1,7 +1,21 @@ import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { and, eq } from "drizzle-orm"; -import { agents, companies, createDb, environmentLeases, environments, heartbeatRuns } from "@paperclipai/db"; +import { + agents, + companies, + companySecretBindings, + companySecrets, + createDb, + environmentCustomImageSetupSessions, + environmentLeases, + environments, + executionWorkspaces, + heartbeatRuns, + instanceSettings, + issues, + projects, +} from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -30,10 +44,17 @@ describeEmbeddedPostgres("environmentService leases", () => { }); afterEach(async () => { + await db.delete(companySecretBindings); + await db.delete(environmentCustomImageSetupSessions); await db.delete(environmentLeases); await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projects); await db.delete(agents); + await db.delete(instanceSettings); await db.delete(environments); + await db.delete(companySecrets); await db.delete(companies); }); @@ -146,6 +167,312 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(stillActive.map((lease) => lease.id)).toEqual([otherLease.id]); }); + it("aggregates delete blast radius counts into static and active tiers", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const environmentId = randomUUID(); + const otherEnvironmentId = randomUUID(); + const projectId = randomUUID(); + const issueId = randomUUID(); + const workspaceId = randomUUID(); + const secretId = randomUUID(); + const otherSecretId = randomUUID(); + const now = new Date(); + + await db.insert(companies).values([ + { + id: companyId, + name: "Acme", + status: "active", + issuePrefix: "ACM", + createdAt: now, + updatedAt: now, + }, + { + id: otherCompanyId, + name: "Other Co", + status: "active", + issuePrefix: "OTH", + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(environments).values([ + { + id: environmentId, + name: "Shared SSH", + driver: "ssh", + status: "active", + config: { + host: "fixture.example.test", + port: 22, + username: "fixture", + remoteWorkspacePath: "/srv/paperclip", + }, + createdAt: now, + updatedAt: now, + }, + { + id: otherEnvironmentId, + name: "Other SSH", + driver: "ssh", + status: "active", + config: { + host: "other.example.test", + port: 22, + username: "fixture", + remoteWorkspacePath: "/srv/paperclip", + }, + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(instanceSettings).values({ + singletonKey: "default", + defaultEnvironmentId: environmentId, + general: {}, + experimental: {}, + createdAt: now, + updatedAt: now, + }); + await db.insert(agents).values([ + { + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: environmentId, + permissions: {}, + createdAt: now, + updatedAt: now, + }, + { + companyId, + name: "OtherCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: otherEnvironmentId, + permissions: {}, + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Project", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "isolated_workspace", + environmentId, + }, + createdAt: now, + updatedAt: now, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + title: "Issue", + status: "todo", + priority: "medium", + executionWorkspaceSettings: { + mode: "isolated_workspace", + environmentId, + }, + createdAt: now, + updatedAt: now, + }); + await db.insert(executionWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + sourceIssueId: issueId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Workspace", + status: "active", + providerType: "git_worktree", + metadata: { + config: { + environmentId, + }, + }, + createdAt: now, + updatedAt: now, + }); + await db.insert(companySecrets).values([ + { + id: secretId, + companyId, + key: "env-secret", + name: "Env Secret", + provider: "local_encrypted", + createdAt: now, + updatedAt: now, + }, + { + id: otherSecretId, + companyId: otherCompanyId, + key: "other-env-secret", + name: "Other Env Secret", + provider: "local_encrypted", + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(companySecretBindings).values([ + { + companyId, + secretId, + targetType: "environment", + targetId: environmentId, + configPath: "env.OPENAI_API_KEY", + createdAt: now, + updatedAt: now, + }, + { + companyId: otherCompanyId, + secretId: otherSecretId, + targetType: "environment", + targetId: environmentId, + configPath: "env.ANTHROPIC_API_KEY", + createdAt: now, + updatedAt: now, + }, + { + companyId, + secretId, + targetType: "agent", + targetId: "agent-1", + configPath: "env.OPENAI_API_KEY", + createdAt: now, + updatedAt: now, + }, + ]); + await svc.acquireLease({ + companyId, + environmentId, + }); + const releasedLease = await svc.acquireLease({ + companyId, + environmentId, + }); + await svc.releaseLease(releasedLease.id); + await db.insert(environmentCustomImageSetupSessions).values([ + { + environmentId, + provider: "fake-plugin", + status: "waiting_for_user", + createdAt: now, + updatedAt: now, + }, + ]); + + const impact = await svc.getDeleteBlastRadius(environmentId); + + expect(impact).toEqual({ + environmentId, + canDelete: false, + deleteBlockedReasons: ["instance_default"], + staticReferences: { + isManagedLocal: false, + isInstanceDefault: true, + agentDefaultCount: 1, + executionWorkspaceSelectionCount: 1, + issueSelectionCount: 1, + projectSelectionCount: 1, + secretBindingCount: 2, + }, + activeRuntimeUse: { + activeLeaseCount: 1, + activeCustomImageSetupSessionCount: 1, + hasActiveRuntimeUse: true, + }, + }); + }); + + it("guards removeIfDeletable with atomic local/default predicates", async () => { + const localEnvId = randomUUID(); + const defaultEnvId = randomUUID(); + const deletableEnvId = randomUUID(); + const now = new Date(); + + await db.insert(environments).values([ + { + id: localEnvId, + name: "Local Guard", + driver: "local", + status: "active", + config: {}, + createdAt: now, + updatedAt: now, + }, + { + id: defaultEnvId, + name: "Default SSH Guard", + driver: "ssh", + status: "active", + config: { + host: "default.example.test", + port: 22, + username: "fixture", + remoteWorkspacePath: "/srv/paperclip", + }, + createdAt: now, + updatedAt: now, + }, + { + id: deletableEnvId, + name: "Deletable SSH Guard", + driver: "ssh", + status: "active", + config: { + host: "delete.example.test", + port: 22, + username: "fixture", + remoteWorkspacePath: "/srv/paperclip", + }, + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(instanceSettings).values({ + singletonKey: "default", + defaultEnvironmentId: defaultEnvId, + general: {}, + experimental: {}, + createdAt: now, + updatedAt: now, + }); + + const removedLocal = await svc.removeIfDeletable(localEnvId); + const localRows = await db.select().from(environments).where(eq(environments.id, localEnvId)); + + expect(removedLocal).toBeNull(); + expect(localRows).toHaveLength(1); + expect(localRows[0]?.driver).toBe("local"); + + const removedDefault = await svc.removeIfDeletable(defaultEnvId); + const defaultRows = await db.select().from(environments).where(eq(environments.id, defaultEnvId)); + + expect(removedDefault).toBeNull(); + expect(defaultRows).toHaveLength(1); + + const removedDeletable = await svc.removeIfDeletable(deletableEnvId); + const deletedRows = await db.select().from(environments).where(eq(environments.id, deletableEnvId)); + + expect(removedDeletable?.id).toBe(deletableEnvId); + expect(deletedRows).toHaveLength(0); + }); + it("creates and then reuses the default local environment for a company", async () => { const companyId = randomUUID(); await db.insert(companies).values({ diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 8cbe724c4e..6ea542fdcd 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -11,10 +11,12 @@ import { redactEnvironmentCustomImageSetupSession, redactEnvironmentCustomImageTemplate, startEnvironmentCustomImageSetupSessionSchema, + type EnvironmentDeleteBlastRadius, updateEnvironmentSchema, } from "@paperclipai/shared"; import { conflict, forbidden, unprocessable } from "../errors.js"; import { validate } from "../middleware/validate.js"; +import { logger } from "../middleware/logger.js"; import { environmentCustomImageService, issueService, @@ -270,6 +272,39 @@ export function environmentRoutes( return details; } + function environmentDeleteBlockMessage(impact: EnvironmentDeleteBlastRadius): string | null { + if (impact.staticReferences.isManagedLocal) { + return "Cannot delete the managed local environment."; + } + if (impact.staticReferences.isInstanceDefault) { + return "Cannot delete the current instance default environment. Set a new default environment before deleting this one."; + } + return null; + } + + function rejectEnvironmentDelete(input: { + actor: ReturnType; + environment: { id: string; driver: string }; + impact: EnvironmentDeleteBlastRadius; + }): never { + const message = + environmentDeleteBlockMessage(input.impact) + ?? "Environment delete is currently blocked. Refresh the environment and retry."; + logger.warn( + { + environmentId: input.environment.id, + environmentDriver: input.environment.driver, + deleteBlockedReasons: input.impact.deleteBlockedReasons, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + }, + "environment delete rejected by guard", + ); + throw conflict(message, { deleteBlockedReasons: input.impact.deleteBlockedReasons }); + } + function setupSessionActivityDetails(session: { id: string; environmentId: string; @@ -334,6 +369,16 @@ export function environmentRoutes( res.json(rows.map((row) => presentEnvironmentForRead(req, row))); }); + router.get("/environments/:id/delete-blast-radius", async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const impact = await svc.getDeleteBlastRadius(req.params.id as string); + if (!impact) { + res.status(404).json({ error: "Environment not found" }); + return; + } + res.json(impact); + }); + router.get("/companies/:companyId/environments/capabilities", async (req, res) => { assertCanReadInstanceEnvironments(req); const pluginDrivers = await listReadyPluginEnvironmentDrivers({ @@ -762,24 +807,48 @@ export function environmentRoutes( return; } assertCanAccessInstanceEnvironments(req); + const actor = getActorInfo(req); + const impact = await svc.getDeleteBlastRadius(existing.id); + if (!impact) { + res.status(404).json({ error: "Environment not found" }); + return; + } + if (!impact.canDelete) { + rejectEnvironmentDelete({ actor, environment: existing, impact }); + } + + const removed = await svc.removeIfDeletable(existing.id); + if (!removed) { + const latestImpact = await svc.getDeleteBlastRadius(existing.id); + if (!latestImpact) { + res.status(404).json({ error: "Environment not found" }); + return; + } + rejectEnvironmentDelete({ actor, environment: existing, impact: latestImpact }); + } const companyIds = await instanceSettings.listCompanyIds(); await Promise.all( companyIds.flatMap((companyId) => [ executionWorkspaces.clearEnvironmentSelection(companyId, existing.id), issues.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id), projects.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id), + secrets.syncEnvBindingsForTarget( + companyId, + { targetType: "environment", targetId: existing.id }, + {}, + ), + secrets.syncSecretRefsForTarget( + companyId, + { targetType: "environment", targetId: existing.id }, + [], + { replaceAll: true }, + ), ]), ); - const removed = await svc.remove(existing.id); - if (!removed) { - res.status(404).json({ error: "Environment not found" }); - return; - } const secretId = readSshEnvironmentPrivateKeySecretId(existing); if (secretId) { await secrets.remove(secretId); } - const actor = getActorInfo(req); await logInstanceEnvironmentActivity({ actor, action: "environment.deleted", diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index c6860154d8..b37ee0508f 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3703,6 +3703,15 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/environments/{id}/delete-blast-radius", + tags: ["environments"], + summary: "Get environment delete blast radius", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + registry.registerPath({ method: "get", path: "/api/environments/{id}/leases", @@ -3739,7 +3748,7 @@ registry.registerPath({ tags: ["environments"], summary: "Delete an environment", request: { params: z.object({ id: z.string() }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, }); registry.registerPath({ diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index c68dad20e9..6487f7a1b9 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -1,6 +1,16 @@ -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { environmentLeases, environments } from "@paperclipai/db"; +import { + agents, + companySecretBindings, + environmentCustomImageSetupSessions, + environmentLeases, + environments, + executionWorkspaces, + instanceSettings, + issues, + projects, +} from "@paperclipai/db"; import { ENVIRONMENT_DRIVERS, ENVIRONMENT_LEASE_CLEANUP_STATUSES, @@ -9,6 +19,8 @@ import { ENVIRONMENT_STATUSES, type CreateEnvironment, type Environment, + type EnvironmentDeleteBlastRadius, + type EnvironmentDeleteBlockedReason, type EnvironmentLease, type EnvironmentLeaseCleanupStatus, type EnvironmentLeasePolicy, @@ -30,6 +42,7 @@ const DEFAULT_KUBERNETES_ENVIRONMENT_DESCRIPTION = const KUBERNETES_PROVIDER_KEY = "kubernetes"; /** Metadata marker for the company's managed-by-config Kubernetes sandbox environment. */ const KUBERNETES_MANAGED_MARKER = "managedKubernetesSandbox"; +const ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES = ["starting", "waiting_for_user", "capturing"] as const; /** * Configuration accepted by `ensureKubernetesEnvironment`. Mirrors the keys of @@ -163,6 +176,10 @@ function toEnvironmentLease(row: EnvironmentLeaseRow): EnvironmentLease { }; } +function countFromRows(rows: Array<{ count: number | string | null | undefined }>): number { + return Number(rows[0]?.count ?? 0); +} + export function environmentService(db: Db) { return { list: async ( @@ -197,28 +214,35 @@ export function environmentService(db: Db) { ensureLocalEnvironment: async (_companyId?: string): Promise => { const now = new Date(); - const row = await db - .insert(environments) - .values({ - name: DEFAULT_LOCAL_ENVIRONMENT_NAME, - description: DEFAULT_LOCAL_ENVIRONMENT_DESCRIPTION, - driver: "local", - status: "active", - config: {}, - envVars: {}, - metadata: { - managedByPaperclip: true, - defaultForInstance: true, - }, - createdAt: now, - updatedAt: now, - }) - .onConflictDoNothing({ - target: [environments.driver], - where: sql`${environments.driver} = 'local'`, - }) - .returning() - .then((rows) => rows[0] ?? null); + const insert = () => + db + .insert(environments) + .values({ + name: DEFAULT_LOCAL_ENVIRONMENT_NAME, + description: DEFAULT_LOCAL_ENVIRONMENT_DESCRIPTION, + driver: "local", + status: "active", + config: {}, + envVars: {}, + metadata: { + managedByPaperclip: true, + defaultForInstance: true, + }, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ + target: [environments.driver], + where: sql`${environments.driver} = 'local'`, + }) + .returning() + .then((rows) => rows[0] ?? null); + const row = await insert().catch((error: unknown) => { + if (hasConstraintName(error, "environments_name_idx")) { + return null; + } + throw error; + }); if (row) return toEnvironment(row); const existing = await db @@ -438,6 +462,123 @@ export function environmentService(db: Db) { return row ? toEnvironment(row) : null; }, + removeIfDeletable: async (id: string): Promise => { + const row = await db + .delete(environments) + .where( + and( + eq(environments.id, id), + ne(environments.driver, "local"), + sql`not exists ( + select 1 from ${instanceSettings} + where ${instanceSettings.defaultEnvironmentId} = ${environments.id} + )`, + ), + ) + .returning() + .then((rows) => rows[0] ?? null); + return row ? toEnvironment(row) : null; + }, + + getDeleteBlastRadius: async (id: string): Promise => { + const environment = await db + .select({ + id: environments.id, + driver: environments.driver, + }) + .from(environments) + .where(eq(environments.id, id)) + .then((rows) => rows[0] ?? null); + if (!environment) return null; + + const [ + instanceDefaultRows, + agentDefaultRows, + executionWorkspaceRows, + issueRows, + projectRows, + secretBindingRows, + activeLeaseRows, + activeSetupRows, + ] = await Promise.all([ + db + .select({ count: sql`count(*)::int` }) + .from(instanceSettings) + .where(eq(instanceSettings.defaultEnvironmentId, id)), + db + .select({ count: sql`count(*)::int` }) + .from(agents) + .where(eq(agents.defaultEnvironmentId, id)), + db + .select({ count: sql`count(*)::int` }) + .from(executionWorkspaces) + .where(sql`${executionWorkspaces.metadata} -> 'config' ->> 'environmentId' = ${id}`), + db + .select({ count: sql`count(*)::int` }) + .from(issues) + .where(sql`${issues.executionWorkspaceSettings} ->> 'environmentId' = ${id}`), + db + .select({ count: sql`count(*)::int` }) + .from(projects) + .where(sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`), + db + .select({ count: sql`count(*)::int` }) + .from(companySecretBindings) + .where( + and( + eq(companySecretBindings.targetType, "environment"), + eq(companySecretBindings.targetId, id), + ), + ), + db + .select({ count: sql`count(*)::int` }) + .from(environmentLeases) + .where( + and( + eq(environmentLeases.environmentId, id), + eq(environmentLeases.status, "active"), + ), + ), + db + .select({ count: sql`count(*)::int` }) + .from(environmentCustomImageSetupSessions) + .where( + and( + eq(environmentCustomImageSetupSessions.environmentId, id), + inArray(environmentCustomImageSetupSessions.status, [...ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES]), + ), + ), + ]); + + const isManagedLocal = environment.driver === "local"; + const isInstanceDefault = countFromRows(instanceDefaultRows) > 0; + const deleteBlockedReasons: EnvironmentDeleteBlockedReason[] = []; + if (isManagedLocal) deleteBlockedReasons.push("managed_local"); + if (isInstanceDefault) deleteBlockedReasons.push("instance_default"); + const activeLeaseCount = countFromRows(activeLeaseRows); + const activeCustomImageSetupSessionCount = countFromRows(activeSetupRows); + + return { + environmentId: id, + canDelete: deleteBlockedReasons.length === 0, + deleteBlockedReasons, + staticReferences: { + isManagedLocal, + isInstanceDefault, + agentDefaultCount: countFromRows(agentDefaultRows), + executionWorkspaceSelectionCount: countFromRows(executionWorkspaceRows), + issueSelectionCount: countFromRows(issueRows), + projectSelectionCount: countFromRows(projectRows), + secretBindingCount: countFromRows(secretBindingRows), + }, + activeRuntimeUse: { + activeLeaseCount, + activeCustomImageSetupSessionCount, + hasActiveRuntimeUse: activeLeaseCount > 0 || activeCustomImageSetupSessionCount > 0, + }, + }; + }, + listLeases: async ( environmentId: string, filters: {