From dbc46930f6bcccef61de2f1cdffec71bd1f896af Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 29 Apr 2026 11:07:05 -0400 Subject: [PATCH 1/2] chore: deployment dependency api endpoints --- apps/api/openapi/openapi.json | 217 ++++++++++++++++++ apps/api/openapi/paths/deployments.jsonnet | 50 ++++ apps/api/openapi/schemas/deployments.jsonnet | 24 ++ .../src/routes/v1/workspaces/deployments.ts | 135 ++++++++++- apps/api/src/types/openapi.ts | 170 ++++++++++++++ 5 files changed, 595 insertions(+), 1 deletion(-) diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index bdcd93b0f..df90c16eb 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -495,6 +495,26 @@ ], "type": "object" }, + "DeploymentDependency": { + "properties": { + "dependencyDeploymentId": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "versionSelector": { + "description": "CEL expression evaluated against the dependency deployment's current release version on the same resource.", + "type": "string" + } + }, + "required": [ + "deploymentId", + "dependencyDeploymentId", + "versionSelector" + ], + "type": "object" + }, "DeploymentDependencyRule": { "properties": { "dependsOn": { @@ -2578,6 +2598,18 @@ }, "type": "object" }, + "UpsertDeploymentDependencyRequest": { + "properties": { + "versionSelector": { + "description": "CEL expression evaluated against the dependency deployment's current release version on the same resource.", + "type": "string" + } + }, + "required": [ + "versionSelector" + ], + "type": "object" + }, "UpsertDeploymentRequest": { "properties": { "description": { @@ -4805,6 +4837,191 @@ "summary": "Upsert deployment" } }, + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/dependencies": { + "get": { + "description": "Returns the dependency edges declared by this deployment.", + "operationId": "listDeploymentDependencies", + "parameters": [ + { + "description": "ID of the workspace", + "in": "path", + "name": "workspaceId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "ID of the deployment", + "in": "path", + "name": "deploymentId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DeploymentDependency" + }, + "type": "array" + } + } + }, + "description": "OK response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" + } + }, + "summary": "List deployment dependencies" + } + }, + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/dependencies/{dependencyDeploymentId}": { + "delete": { + "operationId": "requestDeploymentDependencyDeletion", + "parameters": [ + { + "description": "ID of the workspace", + "in": "path", + "name": "workspaceId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "ID of the deployment", + "in": "path", + "name": "deploymentId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "ID of the dependency deployment", + "in": "path", + "name": "dependencyDeploymentId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentRequestAccepted" + } + } + }, + "description": "Accepted response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" + } + }, + "summary": "Delete deployment dependency" + }, + "put": { + "description": "Declare or update a version-selector dependency from this deployment to another deployment. Identified by the (deploymentId, dependencyDeploymentId) pair.", + "operationId": "requestDeploymentDependencyUpsert", + "parameters": [ + { + "description": "ID of the workspace", + "in": "path", + "name": "workspaceId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "ID of the deployment", + "in": "path", + "name": "deploymentId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "ID of the dependency deployment", + "in": "path", + "name": "dependencyDeploymentId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertDeploymentDependencyRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentRequestAccepted" + } + } + }, + "description": "Accepted response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" + } + }, + "summary": "Upsert deployment dependency" + } + }, "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/plan": { "post": { "description": "Compute a dry-run plan showing rendered diffs for each release target without creating a version.", diff --git a/apps/api/openapi/paths/deployments.jsonnet b/apps/api/openapi/paths/deployments.jsonnet index 25b8f1a5a..ae52ea468 100644 --- a/apps/api/openapi/paths/deployments.jsonnet +++ b/apps/api/openapi/paths/deployments.jsonnet @@ -88,6 +88,56 @@ local openapi = import '../lib/openapi.libsonnet'; + openapi.badRequestResponse(), }, }, + '/v1/workspaces/{workspaceId}/deployments/{deploymentId}/dependencies': { + get: { + summary: 'List deployment dependencies', + description: "Returns the dependency edges declared by this deployment.", + operationId: 'listDeploymentDependencies', + parameters: [ + openapi.workspaceIdParam(), + openapi.deploymentIdParam(), + ], + responses: openapi.okResponse({ + type: 'array', + items: openapi.schemaRef('DeploymentDependency'), + }) + + openapi.notFoundResponse(), + }, + }, + '/v1/workspaces/{workspaceId}/deployments/{deploymentId}/dependencies/{dependencyDeploymentId}': { + put: { + summary: 'Upsert deployment dependency', + description: 'Declare or update a version-selector dependency from this deployment to another deployment. Identified by the (deploymentId, dependencyDeploymentId) pair.', + operationId: 'requestDeploymentDependencyUpsert', + parameters: [ + openapi.workspaceIdParam(), + openapi.deploymentIdParam(), + openapi.stringParam('dependencyDeploymentId', 'ID of the dependency deployment'), + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: openapi.schemaRef('UpsertDeploymentDependencyRequest'), + }, + }, + }, + responses: openapi.acceptedResponse(openapi.schemaRef('DeploymentRequestAccepted')) + + openapi.notFoundResponse() + + openapi.badRequestResponse(), + }, + delete: { + summary: 'Delete deployment dependency', + operationId: 'requestDeploymentDependencyDeletion', + parameters: [ + openapi.workspaceIdParam(), + openapi.deploymentIdParam(), + openapi.stringParam('dependencyDeploymentId', 'ID of the dependency deployment'), + ], + responses: openapi.acceptedResponse(openapi.schemaRef('DeploymentRequestAccepted')) + + openapi.notFoundResponse(), + }, + }, '/v1/workspaces/{workspaceId}/deployments/{deploymentId}/plan': { post: { summary: 'Create a deployment plan', diff --git a/apps/api/openapi/schemas/deployments.jsonnet b/apps/api/openapi/schemas/deployments.jsonnet index be1891e15..21cdb9207 100644 --- a/apps/api/openapi/schemas/deployments.jsonnet +++ b/apps/api/openapi/schemas/deployments.jsonnet @@ -35,6 +35,30 @@ local jobAgentConfig = { }, }, + UpsertDeploymentDependencyRequest: { + type: 'object', + required: ['versionSelector'], + properties: { + versionSelector: { + type: 'string', + description: "CEL expression evaluated against the dependency deployment's current release version on the same resource.", + }, + }, + }, + + DeploymentDependency: { + type: 'object', + required: ['deploymentId', 'dependencyDeploymentId', 'versionSelector'], + properties: { + deploymentId: { type: 'string' }, + dependencyDeploymentId: { type: 'string' }, + versionSelector: { + type: 'string', + description: "CEL expression evaluated against the dependency deployment's current release version on the same resource.", + }, + }, + }, + DeploymentRequestAccepted: { type: 'object', required: ['id', 'message'], diff --git a/apps/api/src/routes/v1/workspaces/deployments.ts b/apps/api/src/routes/v1/workspaces/deployments.ts index 3d6139bd4..20dafcbed 100644 --- a/apps/api/src/routes/v1/workspaces/deployments.ts +++ b/apps/api/src/routes/v1/workspaces/deployments.ts @@ -4,7 +4,16 @@ import { evaluate } from "cel-js"; import { Router } from "express"; import { v4 as uuidv4 } from "uuid"; -import { and, asc, count, desc, eq, inArray, takeFirst } from "@ctrlplane/db"; +import { + and, + asc, + count, + desc, + eq, + inArray, + takeFirst, + takeFirstOrNull, +} from "@ctrlplane/db"; import { db } from "@ctrlplane/db/client"; import { enqueueDeploymentPlan, @@ -280,6 +289,121 @@ const upsertDeployment: AsyncTypedHandler< .json({ id: deploymentId, message: "Deployment update requested" }); }; +const listDeploymentDependencies: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/dependencies", + "get" +> = async (req, res) => { + const { workspaceId, deploymentId } = req.params; + + const dep = await db.query.deployment.findFirst({ + where: and( + eq(schema.deployment.id, deploymentId), + eq(schema.deployment.workspaceId, workspaceId), + ), + }); + + if (dep == null) throw new ApiError("Deployment not found", 404); + + const rows = await db + .select() + .from(schema.deploymentDependency) + .where(eq(schema.deploymentDependency.deploymentId, deploymentId)) + .orderBy(asc(schema.deploymentDependency.dependencyDeploymentId)); + + res.status(200).json(rows); +}; + +const deleteDeploymentDependency: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/dependencies/{dependencyDeploymentId}", + "delete" +> = async (req, res) => { + const { workspaceId, deploymentId, dependencyDeploymentId } = req.params; + + const dep = await db.query.deployment.findFirst({ + where: and( + eq(schema.deployment.id, deploymentId), + eq(schema.deployment.workspaceId, workspaceId), + ), + }); + + if (dep == null) throw new ApiError("Deployment not found", 404); + + const deleted = await db + .delete(schema.deploymentDependency) + .where( + and( + eq(schema.deploymentDependency.deploymentId, deploymentId), + eq( + schema.deploymentDependency.dependencyDeploymentId, + dependencyDeploymentId, + ), + ), + ) + .returning() + .then(takeFirstOrNull); + + if (deleted == null) + throw new ApiError("Deployment dependency not found", 404); + + enqueueReleaseTargetsForDeployment(db, workspaceId, deploymentId); + + res.status(202).json({ + id: deploymentId, + message: "Deployment dependency delete requested", + }); +}; + +const upsertDeploymentDependency: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/dependencies/{dependencyDeploymentId}", + "put" +> = async (req, res) => { + const { workspaceId, deploymentId, dependencyDeploymentId } = req.params; + const { versionSelector } = req.body; + + if (deploymentId === dependencyDeploymentId) + throw new ApiError("A deployment cannot depend on itself", 400); + + if (!validResourceSelector(versionSelector)) + throw new ApiError("Invalid versionSelector CEL expression", 400); + + const deployments = await db + .select({ id: schema.deployment.id }) + .from(schema.deployment) + .where( + and( + eq(schema.deployment.workspaceId, workspaceId), + inArray(schema.deployment.id, [deploymentId, dependencyDeploymentId]), + ), + ); + + const found = new Set(deployments.map((d) => d.id)); + if (!found.has(deploymentId)) throw new ApiError("Deployment not found", 404); + if (!found.has(dependencyDeploymentId)) + throw new ApiError("Dependency deployment not found", 404); + + await db + .insert(schema.deploymentDependency) + .values({ + deploymentId, + dependencyDeploymentId, + versionSelector, + }) + .onConflictDoUpdate({ + target: [ + schema.deploymentDependency.deploymentId, + schema.deploymentDependency.dependencyDeploymentId, + ], + set: { versionSelector }, + }); + + enqueueReleaseTargetsForDeployment(db, workspaceId, deploymentId); + + res.status(202).json({ + id: deploymentId, + message: "Deployment dependency upsert requested", + }); +}; + const deleteDeployment: AsyncTypedHandler< "/v1/workspaces/{workspaceId}/deployments/{deploymentId}", "delete" @@ -532,6 +656,15 @@ export const deploymentsRouter = Router({ mergeParams: true }) .get("/name/:name", asyncHandler(getDeploymentByName)) .get("/:deploymentId", asyncHandler(getDeployment)) .put("/:deploymentId", asyncHandler(upsertDeployment)) + .get("/:deploymentId/dependencies", asyncHandler(listDeploymentDependencies)) + .put( + "/:deploymentId/dependencies/:dependencyDeploymentId", + asyncHandler(upsertDeploymentDependency), + ) + .delete( + "/:deploymentId/dependencies/:dependencyDeploymentId", + asyncHandler(deleteDeploymentDependency), + ) .delete("/:deploymentId", asyncHandler(deleteDeployment)) .get("/:deploymentId/versions", asyncHandler(listDeploymentVersions)) .post("/:deploymentId/versions", asyncHandler(createDeploymentVersion)) diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index a553b3ac9..1d7114acc 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -209,6 +209,47 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/dependencies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List deployment dependencies + * @description Returns the dependency edges declared by this deployment. + */ + get: operations["listDeploymentDependencies"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/dependencies/{dependencyDeploymentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upsert deployment dependency + * @description Declare or update a version-selector dependency from this deployment to another deployment. Identified by the (deploymentId, dependencyDeploymentId) pair. + */ + put: operations["requestDeploymentDependencyUpsert"]; + post?: never; + /** Delete deployment dependency */ + delete: operations["requestDeploymentDependencyDeletion"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/plan": { parameters: { query?: never; @@ -1269,6 +1310,12 @@ export interface components { deployment: components["schemas"]["Deployment"]; systems: components["schemas"]["System"][]; }; + DeploymentDependency: { + dependencyDeploymentId: string; + deploymentId: string; + /** @description CEL expression evaluated against the dependency deployment's current release version on the same resource. */ + versionSelector: string; + }; DeploymentDependencyRule: { /** @description CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. The expression can reference both deployment properties (deployment.id, deployment.name, deployment.slug, deployment.metadata) and the currently deployed version properties (version.id, version.tag, version.name, version.status, version.metadata, version.createdAt). For example: deployment.name == 'db-migration' && version.tag.startsWith('v2.'). */ dependsOn: string; @@ -1991,6 +2038,10 @@ export interface components { /** @description URL-friendly unique identifier (lowercase, no spaces) */ slug?: string; }; + UpsertDeploymentDependencyRequest: { + /** @description CEL expression evaluated against the dependency deployment's current release version on the same resource. */ + versionSelector: string; + }; UpsertDeploymentRequest: { description?: string; jobAgentConfig?: { @@ -3248,6 +3299,125 @@ export interface operations { }; }; }; + listDeploymentDependencies: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the deployment */ + deploymentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentDependency"][]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + requestDeploymentDependencyUpsert: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the deployment */ + deploymentId: string; + /** @description ID of the dependency deployment */ + dependencyDeploymentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertDeploymentDependencyRequest"]; + }; + }; + 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"]; + }; + }; + }; + }; + requestDeploymentDependencyDeletion: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the deployment */ + deploymentId: 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 Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; createDeploymentPlan: { parameters: { query?: never; From b840ad6330c4d847b646ec876ff281c6a8a94371 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 29 Apr 2026 11:23:12 -0400 Subject: [PATCH 2/2] cleanup --- apps/api/openapi/openapi.json | 10 ++++++ apps/api/openapi/paths/deployments.jsonnet | 3 +- .../src/routes/v1/workspaces/deployments.ts | 36 +++++++++++-------- apps/api/src/types/openapi.ts | 9 +++++ 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index df90c16eb..7b50a5a4b 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -4932,6 +4932,16 @@ }, "description": "Accepted response" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, "404": { "content": { "application/json": { diff --git a/apps/api/openapi/paths/deployments.jsonnet b/apps/api/openapi/paths/deployments.jsonnet index ae52ea468..f120b8b0e 100644 --- a/apps/api/openapi/paths/deployments.jsonnet +++ b/apps/api/openapi/paths/deployments.jsonnet @@ -135,7 +135,8 @@ local openapi = import '../lib/openapi.libsonnet'; openapi.stringParam('dependencyDeploymentId', 'ID of the dependency deployment'), ], responses: openapi.acceptedResponse(openapi.schemaRef('DeploymentRequestAccepted')) - + openapi.notFoundResponse(), + + openapi.notFoundResponse() + + openapi.badRequestResponse(), }, }, '/v1/workspaces/{workspaceId}/deployments/{deploymentId}/plan': { diff --git a/apps/api/src/routes/v1/workspaces/deployments.ts b/apps/api/src/routes/v1/workspaces/deployments.ts index 20dafcbed..0351cfbf7 100644 --- a/apps/api/src/routes/v1/workspaces/deployments.ts +++ b/apps/api/src/routes/v1/workspaces/deployments.ts @@ -381,20 +381,28 @@ const upsertDeploymentDependency: AsyncTypedHandler< if (!found.has(dependencyDeploymentId)) throw new ApiError("Dependency deployment not found", 404); - await db - .insert(schema.deploymentDependency) - .values({ - deploymentId, - dependencyDeploymentId, - versionSelector, - }) - .onConflictDoUpdate({ - target: [ - schema.deploymentDependency.deploymentId, - schema.deploymentDependency.dependencyDeploymentId, - ], - set: { versionSelector }, - }); + try { + await db + .insert(schema.deploymentDependency) + .values({ + deploymentId, + dependencyDeploymentId, + versionSelector, + }) + .onConflictDoUpdate({ + target: [ + schema.deploymentDependency.deploymentId, + schema.deploymentDependency.dependencyDeploymentId, + ], + set: { versionSelector }, + }); + } catch (error: any) { + // Rare race: a referenced deployment was deleted between the preflight + // check and the insert. Surface as 404 to match the preflight outcome. + if (error.code === "23503") + throw new ApiError("Deployment not found", 404); + throw error; + } enqueueReleaseTargetsForDeployment(db, workspaceId, deploymentId); diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index 1d7114acc..a429bccac 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -3407,6 +3407,15 @@ export interface operations { "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: {