Harden environment deletion and expose delete blast radius (#9250)
## Thinking Path > - Paperclip manages AI agents that each have an associated execution environment (local, Kubernetes, etc.) > - Instance administrators can create and delete environments; currently the DELETE endpoint has no protection against deleting managed or in-use environments > - Deleting the managed local environment or the instance-default environment would break all agents using those environments with no path to recovery > - The endpoint also suffered a TOCTOU race: a check-then-delete pattern allowed the managed-local or default guard to pass if the environment's role changed between the read and the delete > - This pull request adds a blast-radius read endpoint so admins can preview impact, hard-blocks the dangerous deletes atomically, cleans up all dependent references after a valid delete, and fixes a concurrent creation race in ensureLocalEnvironment ## Linked Issues or Issue Description Fixes #9251 ## What Changed - **New endpoint** `GET /api/environments/:id/delete-blast-radius` (instance-admin gated): returns reference counts (agent defaults, workspace selections, issue selections, project selections, secret bindings, active leases, active setup sessions) and blocking reasons — no config, env-var values, or secret data returned. - **Atomic delete guard** `environmentService.removeIfDeletable(id)`: performs the DELETE with an inline `WHERE driver != 'local' AND NOT EXISTS (instanceSettings where defaultEnvironmentId = id)` predicate, eliminating the TOCTOU race between the app-level check and the DB write. - **Route hardening**: `DELETE /environments/:id` now calls `getDeleteBlastRadius` first (app-level check + logging), then calls `removeIfDeletable` (atomic guard). If the atomic guard returns null the route fetches a fresh blast-radius snapshot and rejects with a 409 Conflict carrying `deleteBlockedReasons`. - **Reference cleanup on valid delete**: after a successful delete, the route clears environment selections on all company execution workspaces, issues, and projects; syncs env-var secret bindings to `{}` (removing bindings for the deleted environment); syncs config secret refs to `[]` for the environment target; and removes the SSH private-key secret if one was stored. - **Race fix in `ensureLocalEnvironment`**: the insert-or-nothing path now catches a `environments_name_idx` unique-constraint violation and falls through to the existing SELECT, treating the name conflict as idempotent. - **Shared types**: `EnvironmentDeleteBlastRadius` and `EnvironmentDeleteBlockedReason` exported from `@paperclipai/shared`. - **OpenAPI**: registers the new blast-radius endpoint; updates the delete-environment response schema to document 403/404/409. - **Tests**: 56 existing environment-route and service tests continue to pass; new service-level regression tests assert the atomic guard rejects `local`-driver environments and instance-default environments and succeeds for deletable ones. ## Verification ``` corepack pnpm exec vitest run \ server/src/__tests__/environment-routes.test.ts \ server/src/__tests__/environment-service.test.ts # 56 tests, all passing corepack pnpm --filter @paperclipai/shared typecheck node scripts/ensure-plugin-build-deps.mjs cd server && ../node_modules/.bin/tsc --noEmit ``` ## Risks - **Blast-radius endpoint auth**: guarded by `assertCanAccessInstanceEnvironments`, the same gate as the existing environment-list and delete routes. Non-admin callers receive 401/403 before any data is returned. - **Atomic guard may reject a delete that the app-level check passed**: this is intentional — it means the environment became protected between the read and the write. The caller receives a fresh blast-radius snapshot explaining why. - **Secret cleanup ordering**: cleanup runs after the atomic DELETE succeeds, in parallel across companies. If cleanup partially fails the environment row is already gone; partial-cleanup state is recoverable by re-running the sync operations. Risk: low — these are idempotent upsert/sync operations. - **ensureLocalEnvironment race fix**: swapping a unique-constraint error for an idempotent SELECT adds one extra query on the conflict path. This path is rare (only fires during concurrent boot) and is significantly safer than the previous behavior. - **No migration**: all changes are application-level; no schema changes required. ## Model Used - Provider: Anthropic - Model: claude-sonnet-4-6 (Claude Sonnet 4.6) - Context window: 200k tokens - Mode: agentic tool use via Paperclip agent system (Claude Code) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Priya Raman <priya.raman@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Harold Kim <harold.kim@paperclip.ing>
This commit is contained in:
parent
1b16f9611c
commit
719da5f9b5
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -415,6 +415,8 @@ export {
|
|||
export type {
|
||||
Company,
|
||||
Environment,
|
||||
EnvironmentDeleteBlastRadius,
|
||||
EnvironmentDeleteBlockedReason,
|
||||
EnvironmentLease,
|
||||
EnvironmentProbeResult,
|
||||
FakeSandboxEnvironmentConfig,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export type { Company } from "./company.js";
|
||||
export type {
|
||||
Environment,
|
||||
EnvironmentDeleteBlastRadius,
|
||||
EnvironmentDeleteBlockedReason,
|
||||
EnvironmentLease,
|
||||
EnvironmentProbeResult,
|
||||
FakeSandboxEnvironmentConfig,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<typeof getActorInfo>;
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<Environment> => {
|
||||
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<Environment | null> => {
|
||||
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<EnvironmentDeleteBlastRadius | null> => {
|
||||
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<number>`count(*)::int` })
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.defaultEnvironmentId, id)),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(agents)
|
||||
.where(eq(agents.defaultEnvironmentId, id)),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(executionWorkspaces)
|
||||
.where(sql`${executionWorkspaces.metadata} -> 'config' ->> 'environmentId' = ${id}`),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(issues)
|
||||
.where(sql`${issues.executionWorkspaceSettings} ->> 'environmentId' = ${id}`),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(projects)
|
||||
.where(sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.targetType, "environment"),
|
||||
eq(companySecretBindings.targetId, id),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(environmentLeases)
|
||||
.where(
|
||||
and(
|
||||
eq(environmentLeases.environmentId, id),
|
||||
eq(environmentLeases.status, "active"),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ count: sql<number>`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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue