diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/EnvironmentDeploymentsPageContent.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/EnvironmentDeploymentsPageContent.tsx index 20a20d93b..9391605d4 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/EnvironmentDeploymentsPageContent.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/environments/[environmentId]/deployments/EnvironmentDeploymentsPageContent.tsx @@ -1,5 +1,6 @@ "use client"; +import type { JobCondition } from "@ctrlplane/validators/jobs"; import React, { useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { IconSearch } from "@tabler/icons-react"; @@ -26,7 +27,7 @@ import { TableRow, } from "@ctrlplane/ui/table"; import { ColumnOperator } from "@ctrlplane/validators/conditions"; -import { JobCondition, JobConditionType } from "@ctrlplane/validators/jobs"; +import { JobConditionType } from "@ctrlplane/validators/jobs"; import { urls } from "~/app/urls"; import { api } from "~/trpc/react"; diff --git a/apps/webservice/src/app/api/v1/deployment-version-channels/openapi.ts b/apps/webservice/src/app/api/v1/deployment-version-channels/openapi.ts new file mode 100644 index 000000000..749f4e196 --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployment-version-channels/openapi.ts @@ -0,0 +1,116 @@ +import type { Swagger } from "atlassian-openapi"; + +export const openapi: Swagger.SwaggerV3 = { + openapi: "3.0.0", + info: { + title: "Ctrlplane API", + version: "1.0.0", + }, + paths: { + "/v1/deployment-version-channels": { + post: { + summary: "Create a deployment version channel", + operationId: "createDeploymentVersionChannel", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["deploymentId", "name", "versionSelector"], + properties: { + deploymentId: { type: "string" }, + name: { type: "string" }, + description: { type: "string", nullable: true }, + versionSelector: { + type: "object", + additionalProperties: true, + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Deployment version channel created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string" }, + deploymentId: { type: "string" }, + name: { type: "string" }, + description: { type: "string", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + versionSelector: { + type: "object", + additionalProperties: true, + }, + }, + required: ["id", "deploymentId", "name", "createdAt"], + }, + }, + }, + }, + "409": { + description: "Deployment version channel already exists", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { type: "string" }, + id: { type: "string" }, + }, + required: ["error", "id"], + }, + }, + }, + }, + "500": { + description: "Failed to create deployment version channel", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + "403": { + description: "Forbidden", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + }, + security: [{ bearerAuth: [] }], + }, + }, + }, + components: { + securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } }, + }, +}; diff --git a/apps/webservice/src/app/api/v1/deployment-version-channels/route.ts b/apps/webservice/src/app/api/v1/deployment-version-channels/route.ts new file mode 100644 index 000000000..8d4db4137 --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployment-version-channels/route.ts @@ -0,0 +1,53 @@ +import type { z } from "zod"; +import { NextResponse } from "next/server"; + +import { buildConflictUpdateColumns, takeFirst } from "@ctrlplane/db"; +import { createDeploymentVersionChannel } from "@ctrlplane/db/schema"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { logger } from "@ctrlplane/logger"; +import { Permission } from "@ctrlplane/validators/auth"; + +import { authn, authz } from "../auth"; +import { parseBody } from "../body-parser"; +import { request } from "../middleware"; + +const schema = createDeploymentVersionChannel; + +export const POST = request() + .use(authn) + .use(parseBody(schema)) + .use( + authz(({ ctx, can }) => + can + .perform(Permission.DeploymentVersionChannelCreate) + .on({ type: "deployment", id: ctx.body.deploymentId }), + ), + ) + .handle<{ body: z.infer }>(({ db, body }) => { + const { versionSelector } = body; + + return db + .insert(SCHEMA.deploymentVersionChannel) + .values({ ...body, versionSelector }) + .onConflictDoUpdate({ + target: [ + SCHEMA.deploymentVersionChannel.deploymentId, + SCHEMA.deploymentVersionChannel.name, + ], + set: buildConflictUpdateColumns(SCHEMA.deploymentVersionChannel, [ + "versionSelector", + ]), + }) + .returning() + .then(takeFirst) + .then((deploymentVersionChannel) => + NextResponse.json(deploymentVersionChannel), + ) + .catch((error) => { + logger.error(error); + return NextResponse.json( + { error: "Failed to create deployment version channel" }, + { status: 500 }, + ); + }); + }); diff --git a/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/openapi.ts b/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/openapi.ts new file mode 100644 index 000000000..a112b54f5 --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/openapi.ts @@ -0,0 +1,65 @@ +import type { Swagger } from "atlassian-openapi"; + +import { DeploymentVersionStatus } from "@ctrlplane/validators/releases"; + +export const openapi: Swagger.SwaggerV3 = { + openapi: "3.0.0", + info: { title: "Ctrlplane API", version: "1.0.0" }, + paths: { + "/v1/deployment-versions/{deploymentVersionId}": { + patch: { + summary: "Updates a deployment version", + operationId: "updateDeploymentVersion", + parameters: [ + { + name: "deploymentVersionId", + in: "path", + required: true, + schema: { type: "string" }, + description: "The deployment version ID", + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + tag: { type: "string" }, + deploymentId: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + name: { type: "string" }, + config: { type: "object", additionalProperties: true }, + jobAgentConfig: { + type: "object", + additionalProperties: true, + }, + status: { + type: "string", + enum: Object.values(DeploymentVersionStatus), + }, + message: { type: "string" }, + metadata: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/DeploymentVersion" }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/route.ts b/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/route.ts new file mode 100644 index 000000000..ba7feba91 --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import httpStatus from "http-status"; +import { z } from "zod"; + +import { buildConflictUpdateColumns, eq, takeFirst } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { + cancelOldReleaseJobTriggersOnJobDispatch, + createJobApprovals, + createReleaseJobTriggers, + dispatchReleaseJobTriggers, + isPassingAllPolicies, + isPassingChannelSelectorPolicy, +} from "@ctrlplane/job-dispatch"; +import { logger } from "@ctrlplane/logger"; +import { Permission } from "@ctrlplane/validators/auth"; + +import { authn, authz } from "../../auth"; +import { parseBody } from "../../body-parser"; +import { request } from "../../middleware"; + +const patchSchema = SCHEMA.updateDeploymentVersion.and( + z.object({ metadata: z.record(z.string()).optional() }), +); + +export const PATCH = request() + .use(authn) + .use(parseBody(patchSchema)) + .use( + authz(({ can, extra: { params } }) => + can + .perform(Permission.DeploymentVersionUpdate) + .on({ type: "deploymentVersion", id: params.deploymentVersionId }), + ), + ) + .handle< + { body: z.infer; user: SCHEMA.User }, + { params: { deploymentVersionId: string } } + >(async (ctx, { params }) => { + const { deploymentVersionId } = params; + const { body, user, req } = ctx; + + try { + const deploymentVersion = await ctx.db + .update(SCHEMA.deploymentVersion) + .set(body) + .where(eq(SCHEMA.deploymentVersion.id, deploymentVersionId)) + .returning() + .then(takeFirst); + + if (Object.keys(body.metadata ?? {}).length > 0) + await ctx.db + .insert(SCHEMA.deploymentVersionMetadata) + .values( + Object.entries(body.metadata ?? {}).map(([key, value]) => ({ + versionId: deploymentVersionId, + key, + value, + })), + ) + .onConflictDoUpdate({ + target: [ + SCHEMA.deploymentVersionMetadata.key, + SCHEMA.deploymentVersionMetadata.versionId, + ], + set: buildConflictUpdateColumns(SCHEMA.deploymentVersionMetadata, [ + "value", + ]), + }); + + await createReleaseJobTriggers(ctx.db, "version_updated") + .causedById(user.id) + .filter(isPassingChannelSelectorPolicy) + .versions([deploymentVersionId]) + .then(createJobApprovals) + .insert() + .then((releaseJobTriggers) => { + dispatchReleaseJobTriggers(ctx.db) + .releaseTriggers(releaseJobTriggers) + .filter(isPassingAllPolicies) + .then(cancelOldReleaseJobTriggersOnJobDispatch) + .dispatch(); + }) + .then(() => + logger.info( + `Version for ${deploymentVersionId} job triggers created and dispatched.`, + req, + ), + ); + + return NextResponse.json(deploymentVersion); + } catch (error) { + logger.error(error); + return NextResponse.json( + { error: "Failed to update version" }, + { status: httpStatus.INTERNAL_SERVER_ERROR }, + ); + } + }); diff --git a/apps/webservice/src/app/api/v1/deployment-versions/openapi.ts b/apps/webservice/src/app/api/v1/deployment-versions/openapi.ts new file mode 100644 index 000000000..4b8c1f543 --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployment-versions/openapi.ts @@ -0,0 +1,74 @@ +import type { Swagger } from "atlassian-openapi"; + +import { DeploymentVersionStatus } from "@ctrlplane/validators/releases"; + +export const openapi: Swagger.SwaggerV3 = { + openapi: "3.0.0", + info: { + title: "Ctrlplane API", + version: "1.0.0", + }, + paths: { + "/v1/deployment-versions": { + post: { + summary: "Upserts a deployment version", + operationId: "upsertDeploymentVersion", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + tag: { type: "string" }, + deploymentId: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + name: { type: "string" }, + config: { type: "object", additionalProperties: true }, + jobAgentConfig: { + type: "object", + additionalProperties: true, + }, + status: { + type: "string", + enum: Object.values(DeploymentVersionStatus), + }, + message: { type: "string" }, + metadata: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + required: ["tag", "deploymentId"], + }, + }, + }, + }, + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/DeploymentVersion" }, + }, + }, + }, + "409": { + description: "Deployment version already exists", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { type: "string" }, + id: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/apps/webservice/src/app/api/v1/deployment-versions/route.ts b/apps/webservice/src/app/api/v1/deployment-versions/route.ts new file mode 100644 index 000000000..871a65a7b --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployment-versions/route.ts @@ -0,0 +1,156 @@ +import { NextResponse } from "next/server"; +import httpStatus from "http-status"; +import { z } from "zod"; + +import { releaseNewVersion } from "@ctrlplane/api/queues"; +import { + and, + buildConflictUpdateColumns, + eq, + takeFirst, + takeFirstOrNull, +} from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; +import { + cancelOldReleaseJobTriggersOnJobDispatch, + createJobApprovals, + createReleaseJobTriggers, + dispatchReleaseJobTriggers, + isPassingAllPolicies, + isPassingChannelSelectorPolicy, +} from "@ctrlplane/job-dispatch"; +import { logger } from "@ctrlplane/logger"; +import { Permission } from "@ctrlplane/validators/auth"; +import { DeploymentVersionStatus } from "@ctrlplane/validators/releases"; + +import { authn, authz } from "../auth"; +import { parseBody } from "../body-parser"; +import { request } from "../middleware"; + +const bodySchema = schema.createDeploymentVersion.and( + z.object({ + metadata: z.record(z.string()).optional(), + status: z.nativeEnum(DeploymentVersionStatus).optional(), + }), +); + +export const POST = request() + .use(authn) + .use(parseBody(bodySchema)) + .use( + authz(({ ctx, can }) => + can + .perform(Permission.DeploymentVersionCreate) + .on({ type: "deployment", id: ctx.body.deploymentId }), + ), + ) + .handle<{ user: schema.User; body: z.infer }>( + async (ctx) => { + const { req, body } = ctx; + const { name, tag, metadata = {} } = body; + + const versionName = name ?? tag; + + try { + const prevVersion = await db + .select() + .from(schema.deploymentVersion) + .where( + and( + eq(schema.deploymentVersion.deploymentId, body.deploymentId), + eq(schema.deploymentVersion.tag, tag), + ), + ) + .then(takeFirstOrNull); + + const depVersion = await db + .insert(schema.deploymentVersion) + .values({ ...body, name: versionName, tag }) + .onConflictDoUpdate({ + target: [ + schema.deploymentVersion.deploymentId, + schema.deploymentVersion.tag, + ], + set: buildConflictUpdateColumns(schema.deploymentVersion, [ + "name", + "status", + "message", + "config", + "jobAgentConfig", + ]), + }) + .returning() + .then(takeFirst); + + if (Object.keys(metadata).length > 0) + await db + .insert(schema.deploymentVersionMetadata) + .values( + Object.entries(metadata).map(([key, value]) => ({ + versionId: depVersion.id, + key, + value, + })), + ) + .onConflictDoUpdate({ + target: [ + schema.deploymentVersionMetadata.versionId, + schema.deploymentVersionMetadata.key, + ], + set: buildConflictUpdateColumns( + schema.deploymentVersionMetadata, + ["value"], + ), + }); + + const shouldTrigger = + prevVersion == null || + (prevVersion.status !== DeploymentVersionStatus.Ready && + depVersion.status === DeploymentVersionStatus.Ready); + + if (shouldTrigger) { + releaseNewVersion.add(depVersion.id, { + versionId: depVersion.id, + }); + + await createReleaseJobTriggers(db, "new_version") + .causedById(ctx.user.id) + .filter(isPassingChannelSelectorPolicy) + .versions([depVersion.id]) + .then(createJobApprovals) + .insert() + .then((releaseJobTriggers) => { + dispatchReleaseJobTriggers(db) + .releaseTriggers(releaseJobTriggers) + .filter(isPassingAllPolicies) + .then(cancelOldReleaseJobTriggersOnJobDispatch) + .dispatch(); + }) + .then(() => + logger.info( + `Release for ${depVersion.id} job triggers created and dispatched.`, + req, + ), + ); + } + + return NextResponse.json( + { ...depVersion, metadata }, + { status: httpStatus.CREATED }, + ); + } catch (error) { + if (error instanceof z.ZodError) + return NextResponse.json( + { error: error.errors }, + { status: httpStatus.BAD_REQUEST }, + ); + + logger.error("Error creating release:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: httpStatus.INTERNAL_SERVER_ERROR }, + ); + } + }, + ); diff --git a/apps/webservice/src/app/api/v1/deployments/[deploymentId]/deployment-version-channels/name/[name]/openapi.ts b/apps/webservice/src/app/api/v1/deployments/[deploymentId]/deployment-version-channels/name/[name]/openapi.ts new file mode 100644 index 000000000..51a5dff4d --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployments/[deploymentId]/deployment-version-channels/name/[name]/openapi.ts @@ -0,0 +1,81 @@ +import type { Swagger } from "atlassian-openapi"; + +export const openapi: Swagger.SwaggerV3 = { + openapi: "3.0.0", + info: { + title: "Ctrlplane API", + version: "1.0.0", + }, + paths: { + "/v1/deployments/{deploymentId}/deployment-version-channels/name/{name}": { + delete: { + summary: "Delete a deployment version channel", + operationId: "deleteDeploymentVersionChannel", + parameters: [ + { + name: "deploymentId", + in: "path", + required: true, + schema: { type: "string" }, + }, + { + name: "name", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Deployment version channel deleted", + content: { + "application/json": { + schema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + }, + }, + }, + "403": { + description: "Permission denied", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + "404": { + description: "Deployment version channel not found", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + "500": { + description: "Failed to delete deployment version channel", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/apps/webservice/src/app/api/v1/deployments/[deploymentId]/deployment-version-channels/name/[name]/route.ts b/apps/webservice/src/app/api/v1/deployments/[deploymentId]/deployment-version-channels/name/[name]/route.ts new file mode 100644 index 000000000..48a72607f --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployments/[deploymentId]/deployment-version-channels/name/[name]/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +import { and, eq } from "@ctrlplane/db"; +import * as schema from "@ctrlplane/db/schema"; +import { Permission } from "@ctrlplane/validators/auth"; + +import { authn, authz } from "~/app/api/v1/auth"; +import { request } from "~/app/api/v1/middleware"; + +export const DELETE = request() + .use(authn) + .use( + authz(async ({ can, extra: { params } }) => + can + .perform(Permission.DeploymentVersionChannelDelete) + .on({ type: "deployment", id: params.deploymentId }), + ), + ) + .handle( + async (ctx, { params }) => { + try { + await ctx.db + .delete(schema.deploymentVersionChannel) + .where( + and( + eq( + schema.deploymentVersionChannel.deploymentId, + params.deploymentId, + ), + eq(schema.deploymentVersionChannel.name, params.name), + ), + ); + + return NextResponse.json( + { message: "Deployment version channel deleted" }, + { status: 200 }, + ); + } catch { + return NextResponse.json( + { error: "Failed to delete deployment version channel" }, + { status: 500 }, + ); + } + }, + ); diff --git a/apps/webservice/src/app/api/v1/environments/openapi.ts b/apps/webservice/src/app/api/v1/environments/openapi.ts index e9db26d92..047a35c98 100644 --- a/apps/webservice/src/app/api/v1/environments/openapi.ts +++ b/apps/webservice/src/app/api/v1/environments/openapi.ts @@ -47,6 +47,12 @@ export const openapi: Swagger.SwaggerV3 = { type: "string", }, }, + deploymentVersionChannels: { + type: "array", + items: { + type: "string", + }, + }, metadata: { type: "object", additionalProperties: { type: "string" }, diff --git a/apps/webservice/src/app/api/v1/environments/route.ts b/apps/webservice/src/app/api/v1/environments/route.ts index 4972853e0..a9f05d7ad 100644 --- a/apps/webservice/src/app/api/v1/environments/route.ts +++ b/apps/webservice/src/app/api/v1/environments/route.ts @@ -16,6 +16,7 @@ import { request } from "../middleware"; const body = schema.createEnvironment.extend({ releaseChannels: z.array(z.string()), + deploymentVersionChannels: z.array(z.string()), }); export const POST = request() @@ -36,7 +37,10 @@ export const POST = request() .select() .from(schema.deploymentVersionChannel) .where( - inArray(schema.deploymentVersionChannel.id, body.releaseChannels), + inArray(schema.deploymentVersionChannel.id, [ + ...body.releaseChannels, + ...body.deploymentVersionChannels, + ]), ) .then((rows) => _.uniqBy(rows, (r) => r.deploymentId).map((r) => ({ diff --git a/apps/webservice/src/app/api/v1/jobs/[jobId]/openapi.ts b/apps/webservice/src/app/api/v1/jobs/[jobId]/openapi.ts index dc2f99912..c2544df2d 100644 --- a/apps/webservice/src/app/api/v1/jobs/[jobId]/openapi.ts +++ b/apps/webservice/src/app/api/v1/jobs/[jobId]/openapi.ts @@ -15,6 +15,9 @@ export const openapi: Swagger.SwaggerV3 = { type: "object", properties: { release: { $ref: "#/components/schemas/Release" }, + deploymentVersion: { + $ref: "#/components/schemas/DeploymentVersion", + }, deployment: { $ref: "#/components/schemas/Deployment" }, runbook: { $ref: "#/components/schemas/Runbook" }, resource: { $ref: "#/components/schemas/Resource" }, diff --git a/apps/webservice/src/app/api/v1/jobs/[jobId]/route.ts b/apps/webservice/src/app/api/v1/jobs/[jobId]/route.ts index 02f82c2fb..c0cbd074d 100644 --- a/apps/webservice/src/app/api/v1/jobs/[jobId]/route.ts +++ b/apps/webservice/src/app/api/v1/jobs/[jobId]/route.ts @@ -101,7 +101,7 @@ export const GET = request() { status: 404 }, ); - const version = + const deploymentVersion = row.deployment_version != null ? { ...row.deployment_version, metadata: {} } : null; @@ -112,14 +112,14 @@ export const GET = request() environment: row.environment, resource: row.resource, deployment: row.deployment, - version, + deploymentVersion, }; const policyId = je.environment?.policyId; const approval = - je.version?.id && policyId - ? await getApprovalDetails(je.version.id, policyId) + je.deploymentVersion?.id && policyId + ? await getApprovalDetails(je.deploymentVersion.id, policyId) : undefined; const jobVariableRows = await db @@ -139,8 +139,8 @@ export const GET = request() ...je, variables, release: - je.version != null - ? { ...je.version, version: je.version.tag } + je.deploymentVersion != null + ? { ...je.deploymentVersion, version: je.deploymentVersion.tag } : { version: undefined }, }; if (je.resource == null) return NextResponse.json(jobWithVariables); diff --git a/apps/webservice/src/app/api/v1/openapi.ts b/apps/webservice/src/app/api/v1/openapi.ts index adcce2fec..21a97f1aa 100644 --- a/apps/webservice/src/app/api/v1/openapi.ts +++ b/apps/webservice/src/app/api/v1/openapi.ts @@ -119,6 +119,28 @@ export const openapi: Swagger.SwaggerV3 = { "jobAgentConfig", ], }, + DeploymentVersion: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + tag: { type: "string" }, + config: { type: "object", additionalProperties: true }, + jobAgentConfig: { type: "object", additionalProperties: true }, + deploymentId: { type: "string", format: "uuid" }, + createdAt: { type: "string", format: "date-time" }, + metadata: { type: "object", additionalProperties: true }, + }, + required: [ + "id", + "name", + "tag", + "config", + "deploymentId", + "createdAt", + "jobAgentConfig", + ], + }, Policy: { type: "object", properties: { diff --git a/apps/webservice/src/app/api/v1/releases/[releaseId]/route.ts b/apps/webservice/src/app/api/v1/releases/[releaseId]/route.ts index 5756652fb..5593e5130 100644 --- a/apps/webservice/src/app/api/v1/releases/[releaseId]/route.ts +++ b/apps/webservice/src/app/api/v1/releases/[releaseId]/route.ts @@ -20,7 +20,10 @@ import { parseBody } from "../../body-parser"; import { request } from "../../middleware"; const patchSchema = SCHEMA.updateDeploymentVersion.and( - z.object({ metadata: z.record(z.string()).optional() }), + z.object({ + metadata: z.record(z.string()).optional(), + version: z.string().optional(), + }), ); export const PATCH = request() @@ -41,9 +44,10 @@ export const PATCH = request() const { body, user, req } = ctx; try { + const tag = body.tag ?? body.version; const release = await ctx.db .update(SCHEMA.deploymentVersion) - .set(body) + .set({ ...body, tag }) .where(eq(SCHEMA.deploymentVersion.id, versionId)) .returning() .then(takeFirst); diff --git a/openapi.v1.json b/openapi.v1.json index 84a850882..b0ef2296c 100644 --- a/openapi.v1.json +++ b/openapi.v1.json @@ -60,6 +60,432 @@ } } }, + "/v1/deployment-version-channels": { + "post": { + "summary": "Create a deployment version channel", + "operationId": "createDeploymentVersionChannel", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "deploymentId", + "name", + "versionSelector" + ], + "properties": { + "deploymentId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "versionSelector": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Deployment version channel created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "versionSelector": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "id", + "deploymentId", + "name", + "createdAt" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "Deployment version channel already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "error", + "id" + ] + } + } + } + }, + "500": { + "description": "Failed to create deployment version channel", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/v1/deployment-versions/{deploymentVersionId}": { + "patch": { + "summary": "Updates a deployment version", + "operationId": "updateDeploymentVersion", + "parameters": [ + { + "name": "deploymentVersionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The deployment version ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "string", + "enum": [ + "ready", + "building", + "failed" + ] + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentVersion" + } + } + } + } + } + } + }, + "/v1/deployment-versions": { + "post": { + "summary": "Upserts a deployment version", + "operationId": "upsertDeploymentVersion", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "deploymentId": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "string", + "enum": [ + "ready", + "building", + "failed" + ] + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "tag", + "deploymentId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentVersion" + } + } + } + }, + "409": { + "description": "Deployment version already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/deployments/{deploymentId}/deployment-version-channels/name/{name}": { + "delete": { + "summary": "Delete a deployment version channel", + "operationId": "deleteDeploymentVersionChannel", + "parameters": [ + { + "name": "deploymentId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deployment version channel deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + }, + "403": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Deployment version channel not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Failed to delete deployment version channel", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, "/v1/deployments/{deploymentId}": { "get": { "summary": "Get a deployment", @@ -578,6 +1004,12 @@ "type": "string" } }, + "deploymentVersionChannels": { + "type": "array", + "items": { + "type": "string" + } + }, "metadata": { "type": "object", "additionalProperties": { @@ -3037,6 +3469,9 @@ "release": { "$ref": "#/components/schemas/Release" }, + "deploymentVersion": { + "$ref": "#/components/schemas/DeploymentVersion" + }, "deployment": { "$ref": "#/components/schemas/Deployment" }, @@ -3272,6 +3707,50 @@ "jobAgentConfig" ] }, + "DeploymentVersion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "jobAgentConfig": { + "type": "object", + "additionalProperties": true + }, + "deploymentId": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "id", + "name", + "tag", + "config", + "deploymentId", + "createdAt", + "jobAgentConfig" + ] + }, "Policy": { "type": "object", "properties": { @@ -3586,10 +4065,9 @@ } }, "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "x-api-key" + "bearerAuth": { + "type": "http", + "scheme": "bearer" } } } diff --git a/packages/db/src/schema/deployment-version.ts b/packages/db/src/schema/deployment-version.ts index c4caba148..a92701bf6 100644 --- a/packages/db/src/schema/deployment-version.ts +++ b/packages/db/src/schema/deployment-version.ts @@ -3,7 +3,10 @@ import type { MetadataCondition, VersionCondition, } from "@ctrlplane/validators/conditions"; -import type { DeploymentVersionCondition } from "@ctrlplane/validators/releases"; +import type { + DeploymentVersionCondition, + TagCondition, +} from "@ctrlplane/validators/releases"; import type { InferSelectModel, SQL } from "drizzle-orm"; import { and, @@ -261,6 +264,16 @@ const buildVersionCondition = (cond: VersionCondition): SQL => { return ilike(deploymentVersion.tag, `%${cond.value}%`); }; +const buildTagCondition = (cond: TagCondition): SQL => { + if (cond.operator === ColumnOperator.Equals) + return eq(deploymentVersion.tag, cond.value); + if (cond.operator === ColumnOperator.StartsWith) + return ilike(deploymentVersion.tag, `${cond.value}%`); + if (cond.operator === ColumnOperator.EndsWith) + return ilike(deploymentVersion.tag, `%${cond.value}`); + return ilike(deploymentVersion.tag, `%${cond.value}%`); +}; + const buildCondition = (tx: Tx, cond: DeploymentVersionCondition): SQL => { if (cond.type === DeploymentVersionConditionType.Metadata) return buildMetadataCondition(tx, cond); @@ -268,6 +281,8 @@ const buildCondition = (tx: Tx, cond: DeploymentVersionCondition): SQL => { return buildCreatedAtCondition(cond); if (cond.type === DeploymentVersionConditionType.Version) return buildVersionCondition(cond); + if (cond.type === DeploymentVersionConditionType.Tag) + return buildTagCondition(cond); if (cond.conditions.length === 0) return sql`FALSE`; diff --git a/packages/validators/src/releases/conditions/comparison-condition.ts b/packages/validators/src/releases/conditions/comparison-condition.ts index 8d96eda58..2b5ff4771 100644 --- a/packages/validators/src/releases/conditions/comparison-condition.ts +++ b/packages/validators/src/releases/conditions/comparison-condition.ts @@ -5,8 +5,10 @@ import type { MetadataCondition, VersionCondition, } from "../../conditions/index.js"; +import type { TagCondition } from "./tag-condition.js"; import { createdAtCondition } from "../../conditions/date-condition.js"; import { metadataCondition, versionCondition } from "../../conditions/index.js"; +import { tagCondition } from "./tag-condition.js"; export const comparisonCondition: z.ZodType = z.lazy(() => z.object({ @@ -19,6 +21,7 @@ export const comparisonCondition: z.ZodType = z.lazy(() => comparisonCondition, versionCondition, createdAtCondition, + tagCondition, ]), ), }), @@ -33,5 +36,6 @@ export type ComparisonCondition = { | MetadataCondition | VersionCondition | CreatedAtCondition + | TagCondition >; }; diff --git a/packages/validators/src/releases/conditions/index.ts b/packages/validators/src/releases/conditions/index.ts index 1dee19120..6eaa4f7b1 100644 --- a/packages/validators/src/releases/conditions/index.ts +++ b/packages/validators/src/releases/conditions/index.ts @@ -1,2 +1,3 @@ export * from "./comparison-condition.js"; export * from "./release-condition.js"; +export * from "./tag-condition.js"; diff --git a/packages/validators/src/releases/conditions/release-condition.ts b/packages/validators/src/releases/conditions/release-condition.ts index 6711072ab..7d45d188e 100644 --- a/packages/validators/src/releases/conditions/release-condition.ts +++ b/packages/validators/src/releases/conditions/release-condition.ts @@ -6,21 +6,25 @@ import type { VersionCondition, } from "../../conditions/index.js"; import type { ComparisonCondition } from "./comparison-condition.js"; +import type { TagCondition } from "./tag-condition.js"; import { createdAtCondition } from "../../conditions/date-condition.js"; import { metadataCondition, versionCondition } from "../../conditions/index.js"; import { comparisonCondition } from "./comparison-condition.js"; +import { tagCondition } from "./tag-condition.js"; export type DeploymentVersionCondition = | ComparisonCondition | MetadataCondition | VersionCondition - | CreatedAtCondition; + | CreatedAtCondition + | TagCondition; export const deploymentVersionCondition = z.union([ comparisonCondition, metadataCondition, versionCondition, createdAtCondition, + tagCondition, ]); export enum DeploymentVersionOperator { @@ -38,6 +42,7 @@ export enum DeploymentVersionOperator { export enum DeploymentVersionConditionType { Metadata = "metadata", Version = "version", + Tag = "tag", Comparison = "comparison", CreatedAt = "created-at", } @@ -93,6 +98,11 @@ export const isCreatedAtCondition = ( ): condition is CreatedAtCondition => condition.type === DeploymentVersionConditionType.CreatedAt; +export const isTagCondition = ( + condition: DeploymentVersionCondition, +): condition is TagCondition => + condition.type === DeploymentVersionConditionType.Tag; + export const isValidDeploymentVersionCondition = ( condition: DeploymentVersionCondition, ): boolean => { @@ -101,6 +111,7 @@ export const isValidDeploymentVersionCondition = ( isValidDeploymentVersionCondition(c), ); if (isVersionCondition(condition)) return condition.value.length > 0; + if (isTagCondition(condition)) return condition.value.length > 0; if (isCreatedAtCondition(condition)) return true; if (isMetadataCondition(condition)) { if (condition.operator === DeploymentVersionOperator.Null) diff --git a/packages/validators/src/releases/conditions/tag-condition.ts b/packages/validators/src/releases/conditions/tag-condition.ts new file mode 100644 index 000000000..71712f4ed --- /dev/null +++ b/packages/validators/src/releases/conditions/tag-condition.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +import { columnOperator } from "../../conditions/index.js"; + +export const tagCondition = z.object({ + type: z.literal("tag"), + operator: columnOperator, + value: z.string().min(1), +}); + +export type TagCondition = z.infer;