Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
454 changes: 227 additions & 227 deletions apps/api/openapi/openapi.json

Large diffs are not rendered by default.

51 changes: 0 additions & 51 deletions apps/api/openapi/paths/deployments.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -88,57 +88,6 @@ 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',
Expand Down
51 changes: 51 additions & 0 deletions apps/api/openapi/paths/deploymentversions.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,55 @@ local openapi = import '../lib/openapi.libsonnet';
+ openapi.badRequestResponse(),
},
},
'/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies': {
get: {
summary: 'List deployment-version dependencies',
description: "Returns the dependency edges declared by this deployment version.",
operationId: 'listDeploymentVersionDependencies',
parameters: [
openapi.workspaceIdParam(),
openapi.deploymentVersionIdParam(),
],
responses: openapi.okResponse({
type: 'array',
items: openapi.schemaRef('DeploymentVersionDependency'),
})
+ openapi.notFoundResponse(),
},
},
'/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}': {
put: {
summary: '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.',
operationId: 'requestDeploymentVersionDependencyUpsert',
parameters: [
openapi.workspaceIdParam(),
openapi.deploymentVersionIdParam(),
openapi.stringParam('dependencyDeploymentId', 'ID of the dependency deployment'),
],
requestBody: {
required: true,
content: {
'application/json': {
schema: openapi.schemaRef('UpsertDeploymentVersionDependencyRequest'),
},
},
},
responses: openapi.acceptedResponse(openapi.schemaRef('DeploymentRequestAccepted'))
+ openapi.notFoundResponse()
+ openapi.badRequestResponse(),
},
delete: {
summary: 'Delete deployment-version dependency',
operationId: 'requestDeploymentVersionDependencyDeletion',
parameters: [
openapi.workspaceIdParam(),
openapi.deploymentVersionIdParam(),
openapi.stringParam('dependencyDeploymentId', 'ID of the dependency deployment'),
],
responses: openapi.acceptedResponse(openapi.schemaRef('DeploymentRequestAccepted'))
+ openapi.notFoundResponse()
+ openapi.badRequestResponse(),
},
},
}
8 changes: 4 additions & 4 deletions apps/api/openapi/schemas/deployments.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ local jobAgentConfig = {
},
},

