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 0df7e8baa..b79cfed89 100644 --- a/apps/webservice/src/app/api/v1/jobs/[jobId]/openapi.ts +++ b/apps/webservice/src/app/api/v1/jobs/[jobId]/openapi.ts @@ -48,6 +48,12 @@ export const openapi: Swagger.SwaggerV3 = { "external_run_not_found", ], }, + externalId: { + type: "string", + nullable: true, + description: + "External job identifier (e.g. GitHub workflow run ID)", + }, release: { type: "object", properties: { @@ -205,6 +211,11 @@ export const openapi: Swagger.SwaggerV3 = { type: "string", format: "date-time", }, + jobAgentConfig: { + type: "object", + description: "Configuration for the Job Agent", + additionalProperties: true, + }, }, required: [ "id", @@ -212,6 +223,7 @@ export const openapi: Swagger.SwaggerV3 = { "createdAt", "updatedAt", "variables", + "jobAgentConfig", ], }, }, diff --git a/github/get-job-inputs/index.js b/github/get-job-inputs/index.js index 7feab6858..991551d1b 100644 --- a/github/get-job-inputs/index.js +++ b/github/get-job-inputs/index.js @@ -28072,6 +28072,13 @@ var JobAgent = class { ); return data.jobs.map((job) => new Job(job, this.client)) ?? []; } + async running() { + const { data } = await this.client.GET( + "/v1/job-agents/{agentId}/jobs/running", + { params: { path: { agentId: this.agent.id } } } + ); + return data.map((job) => new Job(job, this.client)) ?? []; + } }; var Job = class { constructor(job, client) { diff --git a/integrations/kubernetes-job-agent/src/agent.ts b/integrations/kubernetes-job-agent/src/agent.ts new file mode 100644 index 000000000..5f29cdfc1 --- /dev/null +++ b/integrations/kubernetes-job-agent/src/agent.ts @@ -0,0 +1,13 @@ +import { JobAgent } from "@ctrlplane/node-sdk"; + +import { env } from "./config.js"; +import { api } from "./sdk.js"; + +export const agent = new JobAgent( + { + name: env.CTRLPLANE_AGENT_NAME, + workspaceId: env.CTRLPLANE_WORKSPACE_ID, + type: "kubernetes-job", + }, + api, +); diff --git a/integrations/kubernetes-job-agent/src/index.ts b/integrations/kubernetes-job-agent/src/index.ts index fba861b04..b3c40c3a0 100644 --- a/integrations/kubernetes-job-agent/src/index.ts +++ b/integrations/kubernetes-job-agent/src/index.ts @@ -1,12 +1,14 @@ +import type { Job } from "@ctrlplane/node-sdk"; import { CronJob } from "cron"; import handlebars from "handlebars"; import yaml from "js-yaml"; +import { z } from "zod"; import { logger } from "@ctrlplane/logger"; +import { agent } from "./agent.js"; import { env } from "./config.js"; import { getBatchClient, getJobStatus } from "./k8s.js"; -import { api } from "./sdk.js"; const renderManifest = (manifestTemplate: string, variables: object) => { try { @@ -20,6 +22,7 @@ const renderManifest = (manifestTemplate: string, variables: object) => { }; const deployManifest = async ( + job: Job, jobId: string, namespace: string, manifest: any, @@ -27,30 +30,31 @@ const deployManifest = async ( try { const name = manifest?.metadata?.name; logger.info(`Deploying manifest: ${namespace}/${name}`); + if (name == null) { logger.error("Job name not found in manifest", { jobId, namespace, manifest, }); - await api.updateJob({ - jobId, - updateJobRequest: { - status: "invalid_job_agent", - message: "Job name not found in manifest.", + await job.update({ + externalId: "", + status: "invalid_job_agent", + message: "Job name not found in manifest.", }); return; } logger.info(`Creating job - ${namespace}/${name}`); + await getBatchClient().createNamespacedJob(namespace, manifest); - await api.updateJob({ - jobId, - updateJobRequest: { - status: "in_progress", - externalId: `${namespace}/${name}`, - message: "Job created successfully.", + + await job.update({ + status: "in_progress", + externalId: `${namespace}/${name}`, + message: "Job created successfully.", }); + logger.info(`Job created successfully`, { jobId, namespace, @@ -62,60 +66,68 @@ const deployManifest = async ( namespace, error, }); - await api.updateJob({ - jobId, - updateJobRequest: { - status: "invalid_job_agent", - message: error.body?.message || error.message, - }, + + await job.update({ + status: "invalid_job_agent" as const, + message: error.body?.message || error.message, }); } }; -const spinUpNewJobs = async (agentId: string) => { +const jobAgentConfigSchema = z.object({ + manifest: z.string(), +}); + +const spinUpNewJobs = async () => { try { - const { jobs = [] } = await api.getNextJobs({ agentId }); + const jobs = await agent.next(); logger.info(`Found ${jobs.length} job(s) to run.`); + await Promise.allSettled( - jobs.map(async (job) => { - logger.info(`Running job ${job.id}`); - logger.debug(`Job details:`, { job }); - try { - const je = await api.getJob({ jobId: job.id }); - const manifest = renderManifest(job.jobAgentConfig.manifest, je); + jobs.map(async (job: Job) => { + const jobDetails = await job.get(); + logger.info(`Running job ${jobDetails.id}`); + logger.debug(`Job details:`, { job: jobDetails }); - const namespace = manifest?.metadata?.namespace ?? env.KUBE_NAMESPACE; - await api.acknowledgeJob({ jobId: job.id }); - await deployManifest(job.id, namespace, manifest); - } catch (error: any) { - logger.error(`Error processing job ${job.id}`, { - error: error.message, - stack: error.stack, + const parseResult = jobAgentConfigSchema.safeParse( + jobDetails.jobAgentConfig, + ); + if (!parseResult.success) { + await job.update({ + status: "failed", + message: + "Invalid job agent configuration: " + parseResult.error.message, }); - throw error; + return; } + + const manifest = renderManifest(parseResult.data.manifest, jobDetails); + const namespace = manifest?.metadata?.namespace ?? env.KUBE_NAMESPACE; + + await job.acknowledge(); + await deployManifest(job, jobDetails.id, namespace, manifest); }), ); } catch (error: any) { logger.error("Error spinning up new jobs", { - agentId, error: error.message, }); throw error; } }; -const updateExecutionStatus = async (agentId: string) => { +const updateExecutionStatus = async () => { try { - const jobs = await api.getAgentRunningJob({ agentId }); - logger.info(`Found ${jobs.length} running execution(s)`); + const jobs = await agent.running(); + logger.info(`Found ${jobs.length} running job(s)`); await Promise.allSettled( - jobs.map(async (job) => { - const [namespace, name] = job.externalId?.split("/") ?? []; + jobs.map(async (job: Job) => { + const jobDetails = await job.get(); + const [namespace, name] = jobDetails.externalId?.split("/") ?? []; if (namespace == null || name == null) { logger.error("Invalid external run ID", { - jobId: job.id, - externalId: job.externalId, + jobId: jobDetails.id, + externalId: jobDetails.externalId, }); return; } @@ -123,10 +135,7 @@ const updateExecutionStatus = async (agentId: string) => { logger.debug(`Checking status of ${namespace}/${name}`); try { const { status, message } = await getJobStatus(namespace, name); - await api.updateJob({ - jobId: job.id, - updateJobRequest: { status, message }, - }); + await job.update({ status, message }); logger.info(`Updated status for ${namespace}/${name}`, { status, message, @@ -140,7 +149,6 @@ const updateExecutionStatus = async (agentId: string) => { ); } catch (error: any) { logger.error("Error updating execution statuses", { - agentId, error: error.message, }); } @@ -148,17 +156,11 @@ const updateExecutionStatus = async (agentId: string) => { const scan = async () => { try { - const { id } = await api.updateJobAgent({ - updateJobAgentRequest: { - name: env.CTRLPLANE_AGENT_NAME, - workspaceId: env.CTRLPLANE_WORKSPACE_ID, - type: "kubernetes-job", - }, - }); + const { id } = await agent.get(); logger.info(`Agent ID: ${id}`); - await spinUpNewJobs(id); - await updateExecutionStatus(id); + await spinUpNewJobs(); + await updateExecutionStatus(); } catch (error: any) { logger.error("Error during scan operation", { error: error.message }); throw error; diff --git a/integrations/kubernetes-job-agent/src/sdk.ts b/integrations/kubernetes-job-agent/src/sdk.ts index 41ffd0516..a852d5653 100644 --- a/integrations/kubernetes-job-agent/src/sdk.ts +++ b/integrations/kubernetes-job-agent/src/sdk.ts @@ -1,10 +1,8 @@ -import { Configuration, DefaultApi } from "@ctrlplane/node-sdk"; +import { createClient } from "@ctrlplane/node-sdk"; import { env } from "./config.js"; -const config = new Configuration({ - basePath: `${env.CTRLPLANE_API_URL}/api`, +export const api = createClient({ + baseUrl: env.CTRLPLANE_API_URL, apiKey: env.CTRLPLANE_API_KEY, }); - -export const api = new DefaultApi(config); diff --git a/integrations/kubernetes-job-agent/src/utils.ts b/integrations/kubernetes-job-agent/src/utils.ts index 70005e497..cca60a211 100644 --- a/integrations/kubernetes-job-agent/src/utils.ts +++ b/integrations/kubernetes-job-agent/src/utils.ts @@ -1,4 +1,4 @@ -import type { SetTargetProvidersTargetsRequest } from "@ctrlplane/node-sdk"; +import type { Operations } from "@ctrlplane/node-sdk"; export function omitNullUndefined(obj: object) { return Object.entries(obj).reduce>( @@ -11,5 +11,5 @@ export function omitNullUndefined(obj: object) { } export type ScannerFunc = () => Promise< - SetTargetProvidersTargetsRequest["targets"] + Operations["setTargetProvidersTargets"]["requestBody"]["content"]["application/json"]["targets"] >; diff --git a/openapi.v1.json b/openapi.v1.json index dbf58ca99..0480600f4 100644 --- a/openapi.v1.json +++ b/openapi.v1.json @@ -15,7 +15,10 @@ "application/json": { "schema": { "type": "object", - "required": ["workspaceId", "targets"], + "required": [ + "workspaceId", + "targets" + ], "properties": { "workspaceId": { "type": "string", @@ -58,7 +61,10 @@ "type": "array", "items": { "type": "object", - "required": ["key", "value"], + "required": [ + "key", + "value" + ], "properties": { "key": { "type": "string" @@ -118,7 +124,10 @@ "type": "object" } }, - "required": ["version", "deploymentId"] + "required": [ + "version", + "deploymentId" + ] } } } @@ -161,7 +170,9 @@ "application/json": { "schema": { "type": "object", - "required": ["systemId"], + "required": [ + "systemId" + ], "properties": { "systemId": { "type": "string" @@ -199,7 +210,9 @@ "additionalProperties": true } }, - "required": ["systemId"] + "required": [ + "systemId" + ] } } } @@ -217,7 +230,9 @@ "type": "string" } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -323,7 +338,10 @@ "default": false } }, - "required": ["key", "value"] + "required": [ + "key", + "value" + ] } } }, @@ -360,7 +378,9 @@ "example": "Target not found" } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -434,7 +454,10 @@ "default": false } }, - "required": ["key", "value"] + "required": [ + "key", + "value" + ] } } } @@ -510,7 +533,9 @@ "type": "string" } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -538,7 +563,9 @@ "application/json": { "schema": { "type": "object", - "required": ["success"], + "required": [ + "success" + ], "properties": { "success": { "type": "boolean" @@ -565,7 +592,9 @@ "type": "string" } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -614,6 +643,11 @@ "external_run_not_found" ] }, + "externalId": { + "type": "string", + "nullable": true, + "description": "External job identifier (e.g. GitHub workflow run ID)" + }, "release": { "type": "object", "properties": { @@ -630,7 +664,12 @@ "type": "object" } }, - "required": ["id", "version", "metadata", "config"] + "required": [ + "id", + "version", + "metadata", + "config" + ] }, "deployment": { "type": "object", @@ -675,7 +714,12 @@ "type": "string" } }, - "required": ["id", "name", "systemId", "jobAgentId"] + "required": [ + "id", + "name", + "systemId", + "jobAgentId" + ] }, "target": { "type": "object", @@ -729,7 +773,11 @@ "type": "string" } }, - "required": ["id", "name", "systemId"] + "required": [ + "id", + "name", + "systemId" + ] }, "variables": { "type": "object" @@ -743,7 +791,11 @@ }, "status": { "type": "string", - "enum": ["pending", "approved", "rejected"] + "enum": [ + "pending", + "approved", + "rejected" + ] }, "approver": { "type": "object", @@ -757,10 +809,16 @@ "type": "string" } }, - "required": ["id", "name"] + "required": [ + "id", + "name" + ] } }, - "required": ["id", "status"] + "required": [ + "id", + "status" + ] }, "createdAt": { "type": "string", @@ -769,6 +827,11 @@ "updatedAt": { "type": "string", "format": "date-time" + }, + "jobAgentConfig": { + "type": "object", + "description": "Configuration for the Job Agent", + "additionalProperties": true } }, "required": [ @@ -776,7 +839,8 @@ "status", "createdAt", "updatedAt", - "variables" + "variables", + "jobAgentConfig" ] } } @@ -833,7 +897,9 @@ "type": "string" } }, - "required": ["id"] + "required": [ + "id" + ] } } } @@ -862,7 +928,11 @@ "type": "string" } }, - "required": ["type", "name", "workspaceId"] + "required": [ + "type", + "name", + "workspaceId" + ] } } } @@ -885,7 +955,11 @@ "type": "string" } }, - "required": ["id", "name", "workspaceId"] + "required": [ + "id", + "name", + "workspaceId" + ] } } } @@ -917,7 +991,9 @@ "application/json": { "schema": { "type": "object", - "required": ["targets"], + "required": [ + "targets" + ], "properties": { "targets": { "type": "array", @@ -1005,7 +1081,9 @@ "type": "boolean" } }, - "required": ["sucess"] + "required": [ + "sucess" + ] } } } @@ -1021,7 +1099,9 @@ "type": "string" } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1049,7 +1129,9 @@ "application/json": { "schema": { "type": "object", - "required": ["name"], + "required": [ + "name" + ], "properties": { "name": { "type": "string" @@ -1118,7 +1200,9 @@ "type": "string" } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1134,7 +1218,9 @@ "type": "string" } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1435,7 +1521,12 @@ } } }, - "required": ["id", "identifier", "workspaceId", "providerId"] + "required": [ + "id", + "identifier", + "workspaceId", + "providerId" + ] } } } @@ -1577,7 +1668,11 @@ "type": "string" } }, - "required": ["id", "name", "workspaceId"] + "required": [ + "id", + "name", + "workspaceId" + ] } } } @@ -1607,4 +1702,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 65599df65..c5c8cded3 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -101,9 +101,17 @@ export class JobAgent { ); return data.jobs.map((job) => new Job(job, this.client)) ?? []; } + + async running() { + const { data } = await this.client.GET( + "/v1/job-agents/{agentId}/jobs/running", + { params: { path: { agentId: this.agent.id } } }, + ); + return data.map((job) => new Job(job, this.client)) ?? []; + } } -class Job { +export class Job { constructor( private job: { id: string }, private client: ReturnType, diff --git a/packages/node-sdk/src/schema.ts b/packages/node-sdk/src/schema.ts index 5ca5e8e3e..af6601583 100644 --- a/packages/node-sdk/src/schema.ts +++ b/packages/node-sdk/src/schema.ts @@ -143,6 +143,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/deployments/{deploymentId}/release-channels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a release channel */ + post: operations["createReleaseChannel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/job-agents/{agentId}/queue/next": { parameters: { query?: never; @@ -346,6 +363,9 @@ export interface operations { systemId: string; /** Format: date-time */ expiresAt?: string | null; + targetFilter?: { + [key: string]: unknown; + }; }; }; }; @@ -600,6 +620,8 @@ export interface operations { | "invalid_job_agent" | "invalid_integration" | "external_run_not_found"; + /** @description External job identifier (e.g. GitHub workflow run ID) */ + externalId?: string | null; release?: { id: string; version: string; @@ -649,6 +671,10 @@ export interface operations { createdAt: string; /** Format: date-time */ updatedAt: string; + /** @description Configuration for the Job Agent */ + jobAgentConfig: { + [key: string]: unknown; + }; }; }; }; @@ -821,6 +847,69 @@ export interface operations { }; }; }; + createReleaseChannel: { + parameters: { + query?: never; + header?: never; + path: { + deploymentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + description?: string; + releaseFilter?: { + [key: string]: unknown; + }; + }; + }; + }; + responses: { + /** @description Release channel created successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + description?: string | null; + deploymentId: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; getNextJobs: { parameters: { query?: never;