diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index e9fcbfe5b..3a0e35b79 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -33,6 +33,7 @@ export const env = createEnv({ GITHUB_BOT_PRIVATE_KEY: z.string().optional(), GITHUB_WEBHOOK_SECRET: z.string().optional(), TFE_WEBHOOK_SECRET: z.string().optional(), + ARGO_WORKFLOW_WEBHOOK_SECRET: z.string().optional(), BASE_URL: z.string().optional(), diff --git a/apps/api/src/routes/argoworkflow/index.ts b/apps/api/src/routes/argoworkflow/index.ts new file mode 100644 index 000000000..5f914fae5 --- /dev/null +++ b/apps/api/src/routes/argoworkflow/index.ts @@ -0,0 +1,29 @@ +import type { Request, Response } from "express"; +import { env } from "@/config.js"; +import { asyncHandler } from "@/types/api.js"; +import { Router } from "express"; + +import { handleArgoWorkflow } from "./run_workflow.js"; + +export const createArgoWorkflowRouter = (): Router => + Router().post("/webhook", asyncHandler(handleWebhookRequest)); + +const verifyRequest = async (req: Request): Promise => { + const authHeader = req.headers["authorization"]?.toString(); + if (authHeader == null) return false; + const secret = env.ARGO_WORKFLOW_WEBHOOK_SECRET; + return authHeader === secret; +}; + +const handleWebhookRequest = async (req: Request, res: Response) => { + const isVerified = await verifyRequest(req); + if (!isVerified) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const payload = req.body; + console.log("handleArgoWorkflow payload:", JSON.stringify(payload, null, 2)); + await handleArgoWorkflow(payload); + res.status(200).send(); +}; diff --git a/apps/api/src/routes/argoworkflow/run_workflow.ts b/apps/api/src/routes/argoworkflow/run_workflow.ts new file mode 100644 index 000000000..00b518bab --- /dev/null +++ b/apps/api/src/routes/argoworkflow/run_workflow.ts @@ -0,0 +1,78 @@ +import { eq } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import { enqueueAllReleaseTargetsDesiredVersion } from "@ctrlplane/db/reconcilers"; +import * as schema from "@ctrlplane/db/schema"; +import { exitedStatus, JobStatus } from "@ctrlplane/validators/jobs"; + +interface ArgoWorkflowPayload { + workflowName: string; + namespace: string; + uid: string; + createdAt: string; + startedAt: string; + finishedAt: string | null; + phase: string; + eventType: string; +} + +const statusMap: Record = { + Succeeded: JobStatus.Successful, + Failed: JobStatus.Failure, + Running: JobStatus.InProgress, + Pending: JobStatus.Pending, +}; + +const extractUuid = (str: string) => { + const uuidRegex = + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/; + const match = uuidRegex.exec(str); + return match ? match[0] : null; +}; + +export const mapTriggerToStatus = (trigger: string): JobStatus | null => + statusMap[trigger] ?? null; + +export const handleArgoWorkflow = async (payload: ArgoWorkflowPayload) => { + const { workflowName, uid, phase, startedAt, finishedAt } = payload; + + const jobId = uid; + if (jobId == null) return; + + const status = statusMap[phase] ?? null; + if (status == null) return; + + const isCompleted = exitedStatus.includes(status); + const completedAt = + isCompleted && finishedAt != null ? new Date(finishedAt) : null; + + const [updated] = await db + .update(schema.job) + .set({ + externalId: uid, + status, + startedAt: new Date(startedAt), + completedAt, + updatedAt: new Date(), + }) + .where(eq(schema.job.id, jobId)) + .returning(); + + if (updated == null) return; + + const result = await db + .select({ workspaceId: schema.deployment.workspaceId }) + .from(schema.releaseJob) + .innerJoin( + schema.release, + eq(schema.releaseJob.releaseId, schema.release.id), + ) + .innerJoin( + schema.deployment, + eq(schema.release.deploymentId, schema.deployment.id), + ) + .where(eq(schema.releaseJob.jobId, jobId)) + .then((rows) => rows[0] ?? null); + + if (result?.workspaceId == null) return; + enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId); +}; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 6003c7eb2..8fcdd5361 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -16,6 +16,7 @@ import { auth } from "@ctrlplane/auth/server"; import { appRouter, createTRPCContext } from "@ctrlplane/trpc"; import swaggerDocument from "../openapi/openapi.json" with { type: "json" }; +import { createArgoWorkflowRouter } from "./routes/argoworkflow"; import { createGithubRouter } from "./routes/github/index.js"; import { createTfeRouter } from "./routes/tfe/index.js"; @@ -26,7 +27,7 @@ const specFile = join(__dirname, "../openapi/openapi.json"); const oapiValidatorMiddleware = OpenApiValidator.middleware({ apiSpec: specFile, validateRequests: true, - ignorePaths: /\/api\/(auth|trpc|github|tfe|ui|healthz)/, + ignorePaths: /\/api\/(auth|trpc|github|tfe|ui|healthz|argo)/, }); const trpcMiddleware = trpcExpress.createExpressMiddleware({ @@ -80,7 +81,11 @@ const app = express() .use("/api/v1", requireAuth) .use("/api/v1", createV1Router()) .use("/api/github", createGithubRouter()) +<<<<<<< Updated upstream .use("/api/tfe", createTfeRouter()) +======= + .use("/api/argo", createArgoWorkflowRouter()) +>>>>>>> Stashed changes .use("/api/trpc", trpcMiddleware) .use(errorHandler); diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index efe7f1d4e..1201d9047 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -53,6 +53,28 @@ ], "type": "object" }, + "ArgoWorkflowJobAgentConfig": { + "properties": { + "apiKey": { + "description": "ArgoWorkflow API token.", + "type": "string" + }, + "serverUrl": { + "description": "ArgoWorkflow server address (host[:port] or URL).", + "type": "string" + }, + "uid": { + "description": "ArgoWorkflow job id ", + "type": "string" + } + }, + "required": [ + "uid", + "serverUrl", + "apiKey" + ], + "type": "object" + }, "BasicResource": { "properties": { "id": { diff --git a/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet b/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet index 64437deb6..380680e06 100644 --- a/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet @@ -155,6 +155,16 @@ local JobPropertyKeys = std.objectFields(Job.properties); }, }, + ArgoWorkflowJobAgentConfig: { + type: 'object', + required: ['uid', 'serverUrl', 'apiKey'], + properties: { + serverUrl: { type: 'string', description: 'ArgoWorkflow server address (host[:port] or URL).' }, + uid: { type: 'string', description: 'ArgoWorkflow job id ' }, + apiKey: { type: 'string', description: 'ArgoWorkflow API token.' }, + }, + }, + TestRunnerJobAgentConfig: { type: 'object', properties: { diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index 5a50bf0e9..a03348a8b 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -239,6 +239,18 @@ type ArgoCDJobAgentConfig struct { Template string `json:"template"` } +// ArgoWorkflowJobAgentConfig defines model for ArgoWorkflowJobAgentConfig. +type ArgoWorkflowJobAgentConfig struct { + // ApiKey ArgoWorkflow API token. + ApiKey string `json:"apiKey"` + + // ServerUrl ArgoWorkflow server address (host[:port] or URL). + ServerUrl string `json:"serverUrl"` + + // Uid ArgoWorkflow job id + Uid string `json:"uid"` +} + // BasicResource defines model for BasicResource. type BasicResource struct { Id string `json:"id"`