diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index bdcd93b0f..7b50a5a4b 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,201 @@ "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" + }, + "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": "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..f120b8b0e 100644 --- a/apps/api/openapi/paths/deployments.jsonnet +++ b/apps/api/openapi/paths/deployments.jsonnet @@ -88,6 +88,57 @@ 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() + + openapi.badRequestResponse(), + }, + }, '/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..0351cfbf7 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,129 @@ 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); + + 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); + + res.status(202).json({ + id: deploymentId, + message: "Deployment dependency upsert requested", + }); +}; + const deleteDeployment: AsyncTypedHandler< "/v1/workspaces/{workspaceId}/deployments/{deploymentId}", "delete" @@ -532,6 +664,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..a429bccac 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,134 @@ 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 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"]; + }; + }; + }; + }; createDeploymentPlan: { parameters: { query?: never;