UpsertDeploymentDependencyRequest: {
UpsertDeploymentVersionDependencyRequest: {
type: 'object',
required: ['versionSelector'],
properties: {
Expand All @@ -46,11 +46,11 @@ local jobAgentConfig = {
},
},

DeploymentDependency: {
DeploymentVersionDependency: {
type: 'object',
required: ['deploymentId', 'dependencyDeploymentId', 'versionSelector'],
required: ['deploymentVersionId', 'dependencyDeploymentId', 'versionSelector'],
properties: {
deploymentId: { type: 'string' },
deploymentVersionId: { type: 'string' },
dependencyDeploymentId: { type: 'string' },
versionSelector: {
type: 'string',
Expand Down
168 changes: 166 additions & 2 deletions apps/api/src/routes/v1/workspaces/deployment-versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import type { AsyncTypedHandler } from "@/types/api.js";
import { ApiError, asyncHandler } from "@/types/api.js";
import { Router } from "express";

import { and, eq, takeFirst, takeFirstOrNull } from "@ctrlplane/db";
import { and, asc, eq, takeFirst, takeFirstOrNull } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import { enqueueReleaseTargetsForDeployment } from "@ctrlplane/db/reconcilers";
import * as schema from "@ctrlplane/db/schema";

import { validResourceSelector } from "../valid-selector.js";

const upsertUserApprovalRecord: AsyncTypedHandler<
"/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/user-approval-records",
"put"
Expand Down Expand Up @@ -109,9 +111,171 @@ const updateDeploymentVersion: AsyncTypedHandler<
res.status(200).json(updatedVersion);
};

// loadVersionInWorkspace returns the deployment_version row joined with its
// owning deployment, scoped to the requested workspace. Used by the dependency
// endpoints to enforce tenant isolation: a version is reachable only via its
// deployment's workspace_id.
const loadVersionInWorkspace = async (
workspaceId: string,
deploymentVersionId: string,
) =>
db
.select({ version: schema.deploymentVersion, deployment: schema.deployment })
.from(schema.deploymentVersion)
.innerJoin(
schema.deployment,
eq(schema.deploymentVersion.deploymentId, schema.deployment.id),
)
.where(
and(
eq(schema.deploymentVersion.id, deploymentVersionId),
eq(schema.deployment.workspaceId, workspaceId),
),
)
.then(takeFirstOrNull);

const listDeploymentVersionDependencies: AsyncTypedHandler<
"/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies",
"get"
> = async (req, res) => {
const { workspaceId, deploymentVersionId } = req.params;

const found = await loadVersionInWorkspace(workspaceId, deploymentVersionId);
if (found == null) throw new ApiError("Deployment version not found", 404);

const rows = await db
.select()
.from(schema.deploymentVersionDependency)
.where(
eq(
schema.deploymentVersionDependency.deploymentVersionId,
deploymentVersionId,
),
)
.orderBy(asc(schema.deploymentVersionDependency.dependencyDeploymentId));

res.status(200).json(rows);
};

const upsertDeploymentVersionDependency: AsyncTypedHandler<
"/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}",
"put"
> = async (req, res) => {
Comment on lines +137 to +163
const { workspaceId, deploymentVersionId, dependencyDeploymentId } =
req.params;
const { versionSelector } = req.body;

if (!validResourceSelector(versionSelector))
throw new ApiError("Invalid versionSelector CEL expression", 400);

const found = await loadVersionInWorkspace(workspaceId, deploymentVersionId);
if (found == null) throw new ApiError("Deployment version not found", 404);

if (found.deployment.id === dependencyDeploymentId)
throw new ApiError(
"A deployment version cannot depend on its own deployment",
400,
);

const targetDeployment = await db.query.deployment.findFirst({
where: and(
eq(schema.deployment.id, dependencyDeploymentId),
eq(schema.deployment.workspaceId, workspaceId),
),
});
if (targetDeployment == null)
throw new ApiError("Dependency deployment not found", 404);

try {
await db
.insert(schema.deploymentVersionDependency)
.values({
deploymentVersionId,
dependencyDeploymentId,
versionSelector,
})
.onConflictDoUpdate({
target: [
schema.deploymentVersionDependency.deploymentVersionId,
schema.deploymentVersionDependency.dependencyDeploymentId,
],
set: { versionSelector },
});
} catch (error: any) {
if (error.code === "23503")
throw new ApiError("Deployment version or deployment not found", 404);
throw error;
}

void enqueueReleaseTargetsForDeployment(
db,
workspaceId,
found.deployment.id,
).catch(console.error);

res.status(202).json({
id: deploymentVersionId,
message: "Deployment version dependency upsert requested",
});
};

const deleteDeploymentVersionDependency: AsyncTypedHandler<
"/v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}/dependencies/{dependencyDeploymentId}",
"delete"
> = async (req, res) => {
const { workspaceId, deploymentVersionId, dependencyDeploymentId } =
req.params;

const found = await loadVersionInWorkspace(workspaceId, deploymentVersionId);
if (found == null) throw new ApiError("Deployment version not found", 404);

const deleted = await db
.delete(schema.deploymentVersionDependency)
.where(
and(
eq(
schema.deploymentVersionDependency.deploymentVersionId,
deploymentVersionId,
),
eq(
schema.deploymentVersionDependency.dependencyDeploymentId,
dependencyDeploymentId,
),
),
)
.returning()
.then(takeFirstOrNull);

if (deleted == null)
throw new ApiError("Deployment version dependency not found", 404);

void enqueueReleaseTargetsForDeployment(
db,
workspaceId,
found.deployment.id,
).catch(console.error);

res.status(202).json({
id: deploymentVersionId,
message: "Deployment version dependency delete requested",
});
};

export const deploymentVersionsRouter = Router({ mergeParams: true })
.put(
"/:deploymentVersionId/user-approval-records",
asyncHandler(upsertUserApprovalRecord),
)
.patch("/:deploymentVersionId", asyncHandler(updateDeploymentVersion));
.patch("/:deploymentVersionId", asyncHandler(updateDeploymentVersion))
.get(
"/:deploymentVersionId/dependencies",
asyncHandler(listDeploymentVersionDependencies),
)
.put(
"/:deploymentVersionId/dependencies/:dependencyDeploymentId",
asyncHandler(upsertDeploymentVersionDependency),
)
.delete(
"/:deploymentVersionId/dependencies/:dependencyDeploymentId",
asyncHandler(deleteDeploymentVersionDependency),
);
Loading
Loading