From 23de89de38a716da329a97a34b92a8a29da185f9 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 30 Apr 2026 11:18:14 -0400 Subject: [PATCH 1/2] chore: include dependencies on version post endpoint --- apps/api/openapi/openapi.json | 16 + .../openapi/paths/deploymentversions.jsonnet | 6 +- .../schemas/deploymentversions.jsonnet | 14 + .../src/routes/v1/workspaces/deployments.ts | 56 +- apps/api/src/types/openapi.ts | 7 + e2e/api/schema.ts | 246 +++++++ e2e/package.json | 1 + .../deployment-version-dependencies.spec.ts | 608 ++++++++++++++++++ 8 files changed, 946 insertions(+), 8 deletions(-) create mode 100644 e2e/tests/api/deployment-version-dependencies.spec.ts diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index 4847473f4b..70b0c5dce5 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -88,6 +88,22 @@ "format": "date-time", "type": "string" }, + "dependencies": { + "additionalProperties": { + "properties": { + "versionSelector": { + "description": "CEL expression evaluated against the dependency deployment's current release version on the same resource.", + "type": "string" + } + }, + "required": [ + "versionSelector" + ], + "type": "object" + }, + "description": "Map of dependency deployment ID to a CEL version selector evaluated against that deployment's current release on the same resource. Inserted atomically with the version so reconciliation cannot fire before edges are attached.", + "type": "object" + }, "jobAgentConfig": { "additionalProperties": true, "type": "object" diff --git a/apps/api/openapi/paths/deploymentversions.jsonnet b/apps/api/openapi/paths/deploymentversions.jsonnet index c454d16354..e76c1e7caf 100644 --- a/apps/api/openapi/paths/deploymentversions.jsonnet +++ b/apps/api/openapi/paths/deploymentversions.jsonnet @@ -32,7 +32,11 @@ local openapi = import '../lib/openapi.libsonnet'; }, }, }, - responses: openapi.acceptedResponse(openapi.schemaRef('DeploymentVersion')) + responses: openapi.okResponse( + openapi.schemaRef('DeploymentVersion'), + 'Deployment version created', + ) + + openapi.notFoundResponse() + openapi.badRequestResponse(), }, }, diff --git a/apps/api/openapi/schemas/deploymentversions.jsonnet b/apps/api/openapi/schemas/deploymentversions.jsonnet index 8e974a1288..25d8e53b1d 100644 --- a/apps/api/openapi/schemas/deploymentversions.jsonnet +++ b/apps/api/openapi/schemas/deploymentversions.jsonnet @@ -23,6 +23,20 @@ local openapi = import '../lib/openapi.libsonnet'; type: 'object', additionalProperties: { type: 'string' }, }, + dependencies: { + type: 'object', + description: "Map of dependency deployment ID to a CEL version selector evaluated against that deployment's current release on the same resource. Inserted atomically with the version so reconciliation cannot fire before edges are attached.", + additionalProperties: { + type: 'object', + required: ['versionSelector'], + properties: { + versionSelector: { + type: 'string', + description: "CEL expression evaluated against the dependency deployment's current release version on the same resource.", + }, + }, + }, + }, }, }, diff --git a/apps/api/src/routes/v1/workspaces/deployments.ts b/apps/api/src/routes/v1/workspaces/deployments.ts index 3d6139bd4c..4482a1ecfe 100644 --- a/apps/api/src/routes/v1/workspaces/deployments.ts +++ b/apps/api/src/routes/v1/workspaces/deployments.ts @@ -383,27 +383,69 @@ const createDeploymentVersion: AsyncTypedHandler< > = async (req, res) => { const { workspaceId, deploymentId } = req.params; const { body } = req; + const { dependencies, ...versionFields } = body; + + const dependencyEntries = Object.entries(dependencies ?? {}); + if (dependencyEntries.length > 0) { + for (const [depDeploymentId, edge] of dependencyEntries) { + if (depDeploymentId === deploymentId) + throw new ApiError( + "A deployment version cannot depend on its own deployment", + 400, + ); + if (!validResourceSelector(edge.versionSelector)) + throw new ApiError( + `Invalid versionSelector CEL expression for dependency ${depDeploymentId}`, + 400, + ); + } + + const dependencyDeploymentIds = dependencyEntries.map(([id]) => id); + const found = await db + .select({ id: schema.deployment.id }) + .from(schema.deployment) + .where( + and( + eq(schema.deployment.workspaceId, workspaceId), + inArray(schema.deployment.id, dependencyDeploymentIds), + ), + ); + const foundIds = new Set(found.map((d) => d.id)); + for (const id of dependencyDeploymentIds) { + if (!foundIds.has(id)) + throw new ApiError(`Dependency deployment ${id} not found`, 404); + } + } const data = { - ...body, - name: body.name === "" ? body.tag : body.name, - config: body.config ?? {}, - jobAgentConfig: body.jobAgentConfig ?? {}, - metadata: body.metadata ?? {}, + ...versionFields, + name: versionFields.name === "" ? versionFields.tag : versionFields.name, + config: versionFields.config ?? {}, + jobAgentConfig: versionFields.jobAgentConfig ?? {}, + metadata: versionFields.metadata ?? {}, deploymentId, createdAt: new Date(), id: uuidv4(), }; const version = await db.transaction(async (tx) => { - const version = await tx + const inserted = await tx .insert(schema.deploymentVersion) .values(data) .onConflictDoNothing() .returning() .then(takeFirst); - return version; + if (dependencyEntries.length > 0) + await tx.insert(schema.deploymentVersionDependency).values( + dependencyEntries.map(([dependencyDeploymentId, edge]) => ({ + deploymentVersionId: inserted.id, + dependencyDeploymentId, + versionSelector: edge.versionSelector, + })), + ); + + return inserted; }); enqueueReleaseTargetsForDeployment(db, workspaceId, deploymentId); diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index be9dd48330..43a50beb80 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -1154,6 +1154,13 @@ export interface components { }; /** Format: date-time */ createdAt?: string; + /** @description Map of dependency deployment ID to a CEL version selector evaluated against that deployment's current release on the same resource. Inserted atomically with the version so reconciliation cannot fire before edges are attached. */ + dependencies?: { + [key: string]: { + /** @description CEL expression evaluated against the dependency deployment's current release version on the same resource. */ + versionSelector: string; + }; + }; jobAgentConfig?: { [key: string]: unknown; }; diff --git a/e2e/api/schema.ts b/e2e/api/schema.ts index aa36800413..43a50beb80 100644 --- a/e2e/api/schema.ts +++ b/e2e/api/schema.ts @@ -138,6 +138,47 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List deployment-version dependencies + * @description Returns the dependency edges declared by this deployment version. + */ + get: operations["listDeploymentVersionDependencies"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upsert deployment-version dependency + * @description Declare or update a version-selector dependency from this deployment version to another deployment. Identified by the (deploymentVersionId, dependencyDeploymentId) pair. + */ + put: operations["requestDeploymentVersionDependencyUpsert"]; + post?: never; + /** Delete deployment-version dependency */ + delete: operations["requestDeploymentVersionDependencyDeletion"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/user-approval-records": { parameters: { query?: never; @@ -173,6 +214,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/deployments/name/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get deployment by name */ + get: operations["getDeploymentByName"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/deployments/{deploymentId}": { parameters: { query?: never; @@ -1096,6 +1154,13 @@ export interface components { }; /** Format: date-time */ createdAt?: string; + /** @description Map of dependency deployment ID to a CEL version selector evaluated against that deployment's current release on the same resource. Inserted atomically with the version so reconciliation cannot fire before edges are attached. */ + dependencies?: { + [key: string]: { + /** @description CEL expression evaluated against the dependency deployment's current release version on the same resource. */ + versionSelector: string; + }; + }; jobAgentConfig?: { [key: string]: unknown; }; @@ -1358,6 +1423,12 @@ export interface components { status: components["schemas"]["DeploymentVersionStatus"]; tag: string; }; + DeploymentVersionDependency: { + dependencyDeploymentId: string; + deploymentVersionId: string; + /** @description CEL expression evaluated against the dependency deployment's current release version on the same resource. */ + versionSelector: string; + }; /** @enum {string} */ DeploymentVersionStatus: "unspecified" | "building" | "ready" | "failed" | "rejected"; DeploymentWindowRule: { @@ -2002,6 +2073,10 @@ export interface components { resourceSelector?: string; value: components["schemas"]["Value"]; }; + UpsertDeploymentVersionDependencyRequest: { + /** @description CEL expression evaluated against the dependency deployment's current release version on the same resource. */ + versionSelector: string; + }; UpsertDeploymentVersionRequest: { config?: { [key: string]: unknown; @@ -2925,6 +3000,134 @@ export interface operations { }; }; }; + listDeploymentVersionDependencies: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the deployment version */ + deploymentVersionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentVersionDependency"][]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + requestDeploymentVersionDependencyUpsert: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the deployment version */ + deploymentVersionId: string; + /** @description ID of the dependency deployment */ + dependencyDeploymentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertDeploymentVersionDependencyRequest"]; + }; + }; + responses: { + /** @description Accepted response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentRequestAccepted"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + requestDeploymentVersionDependencyDeletion: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the deployment version */ + deploymentVersionId: string; + /** @description ID of the dependency deployment */ + dependencyDeploymentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentRequestAccepted"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; requestUserApprovalRecordUpsert: { parameters: { query?: never; @@ -3055,6 +3258,49 @@ export interface operations { }; }; }; + getDeploymentByName: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description Name of the deployment */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentWithVariablesAndSystems"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getDeployment: { parameters: { query?: never; diff --git a/e2e/package.json b/e2e/package.json index faa1ef9979..6ba5995e5d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -17,6 +17,7 @@ "test:environments": "pnpm exec playwright test tests/api/environments.spec.ts", "test:policies": "pnpm exec playwright test tests/api/policies/", "test:deployments": "pnpm exec playwright test --project=api-tests tests/api/deployments.spec.ts", + "test:deployment-version-deps": "pnpm exec playwright test --project=api-tests tests/api/deployment-version-dependencies.spec.ts", "test:release-targets": "pnpm exec playwright test tests/api/release-targets.spec.ts", "test:yaml": "pnpm exec playwright test tests/api/yaml-import.spec.ts", "test:yaml-prefixed": "pnpm exec playwright test tests/api/random-prefix-yaml.spec.ts", diff --git a/e2e/tests/api/deployment-version-dependencies.spec.ts b/e2e/tests/api/deployment-version-dependencies.spec.ts new file mode 100644 index 0000000000..0b9751dccb --- /dev/null +++ b/e2e/tests/api/deployment-version-dependencies.spec.ts @@ -0,0 +1,608 @@ +import { expect } from "@playwright/test"; +import { faker } from "@faker-js/faker"; + +import { test } from "../fixtures"; + +test.describe("Deployment Version Dependencies API", () => { + let systemId: string; + let downstreamId: string; + let upstreamId: string; + let secondUpstreamId: string; + + test.beforeAll(async ({ api, workspace }) => { + const systemRes = await api.POST( + "/v1/workspaces/{workspaceId}/systems", + { + params: { path: { workspaceId: workspace.id } }, + body: { name: `dep-test-system-${faker.string.alphanumeric(8)}` }, + }, + ); + expect(systemRes.response.status).toBe(202); + systemId = systemRes.data!.id; + + const downstreamName = `downstream-${faker.string.alphanumeric(8)}`; + const downstreamRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments", + { + params: { path: { workspaceId: workspace.id } }, + body: { name: downstreamName, slug: downstreamName }, + }, + ); + expect(downstreamRes.response.status).toBe(202); + downstreamId = downstreamRes.data!.id; + + const upstreamName = `upstream-${faker.string.alphanumeric(8)}`; + const upstreamRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments", + { + params: { path: { workspaceId: workspace.id } }, + body: { name: upstreamName, slug: upstreamName }, + }, + ); + expect(upstreamRes.response.status).toBe(202); + upstreamId = upstreamRes.data!.id; + + const secondUpstreamName = `upstream2-${faker.string.alphanumeric(8)}`; + const secondUpstreamRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments", + { + params: { path: { workspaceId: workspace.id } }, + body: { + name: secondUpstreamName, + slug: secondUpstreamName, + }, + }, + ); + expect(secondUpstreamRes.response.status).toBe(202); + secondUpstreamId = secondUpstreamRes.data!.id; + }); + + test.afterAll(async ({ api, workspace }) => { + await api.DELETE("/v1/workspaces/{workspaceId}/systems/{systemId}", { + params: { path: { workspaceId: workspace.id, systemId } }, + }); + for (const id of [downstreamId, upstreamId, secondUpstreamId]) { + await api.DELETE( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}", + { + params: { path: { workspaceId: workspace.id, deploymentId: id } }, + }, + ); + } + }); + + // -------------------------------------------------------------------------- + // Inline dependencies on version create + // -------------------------------------------------------------------------- + + test("creates a version with no dependencies (regression)", async ({ + api, + workspace, + }) => { + const tag = `v-no-deps-${faker.string.alphanumeric(6)}`; + const res = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { name: tag, tag, status: "ready" }, + }, + ); + expect(res.response.status).toBe(200); + + const listRes = await api.GET( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId: res.data!.id, + }, + }, + }, + ); + expect(listRes.response.status).toBe(200); + expect(listRes.data).toEqual([]); + }); + + test("creates a version with inline dependencies (atomic)", async ({ + api, + workspace, + }) => { + const tag = `v-with-deps-${faker.string.alphanumeric(6)}`; + const res = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { + name: tag, + tag, + status: "ready", + dependencies: { + [upstreamId]: { versionSelector: `version.tag == "v2.0.0"` }, + [secondUpstreamId]: { versionSelector: `true` }, + }, + }, + }, + ); + expect(res.response.status).toBe(200); + + const listRes = await api.GET( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId: res.data!.id, + }, + }, + }, + ); + expect(listRes.response.status).toBe(200); + expect(listRes.data).toHaveLength(2); + + const byDep = new Map( + (listRes.data ?? []).map((edge) => [edge.dependencyDeploymentId, edge]), + ); + expect(byDep.get(upstreamId)?.versionSelector).toBe( + `version.tag == "v2.0.0"`, + ); + expect(byDep.get(secondUpstreamId)?.versionSelector).toBe(`true`); + }); + + test("rejects self-dependency on version create", async ({ + api, + workspace, + }) => { + const tag = `v-self-${faker.string.alphanumeric(6)}`; + const res = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { + name: tag, + tag, + status: "ready", + dependencies: { + [downstreamId]: { versionSelector: `true` }, + }, + }, + }, + ); + expect(res.response.status).toBe(400); + }); + + test("rejects invalid CEL selector on version create", async ({ + api, + workspace, + }) => { + const tag = `v-bad-cel-${faker.string.alphanumeric(6)}`; + const res = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { + name: tag, + tag, + status: "ready", + dependencies: { + [upstreamId]: { versionSelector: `((( not valid cel` }, + }, + }, + }, + ); + expect(res.response.status).toBe(400); + }); + + test("rejects dependency on a deployment that doesn't exist in this workspace", async ({ + api, + workspace, + }) => { + const tag = `v-missing-dep-${faker.string.alphanumeric(6)}`; + const fakeDepId = faker.string.uuid(); + const res = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { + name: tag, + tag, + status: "ready", + dependencies: { + [fakeDepId]: { versionSelector: `true` }, + }, + }, + }, + ); + expect(res.response.status).toBe(404); + }); + + test("does not create the version when dependency validation fails", async ({ + api, + workspace, + }) => { + const tag = `v-atomic-fail-${faker.string.alphanumeric(6)}`; + const fakeDepId = faker.string.uuid(); + const failRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { + name: tag, + tag, + status: "ready", + dependencies: { + [fakeDepId]: { versionSelector: `true` }, + }, + }, + }, + ); + expect(failRes.response.status).toBe(404); + + // The version with this tag must not exist — re-creating it with the + // same tag must succeed (i.e., no row was leaked from the failed call). + const retryRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { name: tag, tag, status: "ready" }, + }, + ); + expect(retryRes.response.status).toBe(200); + }); + + // -------------------------------------------------------------------------- + // PUT upsert endpoint + // -------------------------------------------------------------------------- + + test("upserts a dependency edge on an existing version", async ({ + api, + workspace, + }) => { + const tag = `v-put-${faker.string.alphanumeric(6)}`; + const versionRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { name: tag, tag, status: "ready" }, + }, + ); + expect(versionRes.response.status).toBe(200); + const deploymentVersionId = versionRes.data!.id; + + // Create + const createRes = await api.PUT( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId, + dependencyDeploymentId: upstreamId, + }, + }, + body: { versionSelector: `version.tag == "v1.0.0"` }, + }, + ); + expect(createRes.response.status).toBe(202); + + // Update (same edge, new selector) + const updateRes = await api.PUT( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId, + dependencyDeploymentId: upstreamId, + }, + }, + body: { versionSelector: `version.tag == "v2.0.0"` }, + }, + ); + expect(updateRes.response.status).toBe(202); + + const listRes = await api.GET( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies", + { + params: { path: { workspaceId: workspace.id, deploymentVersionId } }, + }, + ); + expect(listRes.response.status).toBe(200); + expect(listRes.data).toHaveLength(1); + expect(listRes.data![0].versionSelector).toBe(`version.tag == "v2.0.0"`); + }); + + test("rejects upsert with invalid CEL selector", async ({ + api, + workspace, + }) => { + const tag = `v-put-bad-cel-${faker.string.alphanumeric(6)}`; + const versionRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { name: tag, tag, status: "ready" }, + }, + ); + const deploymentVersionId = versionRes.data!.id; + + const res = await api.PUT( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId, + dependencyDeploymentId: upstreamId, + }, + }, + body: { versionSelector: `((( not valid cel` }, + }, + ); + expect(res.response.status).toBe(400); + }); + + test("rejects upsert with self-dependency (version's own deployment)", async ({ + api, + workspace, + }) => { + const tag = `v-put-self-${faker.string.alphanumeric(6)}`; + const versionRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { name: tag, tag, status: "ready" }, + }, + ); + const deploymentVersionId = versionRes.data!.id; + + const res = await api.PUT( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId, + dependencyDeploymentId: downstreamId, + }, + }, + body: { versionSelector: `true` }, + }, + ); + expect(res.response.status).toBe(400); + }); + + test("rejects upsert when version does not exist in workspace", async ({ + api, + workspace, + }) => { + const res = await api.PUT( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId: faker.string.uuid(), + dependencyDeploymentId: upstreamId, + }, + }, + body: { versionSelector: `true` }, + }, + ); + expect(res.response.status).toBe(404); + }); + + test("rejects upsert when dependency deployment does not exist in workspace", async ({ + api, + workspace, + }) => { + const tag = `v-put-missing-dep-${faker.string.alphanumeric(6)}`; + const versionRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { name: tag, tag, status: "ready" }, + }, + ); + const deploymentVersionId = versionRes.data!.id; + + const res = await api.PUT( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId, + dependencyDeploymentId: faker.string.uuid(), + }, + }, + body: { versionSelector: `true` }, + }, + ); + expect(res.response.status).toBe(404); + }); + + // -------------------------------------------------------------------------- + // GET list endpoint + // -------------------------------------------------------------------------- + + test("returns 404 listing dependencies for a version that doesn't exist", async ({ + api, + workspace, + }) => { + const res = await api.GET( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId: faker.string.uuid(), + }, + }, + }, + ); + expect(res.response.status).toBe(404); + }); + + test("lists dependencies sorted by dependencyDeploymentId", async ({ + api, + workspace, + }) => { + const tag = `v-list-${faker.string.alphanumeric(6)}`; + const versionRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { + name: tag, + tag, + status: "ready", + dependencies: { + [upstreamId]: { versionSelector: `version.tag.startsWith("v1")` }, + [secondUpstreamId]: { versionSelector: `true` }, + }, + }, + }, + ); + expect(versionRes.response.status).toBe(200); + + const listRes = await api.GET( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId: versionRes.data!.id, + }, + }, + }, + ); + expect(listRes.response.status).toBe(200); + const ids = (listRes.data ?? []).map((e) => e.dependencyDeploymentId); + expect(ids).toEqual([...ids].sort()); + expect(ids).toContain(upstreamId); + expect(ids).toContain(secondUpstreamId); + }); + + // -------------------------------------------------------------------------- + // DELETE endpoint + // -------------------------------------------------------------------------- + + test("deletes a dependency edge", async ({ api, workspace }) => { + const tag = `v-delete-${faker.string.alphanumeric(6)}`; + const versionRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { + name: tag, + tag, + status: "ready", + dependencies: { + [upstreamId]: { versionSelector: `true` }, + }, + }, + }, + ); + const deploymentVersionId = versionRes.data!.id; + + const deleteRes = await api.DELETE( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId, + dependencyDeploymentId: upstreamId, + }, + }, + }, + ); + expect(deleteRes.response.status).toBe(202); + + const listRes = await api.GET( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies", + { + params: { path: { workspaceId: workspace.id, deploymentVersionId } }, + }, + ); + expect(listRes.response.status).toBe(200); + expect(listRes.data).toEqual([]); + }); + + test("returns 404 when deleting a non-existent edge", async ({ + api, + workspace, + }) => { + const tag = `v-delete-missing-${faker.string.alphanumeric(6)}`; + const versionRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", + { + params: { + path: { workspaceId: workspace.id, deploymentId: downstreamId }, + }, + body: { name: tag, tag, status: "ready" }, + }, + ); + const deploymentVersionId = versionRes.data!.id; + + const res = await api.DELETE( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId, + dependencyDeploymentId: upstreamId, + }, + }, + }, + ); + expect(res.response.status).toBe(404); + }); + + test("returns 404 when deleting an edge on a non-existent version", async ({ + api, + workspace, + }) => { + const res = await api.DELETE( + "/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}", + { + params: { + path: { + workspaceId: workspace.id, + deploymentVersionId: faker.string.uuid(), + dependencyDeploymentId: upstreamId, + }, + }, + }, + ); + expect(res.response.status).toBe(404); + }); +}); From 5ff22fa5d0fb8d00612071042cae4cb9ca4a557b Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 30 Apr 2026 11:48:17 -0400 Subject: [PATCH 2/2] cleanup --- apps/api/openapi/openapi.json | 14 +- .../src/routes/v1/workspaces/deployments.ts | 135 +++++++++++++----- apps/api/src/types/openapi.ts | 13 +- e2e/api/schema.ts | 13 +- 4 files changed, 131 insertions(+), 44 deletions(-) diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index 70b0c5dce5..1f926b3e7f 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -5446,7 +5446,7 @@ "required": true }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { @@ -5454,7 +5454,7 @@ } } }, - "description": "Accepted response" + "description": "Deployment version created" }, "400": { "content": { @@ -5465,6 +5465,16 @@ } }, "description": "Invalid request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" } }, "summary": "Create a deployment version" diff --git a/apps/api/src/routes/v1/workspaces/deployments.ts b/apps/api/src/routes/v1/workspaces/deployments.ts index 4482a1ecfe..a2b7a3bead 100644 --- a/apps/api/src/routes/v1/workspaces/deployments.ts +++ b/apps/api/src/routes/v1/workspaces/deployments.ts @@ -3,7 +3,9 @@ import { ApiError, asyncHandler } from "@/types/api.js"; import { evaluate } from "cel-js"; import { Router } from "express"; import { v4 as uuidv4 } from "uuid"; +import { z } from "zod"; +import type { Tx } from "@ctrlplane/db"; import { and, asc, count, desc, eq, inArray, takeFirst } from "@ctrlplane/db"; import { db } from "@ctrlplane/db/client"; import { @@ -14,8 +16,6 @@ import { import * as schema from "@ctrlplane/db/schema"; import { getClientFor } from "@ctrlplane/workspace-engine-sdk"; -// 1 hour - import { validResourceSelector } from "../valid-selector.js"; import { listDeploymentVariablesByDeploymentRouter } from "./deployment-variables.js"; @@ -377,6 +377,88 @@ const listDeploymentVersions: AsyncTypedHandler< }); }; +const assertDeploymentExistsInWorkspace = async ( + workspaceId: string, + deploymentId: string, +) => { + const found = await db.query.deployment.findFirst({ + where: and( + eq(schema.deployment.id, deploymentId), + eq(schema.deployment.workspaceId, workspaceId), + ), + }); + if (found == null) throw new ApiError("Deployment not found", 404); +}; + +const validateDependencyEntries = ( + entries: [string, { versionSelector: string }][], + parentDeploymentId: string, +) => { + for (const [depDeploymentId, edge] of entries) { + if (!z.string().uuid().safeParse(depDeploymentId).success) + throw new ApiError( + `Invalid dependency deployment id "${depDeploymentId}"`, + 400, + ); + if (depDeploymentId === parentDeploymentId) + throw new ApiError( + "A deployment version cannot depend on its own deployment", + 400, + ); + const { versionSelector: selector } = edge; + if (selector.trim() === "") + throw new ApiError( + `Missing versionSelector for dependency ${depDeploymentId}`, + 400, + ); + if (!validResourceSelector(selector)) + throw new ApiError( + `Invalid versionSelector CEL expression for dependency ${depDeploymentId}`, + 400, + ); + } +}; + +const assertDependencyDeploymentsExistInWorkspace = async ( + tx: Tx, + workspaceId: string, + dependencyDeploymentIds: string[], +) => { + if (dependencyDeploymentIds.length === 0) return; + const found = await tx + .select({ id: schema.deployment.id }) + .from(schema.deployment) + .where( + and( + eq(schema.deployment.workspaceId, workspaceId), + inArray(schema.deployment.id, dependencyDeploymentIds), + ), + ); + const foundIds = new Set(found.map((d) => d.id)); + for (const id of dependencyDeploymentIds) { + if (!foundIds.has(id)) + throw new ApiError(`Dependency deployment ${id} not found`, 404); + } +}; + +const insertDeploymentVersionOrThrowConflict = async ( + tx: Tx, + data: typeof schema.deploymentVersion.$inferInsert, +) => { + const insertedRows = await tx + .insert(schema.deploymentVersion) + .values(data) + .onConflictDoNothing() + .returning(); + const inserted = insertedRows[0]; + if (inserted == null) + throw new ApiError( + `Deployment version with tag "${data.tag}" already exists for this deployment`, + 409, + ); + return inserted; +}; + const createDeploymentVersion: AsyncTypedHandler< "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", "post" @@ -385,37 +467,12 @@ const createDeploymentVersion: AsyncTypedHandler< const { body } = req; const { dependencies, ...versionFields } = body; + await assertDeploymentExistsInWorkspace(workspaceId, deploymentId); + const dependencyEntries = Object.entries(dependencies ?? {}); - if (dependencyEntries.length > 0) { - for (const [depDeploymentId, edge] of dependencyEntries) { - if (depDeploymentId === deploymentId) - throw new ApiError( - "A deployment version cannot depend on its own deployment", - 400, - ); - if (!validResourceSelector(edge.versionSelector)) - throw new ApiError( - `Invalid versionSelector CEL expression for dependency ${depDeploymentId}`, - 400, - ); - } + validateDependencyEntries(dependencyEntries, deploymentId); - const dependencyDeploymentIds = dependencyEntries.map(([id]) => id); - const found = await db - .select({ id: schema.deployment.id }) - .from(schema.deployment) - .where( - and( - eq(schema.deployment.workspaceId, workspaceId), - inArray(schema.deployment.id, dependencyDeploymentIds), - ), - ); - const foundIds = new Set(found.map((d) => d.id)); - for (const id of dependencyDeploymentIds) { - if (!foundIds.has(id)) - throw new ApiError(`Dependency deployment ${id} not found`, 404); - } - } + const dependencyDeploymentIds = dependencyEntries.map(([id]) => id); const data = { ...versionFields, @@ -429,14 +486,15 @@ const createDeploymentVersion: AsyncTypedHandler< }; const version = await db.transaction(async (tx) => { - const inserted = await tx - .insert(schema.deploymentVersion) - .values(data) - .onConflictDoNothing() - .returning() - .then(takeFirst); + await assertDependencyDeploymentsExistInWorkspace( + tx, + workspaceId, + dependencyDeploymentIds, + ); - if (dependencyEntries.length > 0) + const inserted = await insertDeploymentVersionOrThrowConflict(tx, data); + + if (dependencyEntries.length > 0) { await tx.insert(schema.deploymentVersionDependency).values( dependencyEntries.map(([dependencyDeploymentId, edge]) => ({ deploymentVersionId: inserted.id, @@ -444,6 +502,7 @@ const createDeploymentVersion: AsyncTypedHandler< versionSelector: edge.versionSelector, })), ); + } return inserted; }); diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index 43a50beb80..8b08e1f2c9 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -3651,8 +3651,8 @@ export interface operations { }; }; responses: { - /** @description Accepted response */ - 202: { + /** @description Deployment version created */ + 200: { headers: { [name: string]: unknown; }; @@ -3669,6 +3669,15 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; }; }; requestDeploymentVersionUpdate: { diff --git a/e2e/api/schema.ts b/e2e/api/schema.ts index 43a50beb80..8b08e1f2c9 100644 --- a/e2e/api/schema.ts +++ b/e2e/api/schema.ts @@ -3651,8 +3651,8 @@ export interface operations { }; }; responses: { - /** @description Accepted response */ - 202: { + /** @description Deployment version created */ + 200: { headers: { [name: string]: unknown; }; @@ -3669,6 +3669,15 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; }; }; requestDeploymentVersionUpdate: {