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
51 changes: 2 additions & 49 deletions apps/api/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,10 @@
"additionalProperties": true,
"type": "object"
},
"jobAgentId": {
"type": "string"
},
"jobAgentSelector": {
"description": "CEL expression to match job agents. Defaults to jobAgent.id == \"<jobAgentId>\" if not provided.",
"description": "CEL expression to match job agents",
"type": "string"
},
Comment on lines 51 to 57
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This OpenAPI change removes jobAgentId/jobAgents from deployment requests/responses. Please ensure any generated clients/schemas are regenerated and any tests updated accordingly (e.g. the generated TS client types and e2e tests that still reference DeploymentJobAgent / jobAgents).

Copilot uses AI. Check for mistakes.
"jobAgents": {
"items": {
"$ref": "#/components/schemas/DeploymentJobAgent"
},
"type": "array"
},
"metadata": {
"additionalProperties": {
"type": "string"
Expand Down Expand Up @@ -456,19 +447,10 @@
"additionalProperties": true,
"type": "object"
},
"jobAgentId": {
"type": "string"
},
"jobAgentSelector": {
"description": "CEL expression to match job agents",
"type": "string"
},
"jobAgents": {
"items": {
"$ref": "#/components/schemas/DeploymentJobAgent"
},
"type": "array"
},
"metadata": {
"additionalProperties": {
"type": "string"
Expand Down Expand Up @@ -525,26 +507,6 @@
],
"type": "object"
},
"DeploymentJobAgent": {
"properties": {
"config": {
"$ref": "#/components/schemas/JobAgentConfig"
},
"ref": {
"type": "string"
},
"selector": {
"description": "CEL expression to determine if the job agent should be used",
"type": "string"
}
},
"required": [
"ref",
"config",
"selector"
],
"type": "object"
},
"DeploymentPlan": {
"properties": {
"id": {
Expand Down Expand Up @@ -2593,19 +2555,10 @@
"additionalProperties": true,
"type": "object"
},
"jobAgentId": {
"type": "string"
},
"jobAgentSelector": {
"description": "CEL expression to match job agents. Defaults to jobAgent.id == \"<jobAgentId>\" if not provided.",
"description": "CEL expression to match job agents",
"type": "string"
},
"jobAgents": {
"items": {
"$ref": "#/components/schemas/DeploymentJobAgent"
},
"type": "array"
},
"metadata": {
"additionalProperties": {
"type": "string"
Expand Down
19 changes: 2 additions & 17 deletions apps/api/openapi/schemas/deployments.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@ local jobAgentConfig = {
};

{
DeploymentJobAgent: {
type: 'object',
required: ['ref', 'config', 'selector'],
properties: {
ref: { type: 'string' },
config: openapi.schemaRef('JobAgentConfig'),
selector: { type: 'string', description: 'CEL expression to determine if the job agent should be used' },
},
},

CreateDeploymentRequest: {
type: 'object',
Expand All @@ -23,10 +14,8 @@ local jobAgentConfig = {
name: { type: 'string' },
slug: { type: 'string' },
description: { type: 'string' },
jobAgentId: { type: 'string' },
jobAgentSelector: { type: 'string', description: 'CEL expression to match job agents. Defaults to jobAgent.id == "<jobAgentId>" if not provided.' },
jobAgentSelector: { type: 'string', description: 'CEL expression to match job agents' },
jobAgentConfig: jobAgentConfig,
jobAgents: { type: 'array', items: openapi.schemaRef('DeploymentJobAgent') },
resourceSelector: { type: 'string', description: 'CEL expression to determine if the deployment should be used' },
metadata: { type: 'object', additionalProperties: { type: 'string' } },
},
Expand All @@ -39,10 +28,8 @@ local jobAgentConfig = {
name: { type: 'string' },
slug: { type: 'string' },
description: { type: 'string' },
jobAgentId: { type: 'string' },
jobAgentSelector: { type: 'string', description: 'CEL expression to match job agents. Defaults to jobAgent.id == "<jobAgentId>" if not provided.' },
jobAgentSelector: { type: 'string', description: 'CEL expression to match job agents' },
jobAgentConfig: jobAgentConfig,
jobAgents: { type: 'array', items: openapi.schemaRef('DeploymentJobAgent') },
resourceSelector: { type: 'string', description: 'CEL expression to determine if the deployment should be used' },
metadata: { type: 'object', additionalProperties: { type: 'string' } },
},
Expand All @@ -65,10 +52,8 @@ local jobAgentConfig = {
name: { type: 'string' },
slug: { type: 'string' },
description: { type: 'string' },
jobAgentId: { type: 'string' },
jobAgentSelector: { type: 'string', description: 'CEL expression to match job agents' },
jobAgentConfig: jobAgentConfig,
jobAgents: { type: 'array', items: openapi.schemaRef('DeploymentJobAgent') },
resourceSelector: { type: 'string', description: 'CEL expression to determine if the deployment should be used' },
metadata: { type: 'object', additionalProperties: { type: 'string' } },
},
Expand Down
123 changes: 7 additions & 116 deletions apps/api/src/routes/v1/workspaces/deployments.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { AsyncTypedHandler } from "@/types/api.js";
import { ApiError, asyncHandler } from "@/types/api.js";
import { Router } from "express";
import _ from "lodash";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";

import { and, asc, count, desc, eq, inArray, takeFirst } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
Expand All @@ -21,22 +19,6 @@ import { listDeploymentVariablesByDeploymentRouter } from "./deployment-variable

const PLAN_TTL_MS = 60 * 60 * 1000;

const getJobAgentsByDeploymentId = async (
deploymentIds: string[],
): Promise<Record<string, { ref: string; config: Record<string, any> }[]>> => {
if (deploymentIds.length === 0) return {};
const links = await db
.select()
.from(schema.deploymentJobAgent)
.where(inArray(schema.deploymentJobAgent.deploymentId, deploymentIds));
return _.chain(links)
.groupBy((l) => l.deploymentId)
.mapValues((group) =>
group.map((l) => ({ ref: l.jobAgentId, config: l.config })),
)
.value();
};

const parseSelector = (raw: string | null | undefined): string | undefined => {
if (raw == null || raw === "false") return undefined;
return raw;
Expand Down Expand Up @@ -126,14 +108,8 @@ const listDeployments: AsyncTypedHandler<
systemsByDeploymentId.set(link.deploymentId, arr);
}

const agentsByDeploymentId = await getJobAgentsByDeploymentId(deploymentIds);
const items = deployments.map((dep) => ({
deployment: {
...formatDeployment(dep),
jobAgents: (agentsByDeploymentId[dep.id] ?? []).map(
({ ref, config }) => ({ ref, config }),
),
},
deployment: formatDeployment(dep),
systems: (systemsByDeploymentId.get(dep.id) ?? []).map(formatSystem),
}));
Comment on lines 108 to 114
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatDeployment() can omit jobAgentSelector when the stored value is "false" (via parseSelector), but the OpenAPI schema marks jobAgentSelector as required. This will cause responses from listDeployments/getDeployment to violate the contract. Consider always returning the raw selector string (including "false") or updating the OpenAPI schema to make jobAgentSelector optional if omission is intended.

Copilot uses AI. Check for mistakes.

Expand All @@ -155,8 +131,6 @@ const getDeployment: AsyncTypedHandler<

if (dep == null) throw new ApiError("Deployment not found", 404);

const agentsByDeploymentId = await getJobAgentsByDeploymentId([deploymentId]);

const systemRows = await db
.select({ system: schema.system })
.from(schema.systemDeployment)
Expand Down Expand Up @@ -196,12 +170,7 @@ const getDeployment: AsyncTypedHandler<
}

res.status(200).json({
deployment: {
...formatDeployment(dep),
jobAgents: (agentsByDeploymentId[dep.id] ?? []).map(
({ ref, config }) => ({ ref, config }),
),
},
deployment: formatDeployment(dep),
systems: systemRows.map((r) => formatSystem(r.system)),
variables: variables.map((v) => ({
variable: {
Expand Down Expand Up @@ -234,48 +203,17 @@ const postDeployment: AsyncTypedHandler<

const id = uuidv4();

const jobAgentId = body.jobAgentId ?? body.jobAgents?.[0]?.ref;
if (jobAgentId != null && !z.string().uuid().safeParse(jobAgentId).success)
throw new ApiError("Invalid job agent ID", 400);

const jobAgentConfig =
body.jobAgentConfig ?? body.jobAgents?.[0]?.config ?? {};

await db.insert(schema.deployment).values({
id,
name: body.name,
description: body.description ?? "",
resourceSelector: body.resourceSelector ?? "false",
jobAgentSelector:
body.jobAgentSelector ??
(jobAgentId ? `jobAgent.id == "${jobAgentId}"` : "false"),
jobAgentConfig,
jobAgentSelector: body.jobAgentSelector ?? "false",
jobAgentConfig: body.jobAgentConfig ?? {},
metadata: body.metadata ?? {},
workspaceId,
});

if (jobAgentId)
await db
.insert(schema.deploymentJobAgent)
.values({
deploymentId: id,
jobAgentId,
config: jobAgentConfig,
})
.onConflictDoNothing();

if (body.jobAgents != null && body.jobAgents.length > 0)
await db
.insert(schema.deploymentJobAgent)
.values(
body.jobAgents.map((agent) => ({
deploymentId: id,
jobAgentId: agent.ref,
config: agent.config,
})),
)
.onConflictDoNothing();

enqueueReleaseTargetsForDeployment(db, workspaceId, id);

res.status(202).json({ id, message: "Deployment creation requested" });
Expand All @@ -291,12 +229,7 @@ const upsertDeployment: AsyncTypedHandler<
const isValid = validResourceSelector(body.resourceSelector);
if (!isValid) throw new ApiError("Invalid resource selector", 400);

const jobAgentId = body.jobAgentId ?? body.jobAgents?.[0]?.ref;
if (jobAgentId != null && !z.string().uuid().safeParse(jobAgentId).success)
throw new ApiError("Invalid job agent ID", 400);

const jobAgentConfig =
body.jobAgentConfig ?? body.jobAgents?.[0]?.config ?? {};
const jobAgentConfig = body.jobAgentConfig ?? {};

await db
.insert(schema.deployment)
Expand All @@ -305,9 +238,7 @@ const upsertDeployment: AsyncTypedHandler<
name: body.name,
description: body.description ?? "",
resourceSelector: body.resourceSelector ?? "false",
jobAgentSelector:
body.jobAgentSelector ??
(jobAgentId ? `jobAgent.id == "${jobAgentId}"` : "false"),
jobAgentSelector: body.jobAgentSelector ?? "false",
jobAgentConfig,
metadata: body.metadata ?? {},
workspaceId,
Expand All @@ -321,50 +252,10 @@ const upsertDeployment: AsyncTypedHandler<
metadata: body.metadata ?? {},
...(body.jobAgentSelector != null
? { jobAgentSelector: body.jobAgentSelector, jobAgentConfig }
: jobAgentId != null
? {
jobAgentSelector: `jobAgent.id == "${jobAgentId}"`,
jobAgentConfig,
}
: {}),
: {}),
},
});

if (jobAgentId)
await db
.insert(schema.deploymentJobAgent)
.values({
deploymentId,
jobAgentId,
config: jobAgentConfig,
})
.onConflictDoUpdate({
target: [
schema.deploymentJobAgent.deploymentId,
schema.deploymentJobAgent.jobAgentId,
],
set: { config: jobAgentConfig },
});

if (body.jobAgents != null)
await db.transaction(async (tx) => {
await tx
.delete(schema.deploymentJobAgent)
.where(eq(schema.deploymentJobAgent.deploymentId, deploymentId));

if (body.jobAgents!.length > 0)
await tx
.insert(schema.deploymentJobAgent)
.values(
body.jobAgents!.map((agent) => ({
deploymentId,
jobAgentId: agent.ref,
config: agent.config,
})),
)
.onConflictDoNothing();
});

enqueueReleaseTargetsForDeployment(db, workspaceId, deploymentId);

res
Expand Down
16 changes: 2 additions & 14 deletions apps/api/src/types/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1063,10 +1063,8 @@ export interface components {
jobAgentConfig?: {
[key: string]: unknown;
};
jobAgentId?: string;
/** @description CEL expression to match job agents. Defaults to jobAgent.id == "<jobAgentId>" if not provided. */
/** @description CEL expression to match job agents */
jobAgentSelector?: string;
jobAgents?: components["schemas"]["DeploymentJobAgent"][];
metadata?: {
[key: string]: string;
};
Expand Down Expand Up @@ -1223,10 +1221,8 @@ export interface components {
jobAgentConfig: {
[key: string]: unknown;
};
jobAgentId?: string;
/** @description CEL expression to match job agents */
jobAgentSelector: string;
jobAgents?: components["schemas"]["DeploymentJobAgent"][];
metadata?: {
[key: string]: string;
};
Expand All @@ -1243,12 +1239,6 @@ export interface components {
/** @description CEL expression to match upstream deployment(s) that must have a successful release before this deployment can proceed. */
dependsOn: string;
};
DeploymentJobAgent: {
config: components["schemas"]["JobAgentConfig"];
ref: string;
/** @description CEL expression to determine if the job agent should be used */
selector: string;
};
DeploymentPlan: {
id: string;
/** @enum {string} */
Expand Down Expand Up @@ -1961,10 +1951,8 @@ export interface components {
jobAgentConfig?: {
[key: string]: unknown;
};
jobAgentId?: string;
/** @description CEL expression to match job agents. Defaults to jobAgent.id == "<jobAgentId>" if not provided. */
/** @description CEL expression to match job agents */
jobAgentSelector?: string;
jobAgents?: components["schemas"]["DeploymentJobAgent"][];
metadata?: {
[key: string]: string;
};
Expand Down
Loading
Loading