diff --git a/apps/pty-proxy/Dockerfile b/apps/pty-proxy/Dockerfile index 60edca412..e4ea449e2 100644 --- a/apps/pty-proxy/Dockerfile +++ b/apps/pty-proxy/Dockerfile @@ -7,6 +7,7 @@ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN npm install -g turbo +RUN npm install -g corepack@latest RUN corepack enable pnpm WORKDIR /app diff --git a/apps/webservice/src/app/api/v1/deployments/[deploymentId]/openapi.ts b/apps/webservice/src/app/api/v1/deployments/[deploymentId]/openapi.ts new file mode 100644 index 000000000..b257dafcf --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployments/[deploymentId]/openapi.ts @@ -0,0 +1,93 @@ +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}": { + get: { + summary: "Get a deployment", + operationId: "getDeployment", + parameters: [ + { + name: "deploymentId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + "200": { + description: "Deployment found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Deployment" }, + }, + }, + }, + "404": { + description: "Deployment not found", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + }, + }, + delete: { + summary: "Delete a deployment", + operationId: "deleteDeployment", + parameters: [ + { + name: "deploymentId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + "200": { + description: "Deployment deleted", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Deployment" }, + }, + }, + }, + "404": { + description: "Deployment not found", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + "500": { + description: "Failed to delete deployment", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/apps/webservice/src/app/api/v1/deployments/[deploymentId]/route.ts b/apps/webservice/src/app/api/v1/deployments/[deploymentId]/route.ts new file mode 100644 index 000000000..06233d3fa --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployments/[deploymentId]/route.ts @@ -0,0 +1,77 @@ +import type { Tx } from "@ctrlplane/db"; +import { NextResponse } from "next/server"; +import httpStatus from "http-status"; + +import { eq, takeFirstOrNull } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { logger } from "@ctrlplane/logger"; +import { Permission } from "@ctrlplane/validators/auth"; + +import { authn, authz } from "../../auth"; +import { request } from "../../middleware"; + +export const GET = request() + .use(authn) + .use( + authz(({ can, extra: { params } }) => + can + .perform(Permission.DeploymentGet) + .on({ type: "deployment", id: params.deploymentId }), + ), + ) + .handle<{ db: Tx }, { params: { deploymentId: string } }>( + async ({ db }, { params }) => { + const deployment = await db + .select() + .from(SCHEMA.deployment) + .where(eq(SCHEMA.deployment.id, params.deploymentId)) + .then(takeFirstOrNull); + + if (deployment == null) + return NextResponse.json( + { error: "Deployment not found" }, + { status: httpStatus.NOT_FOUND }, + ); + + return NextResponse.json(deployment); + }, + ); + +export const DELETE = request() + .use(authn) + .use( + authz(({ can, extra: { params } }) => + can + .perform(Permission.DeploymentDelete) + .on({ type: "deployment", id: params.deploymentId }), + ), + ) + .handle<{ db: Tx }, { params: { deploymentId: string } }>( + async ({ db }, { params }) => { + try { + const deployment = await db + .select() + .from(SCHEMA.deployment) + .where(eq(SCHEMA.deployment.id, params.deploymentId)) + .then(takeFirstOrNull); + + if (deployment == null) + return NextResponse.json( + { error: "Deployment not found" }, + { status: httpStatus.NOT_FOUND }, + ); + + await db + .delete(SCHEMA.deployment) + .where(eq(SCHEMA.deployment.id, params.deploymentId)); + + return NextResponse.json({ deployment, message: "Deployment deleted" }); + } catch (error) { + logger.error("Failed to delete deployment", { error }); + return NextResponse.json( + { error: "Failed to delete deployment" }, + { status: httpStatus.INTERNAL_SERVER_ERROR }, + ); + } + }, + ); diff --git a/apps/webservice/src/app/api/v1/deployments/openapi.ts b/apps/webservice/src/app/api/v1/deployments/openapi.ts new file mode 100644 index 000000000..50453a70c --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployments/openapi.ts @@ -0,0 +1,115 @@ +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": { + post: { + summary: "Create a deployment", + operationId: "createDeployment", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + systemId: { + type: "string", + format: "uuid", + description: + "The ID of the system to create the deployment for", + example: "123e4567-e89b-12d3-a456-426614174000", + }, + name: { + type: "string", + description: "The name of the deployment", + example: "My Deployment", + }, + slug: { + type: "string", + description: "The slug of the deployment", + example: "my-deployment", + }, + description: { + type: "string", + description: "The description of the deployment", + example: "This is a deployment for my system", + }, + jobAgentId: { + type: "string", + format: "uuid", + description: + "The ID of the job agent to use for the deployment", + example: "123e4567-e89b-12d3-a456-426614174000", + }, + jobAgentConfig: { + type: "object", + description: "The configuration for the job agent", + example: { key: "value" }, + }, + retryCount: { + type: "number", + description: "The number of times to retry the deployment", + example: 3, + }, + timeout: { + type: "number", + description: "The timeout for the deployment", + example: 60, + }, + resourceFilter: { + type: "object", + description: "The resource filter for the deployment", + example: { key: "value" }, + }, + }, + required: ["systemId", "slug", "name"], + }, + }, + }, + }, + responses: { + "201": { + description: "Deployment created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Deployment" }, + }, + }, + }, + "409": { + description: "Deployment already exists", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { type: "string" }, + id: { type: "string", format: "uuid" }, + }, + required: ["error", "id"], + }, + }, + }, + }, + "500": { + description: "Failed to create deployment", + content: { + "application/json": { + schema: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/apps/webservice/src/app/api/v1/deployments/route.ts b/apps/webservice/src/app/api/v1/deployments/route.ts new file mode 100644 index 000000000..38028052c --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployments/route.ts @@ -0,0 +1,57 @@ +import type { Tx } from "@ctrlplane/db"; +import { NextResponse } from "next/server"; +import httpStatus from "http-status"; + +import { and, eq, takeFirst, takeFirstOrNull } from "@ctrlplane/db"; +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"; + +export const POST = request() + .use(authn) + .use(parseBody(SCHEMA.createDeployment)) + .use( + authz(({ ctx, can }) => + can + .perform(Permission.DeploymentCreate) + .on({ type: "system", id: ctx.body.systemId }), + ), + ) + .handle<{ db: Tx; body: SCHEMA.CreateDeployment }>(async (ctx) => { + const existingDeployment = await ctx.db + .select() + .from(SCHEMA.deployment) + .where( + and( + eq(SCHEMA.deployment.systemId, ctx.body.systemId), + eq(SCHEMA.deployment.slug, ctx.body.slug), + ), + ) + .then(takeFirstOrNull); + + if (existingDeployment != null) + return NextResponse.json( + { error: "Deployment already exists", id: existingDeployment.id }, + { status: httpStatus.CONFLICT }, + ); + + try { + const deployment = await ctx.db + .insert(SCHEMA.deployment) + .values({ ...ctx.body, description: ctx.body.description ?? "" }) + .returning() + .then(takeFirst); + + return NextResponse.json(deployment, { status: httpStatus.CREATED }); + } catch (error) { + logger.error("Failed to create deployment", { error }); + return NextResponse.json( + { error: "Failed to create deployment" }, + { status: httpStatus.INTERNAL_SERVER_ERROR }, + ); + } + }); diff --git a/openapi.v1.json b/openapi.v1.json index 03b3a3c89..b314d65a5 100644 --- a/openapi.v1.json +++ b/openapi.v1.json @@ -5,6 +5,116 @@ "version": "1.0.0" }, "paths": { + "/v1/deployments/{deploymentId}": { + "get": { + "summary": "Get a deployment", + "operationId": "getDeployment", + "parameters": [ + { + "name": "deploymentId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Deployment found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deployment" + } + } + } + }, + "404": { + "description": "Deployment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "delete": { + "summary": "Delete a deployment", + "operationId": "deleteDeployment", + "parameters": [ + { + "name": "deploymentId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Deployment deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deployment" + } + } + } + }, + "404": { + "description": "Deployment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Failed to delete deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, "/v1/deployments/{deploymentId}/release-channels/name/{name}": { "delete": { "summary": "Delete a release channel", @@ -103,6 +213,132 @@ } } }, + "/v1/deployments": { + "post": { + "summary": "Create a deployment", + "operationId": "createDeployment", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "systemId": { + "type": "string", + "format": "uuid", + "description": "The ID of the system to create the deployment for", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "name": { + "type": "string", + "description": "The name of the deployment", + "example": "My Deployment" + }, + "slug": { + "type": "string", + "description": "The slug of the deployment", + "example": "my-deployment" + }, + "description": { + "type": "string", + "description": "The description of the deployment", + "example": "This is a deployment for my system" + }, + "jobAgentId": { + "type": "string", + "format": "uuid", + "description": "The ID of the job agent to use for the deployment", + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "jobAgentConfig": { + "type": "object", + "description": "The configuration for the job agent", + "example": { + "key": "value" + } + }, + "retryCount": { + "type": "number", + "description": "The number of times to retry the deployment", + "example": 3 + }, + "timeout": { + "type": "number", + "description": "The timeout for the deployment", + "example": 60 + }, + "resourceFilter": { + "type": "object", + "description": "The resource filter for the deployment", + "example": { + "key": "value" + } + } + }, + "required": [ + "systemId", + "slug", + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Deployment created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deployment" + } + } + } + }, + "409": { + "description": "Deployment already exists", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "error", + "id" + ] + } + } + } + }, + "500": { + "description": "Failed to create deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, "/v1/environments/{environmentId}": { "get": { "summary": "Get an environment", diff --git a/packages/api/src/router/deployment.ts b/packages/api/src/router/deployment.ts index 628ae7a7a..4ba1220c9 100644 --- a/packages/api/src/router/deployment.ts +++ b/packages/api/src/router/deployment.ts @@ -444,7 +444,11 @@ export const deploymentRouter = createTRPCRouter({ }) .input(createDeployment) .mutation(({ ctx, input }) => - ctx.db.insert(deployment).values(input).returning().then(takeFirst), + ctx.db + .insert(deployment) + .values({ ...input, description: input.description ?? "" }) + .returning() + .then(takeFirst), ), update: protectedProcedure diff --git a/packages/db/Dockerfile b/packages/db/Dockerfile index 511deb546..730717be2 100644 --- a/packages/db/Dockerfile +++ b/packages/db/Dockerfile @@ -9,6 +9,7 @@ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN npm install -g turbo +RUN npm install -g corepack@latest RUN corepack enable pnpm COPY .gitignore .gitignore diff --git a/packages/db/src/schema/deployment.ts b/packages/db/src/schema/deployment.ts index 6e38152e9..fb5dc68bf 100644 --- a/packages/db/src/schema/deployment.ts +++ b/packages/db/src/schema/deployment.ts @@ -87,9 +87,11 @@ export const deployment = pgTable( const deploymentInsert = createInsertSchema(deployment, { ...deploymentSchema.shape, jobAgentConfig: z.record(z.any()), + description: z.string().optional(), }).omit({ id: true }); export const createDeployment = deploymentInsert; +export type CreateDeployment = z.infer; export const updateDeployment = deploymentInsert.partial(); export type UpdateDeployment = z.infer; export type Deployment = InferSelectModel;