From ce96614c5f9d5599c4f6596e20a3ab3852888d21 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Thu, 3 Jul 2025 11:16:33 -0700 Subject: [PATCH 1/3] feat: init extend webhooks feat: minor changes to webhook extensions feat: simplify job metadata remove unused api for now --- docs/schema/yaml/1.0.0.yaml | 24 ++- src/server/lib/jsonschema/schemas/1.0.0.json | 48 +++++- .../lib/kubernetes/common/serviceAccount.ts | 5 +- .../lib/kubernetes/webhookJobFactory.ts | 108 ++++++++++++ src/server/lib/webhook/index.ts | 155 ++++++++++++++++++ src/server/lib/webhook/webhookValidator.ts | 109 ++++++++++++ src/server/lib/yamlConfigValidator.ts | 5 +- .../yamlSchemas/schema_1_0_0/schema_1_0_0.ts | 23 +-- .../lib/yamlSchemas/schema_1_0_0/webhooks.ts | 75 +++++++++ src/server/models/yaml/YamlWebhook.ts | 29 +++- src/server/services/webhook.ts | 76 ++++++++- 11 files changed, 620 insertions(+), 37 deletions(-) create mode 100644 src/server/lib/kubernetes/webhookJobFactory.ts create mode 100644 src/server/lib/webhook/index.ts create mode 100644 src/server/lib/webhook/webhookValidator.ts create mode 100644 src/server/lib/yamlSchemas/schema_1_0_0/webhooks.ts diff --git a/docs/schema/yaml/1.0.0.yaml b/docs/schema/yaml/1.0.0.yaml index 61c6c8ab..7c7a6ded 100644 --- a/docs/schema/yaml/1.0.0.yaml +++ b/docs/schema/yaml/1.0.0.yaml @@ -48,13 +48,35 @@ environment: state: '' # @param environment.webhooks.type (required) type: '' - # @param environment.webhooks.pipelineId (required) + # @param environment.webhooks.pipelineId pipelineId: '' # @param environment.webhooks.trigger (required) trigger: '' # @param environment.webhooks.env (required) env: + # @param environment.webhooks.docker + docker: + # @param environment.webhooks.docker.image (required) + image: '' + # @param environment.webhooks.docker.command + command: + # @param environment.webhooks.docker.command[] + - '' + # @param environment.webhooks.docker.args + args: + # @param environment.webhooks.docker.args[] + - '' + # @param environment.webhooks.docker.timeout + timeout: 0 + # @param environment.webhooks.command + command: + # @param environment.webhooks.command.image (required) + image: '' + # @param environment.webhooks.command.script (required) + script: '' + # @param environment.webhooks.command.timeout + timeout: 0 # @section services services: # @param services[] diff --git a/src/server/lib/jsonschema/schemas/1.0.0.json b/src/server/lib/jsonschema/schemas/1.0.0.json index 41f358c8..4895c3ff 100644 --- a/src/server/lib/jsonschema/schemas/1.0.0.json +++ b/src/server/lib/jsonschema/schemas/1.0.0.json @@ -106,12 +106,58 @@ "required": [ "branch" ] + }, + "docker": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { + "type": "string" + }, + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "image" + ] + }, + "command": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { + "type": "string" + }, + "script": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "image", + "script" + ] } }, "required": [ "state", "type", - "pipelineId", + "env", "trigger", "env" ] diff --git a/src/server/lib/kubernetes/common/serviceAccount.ts b/src/server/lib/kubernetes/common/serviceAccount.ts index 0b9a05b7..30811265 100644 --- a/src/server/lib/kubernetes/common/serviceAccount.ts +++ b/src/server/lib/kubernetes/common/serviceAccount.ts @@ -20,7 +20,10 @@ import { setupServiceAccountInNamespace } from '../../nativeHelm/utils'; const logger = rootLogger.child({ filename: 'lib/kubernetes/serviceAccount.ts' }); -export async function ensureServiceAccountForJob(namespace: string, jobType: 'build' | 'deploy'): Promise { +export async function ensureServiceAccountForJob( + namespace: string, + jobType: 'build' | 'deploy' | 'webhook' +): Promise { const { serviceAccount } = await GlobalConfigService.getInstance().getAllConfigs(); const serviceAccountName = serviceAccount?.name || 'default'; const role = serviceAccount?.role || 'default'; diff --git a/src/server/lib/kubernetes/webhookJobFactory.ts b/src/server/lib/kubernetes/webhookJobFactory.ts new file mode 100644 index 00000000..91b0852f --- /dev/null +++ b/src/server/lib/kubernetes/webhookJobFactory.ts @@ -0,0 +1,108 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { V1Job } from '@kubernetes/client-node'; +import { createKubernetesJob, JobConfig } from './jobFactory'; +import { randomAlphanumeric } from 'server/lib/random'; + +export interface WebhookJobConfig { + name: string; + namespace: string; + serviceAccount: string; + buildUuid: string; + buildId: string; + buildSha?: string; + webhookName: string; + webhookType: 'docker' | 'command'; + image: string; + command?: string[]; + args?: string[]; + script?: string; + env: Record; + timeout?: number; +} + +const DEFAULT_WEBHOOK_TIMEOUT = 1800; // 30 minutes +const DEFAULT_RESOURCES = { + requests: { cpu: '200m', memory: '1Gi' }, + limits: { cpu: '200m', memory: '1Gi' }, +}; + +export function createWebhookJob(config: WebhookJobConfig): V1Job { + const jobId = randomAlphanumeric(4).toLowerCase(); + const sanitizedWebhookName = + config.webhookName + ?.toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .substring(0, 20) || 'webhook'; + + const shortSha = config.buildSha ? config.buildSha.substring(0, 7) : 'unknown'; + + let jobName = `wh-${sanitizedWebhookName}-${config.buildUuid}-${jobId}-${shortSha}`.substring(0, 63); + if (jobName.endsWith('-')) { + jobName = jobName.slice(0, -1); + } + + const timeout = config.timeout || DEFAULT_WEBHOOK_TIMEOUT; + const ttl = 86400; // 24 hours + + // Create container configuration based on webhook type + let container: any; + if (config.webhookType === 'docker') { + container = { + name: 'webhook-executor', + image: config.image, + command: config.command, + args: config.args, + env: Object.entries(config.env).map(([name, value]) => ({ name, value })), + resources: DEFAULT_RESOURCES, + }; + } else if (config.webhookType === 'command') { + // For command type, we wrap the script in a shell command + container = { + name: 'webhook-executor', + image: config.image, + command: ['/bin/sh', '-c'], + args: [config.script], + env: Object.entries(config.env).map(([name, value]) => ({ name, value })), + resources: DEFAULT_RESOURCES, + }; + } + + const jobConfig: JobConfig = { + name: jobName, + namespace: config.namespace, + appName: 'webhook', + component: 'build', + serviceAccount: config.serviceAccount, + timeout, + ttl, + labels: { + lc_uuid: config.buildUuid, + 'lfc/uuid': config.buildUuid, + 'lfc/build_id': String(config.buildId), + 'lfc/webhook_name': sanitizedWebhookName, + 'lfc/webhook_type': config.webhookType, + }, + annotations: { + 'lfc/webhook_name': config.webhookName, + 'lfc/webhook_type': config.webhookType, + }, + containers: [container], + }; + + return createKubernetesJob(jobConfig); +} diff --git a/src/server/lib/webhook/index.ts b/src/server/lib/webhook/index.ts new file mode 100644 index 00000000..0f984dd3 --- /dev/null +++ b/src/server/lib/webhook/index.ts @@ -0,0 +1,155 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import yaml from 'js-yaml'; +import fs from 'fs'; +import { Build } from 'server/models'; +import { Webhook } from 'server/models/yaml'; +import { createWebhookJob, WebhookJobConfig } from 'server/lib/kubernetes/webhookJobFactory'; +import { shellPromise } from 'server/lib/shell'; +import { waitForJobAndGetLogs } from 'server/lib/nativeBuild/utils'; +import { ensureServiceAccountForJob } from 'server/lib/kubernetes/common/serviceAccount'; +import rootLogger from 'server/lib/logger'; +import { nanoid } from 'nanoid'; + +const logger = rootLogger.child({ + filename: 'lib/webhook/index.ts', +}); + +const MANIFEST_PATH = process.env.MANIFEST_PATH || '/tmp/lifecycle/manifests'; + +export interface WebhookExecutionResult { + success: boolean; + jobName: string; + logs: string; + status: string; + metadata?: Record; +} + +export async function executeDockerWebhook( + webhook: Webhook, + build: Build, + resolvedEnv: Record +): Promise { + if (!webhook.docker) { + throw new Error('Docker webhook configuration is missing'); + } + + const namespace = build.namespace; + const serviceAccountName = await ensureServiceAccountForJob(namespace, 'webhook'); + + const jobConfig: WebhookJobConfig = { + name: webhook.name || 'docker-webhook', + namespace, + serviceAccount: serviceAccountName, + buildUuid: build.uuid, + buildId: String(build.id), + buildSha: build.sha, + webhookName: webhook.name || 'docker-webhook', + webhookType: 'docker', + image: webhook.docker.image, + command: webhook.docker.command, + args: webhook.docker.args, + env: resolvedEnv, + timeout: webhook.docker.timeout, + }; + + return executeWebhookJob(jobConfig, build); +} + +export async function executeCommandWebhook( + webhook: Webhook, + build: Build, + resolvedEnv: Record +): Promise { + if (!webhook.command) { + throw new Error('Command webhook configuration is missing'); + } + + const namespace = build.namespace; + const serviceAccountName = await ensureServiceAccountForJob(namespace, 'webhook'); + + const jobConfig: WebhookJobConfig = { + name: webhook.name || 'command-webhook', + namespace, + serviceAccount: serviceAccountName, + buildUuid: build.uuid, + buildId: String(build.id), + buildSha: build.sha, + webhookName: webhook.name || 'command-webhook', + webhookType: 'command', + image: webhook.command.image, + script: webhook.command.script, + env: resolvedEnv, + timeout: webhook.command.timeout, + }; + + return executeWebhookJob(jobConfig, build); +} + +async function executeWebhookJob(jobConfig: WebhookJobConfig, build: Build): Promise { + const executionId = nanoid(); + logger.info(`[WEBHOOK ${build.uuid}] Starting ${jobConfig.webhookType} webhook: ${jobConfig.webhookName}`, { + buildUuid: build.uuid, + webhookName: jobConfig.webhookName, + webhookType: jobConfig.webhookType, + executionId, + }); + + try { + const job = createWebhookJob(jobConfig); + const manifest = yaml.dump(job); + + const manifestDir = `${MANIFEST_PATH}/webhooks`; + await fs.promises.mkdir(manifestDir, { recursive: true }); + const manifestPath = `${manifestDir}/${job.metadata.name}-${executionId}.yaml`; + await fs.promises.writeFile(manifestPath, manifest, 'utf8'); + await shellPromise(`kubectl apply -f ${manifestPath}`); + + const jobResult = await waitForJobAndGetLogs(job.metadata.name, jobConfig.namespace, `[WEBHOOK ${build.uuid}]`); + + logger.info(`[WEBHOOK ${build.uuid}] Webhook execution completed`, { + buildUuid: build.uuid, + webhookName: jobConfig.webhookName, + success: jobResult.success, + status: jobResult.status, + }); + + return { + success: jobResult.success, + jobName: job.metadata.name, + logs: jobResult.logs, + status: jobResult.status || (jobResult.success ? 'succeeded' : 'failed'), + metadata: {}, + }; + } catch (error) { + logger.error(`[WEBHOOK ${build.uuid}] Webhook execution failed`, { + buildUuid: build.uuid, + webhookName: jobConfig.webhookName, + error: error.message, + }); + + return { + success: false, + jobName: '', + logs: error.message, + status: 'failed', + metadata: { + error: error.message, + }, + }; + } +} diff --git a/src/server/lib/webhook/webhookValidator.ts b/src/server/lib/webhook/webhookValidator.ts new file mode 100644 index 00000000..7504ca07 --- /dev/null +++ b/src/server/lib/webhook/webhookValidator.ts @@ -0,0 +1,109 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Webhook } from 'server/models/yaml'; + +export interface WebhookValidationError { + field: string; + message: string; +} + +/** + * Validates webhook configuration based on its type + * @param webhook The webhook to validate + * @returns Array of validation errors, empty if valid + */ +export function validateWebhook(webhook: Webhook): WebhookValidationError[] { + const errors: WebhookValidationError[] = []; + + // Common validations + if (!webhook.type) { + errors.push({ field: 'type', message: 'Webhook type is required' }); + return errors; // Can't validate further without type + } + + if (!webhook.state) { + errors.push({ field: 'state', message: 'Webhook state is required' }); + } + + if (!webhook.env || typeof webhook.env !== 'object') { + errors.push({ field: 'env', message: 'Webhook env must be an object' }); + } + + // Type-specific validations + switch (webhook.type) { + case 'codefresh': + if (!webhook.pipelineId) { + errors.push({ field: 'pipelineId', message: 'Pipeline ID is required for codefresh webhooks' }); + } + if (!webhook.trigger) { + errors.push({ field: 'trigger', message: 'Trigger is required for codefresh webhooks' }); + } + break; + + case 'docker': + if (!webhook.docker) { + errors.push({ field: 'docker', message: 'Docker configuration is required for docker webhooks' }); + } else { + if (!webhook.docker.image) { + errors.push({ field: 'docker.image', message: 'Docker image is required' }); + } + if (webhook.docker.timeout && (webhook.docker.timeout <= 0 || webhook.docker.timeout > 86400)) { + errors.push({ field: 'docker.timeout', message: 'Docker timeout must be between 1 and 86400 seconds' }); + } + } + break; + + case 'command': + if (!webhook.command) { + errors.push({ field: 'command', message: 'Command configuration is required for command webhooks' }); + } else { + if (!webhook.command.image) { + errors.push({ field: 'command.image', message: 'Command image is required' }); + } + if (!webhook.command.script) { + errors.push({ field: 'command.script', message: 'Command script is required' }); + } + if (webhook.command.timeout && (webhook.command.timeout <= 0 || webhook.command.timeout > 86400)) { + errors.push({ field: 'command.timeout', message: 'Command timeout must be between 1 and 86400 seconds' }); + } + } + break; + + default: + errors.push({ field: 'type', message: `Invalid webhook type: ${webhook.type}` }); + } + + return errors; +} + +/** + * Validates all webhooks in an array + * @param webhooks Array of webhooks to validate + * @returns Map of webhook index to validation errors + */ +export function validateWebhooks(webhooks: Webhook[]): Map { + const errorMap = new Map(); + + webhooks.forEach((webhook, index) => { + const errors = validateWebhook(webhook); + if (errors.length > 0) { + errorMap.set(index, errors); + } + }); + + return errorMap; +} diff --git a/src/server/lib/yamlConfigValidator.ts b/src/server/lib/yamlConfigValidator.ts index df37f07b..e6748ae7 100644 --- a/src/server/lib/yamlConfigValidator.ts +++ b/src/server/lib/yamlConfigValidator.ts @@ -40,7 +40,10 @@ export class ValidationError extends LifecycleError { } } -JsonSchema.Validator.prototype.customFormats.webhookType = (input) => input === 'codefresh'; +JsonSchema.Validator.prototype.customFormats.webhookType = (input) => { + const validTypes = ['codefresh', 'docker', 'command']; + return validTypes.includes(input); +}; JsonSchema.Validator.prototype.customFormats.webhookState = (input) => { let result: boolean = false; diff --git a/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts b/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts index 10fcd99a..2b004a60 100644 --- a/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts +++ b/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts @@ -17,6 +17,7 @@ import { kedaScaleToZero } from './keda'; import { deployment } from './deployment'; import { docker } from './docker'; +import { webhooks } from './webhooks'; const schema_1_0_0 = { id: 'schema-1.0.0', @@ -62,27 +63,7 @@ const schema_1_0_0 = { required: ['name'], }, }, - webhooks: { - type: 'array', - minItems: 1, - items: { - type: 'object', - additionalProperties: false, - properties: { - name: { type: 'string' }, - description: { type: 'string' }, - state: { type: 'string', format: 'webhookState' }, - type: { type: 'string', format: 'webhookType' }, - pipelineId: { type: 'string' }, - trigger: { type: 'string' }, - env: { - type: 'object', - required: ['branch'], - }, - }, - required: ['state', 'type', 'pipelineId', 'trigger', 'env'], - }, - }, + webhooks, }, }, services: { diff --git a/src/server/lib/yamlSchemas/schema_1_0_0/webhooks.ts b/src/server/lib/yamlSchemas/schema_1_0_0/webhooks.ts new file mode 100644 index 00000000..f65f2c1a --- /dev/null +++ b/src/server/lib/yamlSchemas/schema_1_0_0/webhooks.ts @@ -0,0 +1,75 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const dockerWebhookConfig = { + type: 'object', + additionalProperties: false, + properties: { + image: { type: 'string' }, + command: { + type: 'array', + items: { type: 'string' }, + }, + args: { + type: 'array', + items: { type: 'string' }, + }, + timeout: { type: 'number' }, + }, + required: ['image'], +}; + +const commandWebhookConfig = { + type: 'object', + additionalProperties: false, + properties: { + image: { type: 'string' }, + script: { type: 'string' }, + timeout: { type: 'number' }, + }, + required: ['image', 'script'], +}; + +const webhooks = { + type: 'array', + minItems: 1, + items: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + state: { type: 'string', format: 'webhookState' }, + type: { type: 'string', format: 'webhookType' }, + + // Codefresh-specific fields (optional for new types) + pipelineId: { type: 'string' }, + trigger: { type: 'string' }, + + // Docker webhook configuration + docker: dockerWebhookConfig, + + // Command webhook configuration + command: commandWebhookConfig, + + // Environment variables (required for all types) + env: { type: 'object' }, + }, + required: ['state', 'type', 'env'], + }, +}; + +export { webhooks }; diff --git a/src/server/models/yaml/YamlWebhook.ts b/src/server/models/yaml/YamlWebhook.ts index f6cb8ce8..8f0d355a 100644 --- a/src/server/models/yaml/YamlWebhook.ts +++ b/src/server/models/yaml/YamlWebhook.ts @@ -14,12 +14,35 @@ * limitations under the License. */ +export interface DockerWebhookConfig { + readonly image: string; + readonly command?: string[]; + readonly args?: string[]; + readonly timeout?: number; // seconds, default 1800 (30 min) +} + +export interface CommandWebhookConfig { + readonly image: string; + readonly script: string; + readonly timeout?: number; // seconds, default 1800 (30 min) +} + export interface Webhook { readonly name?: string; readonly description?: string; readonly state: string; - readonly type: string; - readonly pipelineId: string; - readonly trigger: string; + readonly type: string; // 'codefresh' | 'docker' | 'command' + + // Codefresh-specific fields (required when type === 'codefresh') + readonly pipelineId?: string; + readonly trigger?: string; + + // Docker webhook configuration (required when type === 'docker') + readonly docker?: DockerWebhookConfig; + + // Command webhook configuration (required when type === 'command') + readonly command?: CommandWebhookConfig; + + // Environment variables for all webhook types readonly env: Record; } diff --git a/src/server/services/webhook.ts b/src/server/services/webhook.ts index d76143d5..4b2ae46c 100644 --- a/src/server/services/webhook.ts +++ b/src/server/services/webhook.ts @@ -25,6 +25,8 @@ import { ConfigFileWebhookEnvironmentVariables } from 'server/lib/configFileWebh import { LifecycleError } from 'server/lib/errors'; import { JOB_VERSION } from 'shared/config'; import { redisClient } from 'server/lib/dependencies'; +import { validateWebhook } from 'server/lib/webhook/webhookValidator'; +import { executeDockerWebhook, executeCommandWebhook } from 'server/lib/webhook'; const logger = rootLogger.child({ filename: 'services/webhook.ts', @@ -120,30 +122,86 @@ export default class WebhookService extends BaseService { * @param build */ private async runYamlConfigFileWebhookForBuild(webhook: YamlService.Webhook, build: Build): Promise { + // Validate webhook configuration + const validationErrors = validateWebhook(webhook); + if (validationErrors.length > 0) { + const errorMessage = validationErrors.map((e) => `${e.field}: ${e.message}`).join(', '); + throw new Error(`Invalid webhook configuration: ${errorMessage}`); + } + const envVariables = await new ConfigFileWebhookEnvironmentVariables(this.db).resolve(build, webhook); const data = merge(envVariables, build.commentRuntimeEnv); + try { - const buildId: string = await this.db.services.Codefresh.triggerYamlConfigWebhookPipeline(webhook, data); - logger - .child({ url: `https://g.codefresh.io/build/${buildId}` }) - .info(`[BUILD ${build.uuid}] Webhook (${webhook.name}) triggered: ${buildId}`); - - const metadata = { - link: `https://g.codefresh.io/build/${buildId}`, - }; + let metadata: Record = {}; + let status = 'invoked'; + + switch (webhook.type) { + case 'codefresh': { + const buildId: string = await this.db.services.Codefresh.triggerYamlConfigWebhookPipeline(webhook, data); + logger + .child({ url: `https://g.codefresh.io/build/${buildId}` }) + .info(`[BUILD ${build.uuid}] Webhook (${webhook.name}) triggered: ${buildId}`); + metadata = { + link: `https://g.codefresh.io/build/${buildId}`, + }; + break; + } + + case 'docker': { + const result = await executeDockerWebhook(webhook, build, data); + logger.info(`[BUILD ${build.uuid}] Docker webhook (${webhook.name}) executed: ${result.jobName}`); + metadata = { + jobName: result.jobName, + success: result.success, + ...result.metadata, + }; + status = result.success ? 'completed' : 'failed'; + break; + } + + case 'command': { + const result = await executeCommandWebhook(webhook, build, data); + logger.info(`[BUILD ${build.uuid}] Command webhook (${webhook.name}) executed: ${result.jobName}`); + metadata = { + jobName: result.jobName, + success: result.success, + ...result.metadata, + }; + status = result.success ? 'completed' : 'failed'; + break; + } + + default: + throw new Error(`Unsupported webhook type: ${webhook.type}`); + } + // create the invocation history record await this.db.models.WebhookInvocations.create({ buildId: build.id, runUUID: build.runUUID, name: webhook.name, + type: webhook.type, state: webhook.state, yamlConfig: JSON.stringify(webhook), metadata, - status: 'invoked', + status, }); logger.debug(`[BUILD ${build.uuid}] Webhook history added for runUUID: ${build.runUUID}`); } catch (error) { logger.error(`[BUILD ${build.uuid}] Error invoking webhook: ${error}`); + + // Still create a failed invocation record + await this.db.models.WebhookInvocations.create({ + buildId: build.id, + runUUID: build.runUUID, + name: webhook.name, + type: webhook.type, + state: webhook.state, + yamlConfig: JSON.stringify(webhook), + metadata: { error: error.message }, + status: 'failed', + }); } } From e1a673d5952c75beac261c4cb33b34caade98748 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Thu, 3 Jul 2025 20:27:07 -0700 Subject: [PATCH 2/3] feat: webhooks ui webhook pod logs UI ui fixes remove file --- src/components/logs/PageLayout.tsx | 169 +++-- src/components/logs/TerminalContainer.tsx | 54 +- src/components/logs/hooks/useJobPolling.ts | 6 +- .../v1/builds/[uuid]/jobs/[jobName]/logs.ts | 115 +++ .../[uuid]/services/[name]/logs/[jobName].ts | 28 +- src/pages/builds/[uuid]/webhooks.tsx | 659 ++++++++++++++++++ src/server/services/webhook.ts | 83 ++- 7 files changed, 986 insertions(+), 128 deletions(-) create mode 100644 src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs.ts create mode 100644 src/pages/builds/[uuid]/webhooks.tsx diff --git a/src/components/logs/PageLayout.tsx b/src/components/logs/PageLayout.tsx index 4de773e7..88f773d8 100644 --- a/src/components/logs/PageLayout.tsx +++ b/src/components/logs/PageLayout.tsx @@ -28,56 +28,75 @@ interface PageLayoutProps { export function PageLayout({ backLink, title, serviceName, environmentId, deploymentType, children }: PageLayoutProps) { return ( -
-
+
+
- - + Back to Environment -

+

{title}

- {serviceName && environmentId && ( -
- Service: {serviceName}   •   - Environment: {environmentId} - {deploymentType && ( - <> -   •   - Type:{' '} - + Environment:{' '} + {environmentId} + {serviceName && ( + <> +   •   Service:{' '} + {serviceName} + + )} + {deploymentType && ( + <> +   •   + Type:{' '} + - {deploymentType} - - - )} -
- )} + textTransform: 'uppercase', + }} + > + {deploymentType} + + + )} +
- + {children} + + {error && !selectedWebhook && } + + {loading ? ( + + ) : webhooks.length === 0 ? ( + + ) : ( +
+ + +
+ {selectedWebhook ? ( + setShowTimestamps(!showTimestamps)} + showEventsTab={webhookInfo?.type !== 'codefresh'} + > + {loadingWebhook ? ( +
+ + Loading webhook details... +
+ ) : activeContainer === 'codefresh' ? ( + renderCodefreshView() + ) : activeContainer === 'config' ? ( + renderConfigView() + ) : activeContainer === 'metadata' ? ( + renderMetadataView() + ) : activeContainer === 'events' ? ( + + ) : ( + c.name === activeContainer)?.state} + /> + )} +
+ ) : ( + + )} +
+
+ )} +
+ + ); +} diff --git a/src/server/services/webhook.ts b/src/server/services/webhook.ts index 4b2ae46c..5b954561 100644 --- a/src/server/services/webhook.ts +++ b/src/server/services/webhook.ts @@ -134,7 +134,6 @@ export default class WebhookService extends BaseService { try { let metadata: Record = {}; - let status = 'invoked'; switch (webhook.type) { case 'codefresh': { @@ -145,48 +144,82 @@ export default class WebhookService extends BaseService { metadata = { link: `https://g.codefresh.io/build/${buildId}`, }; + await this.db.models.WebhookInvocations.create({ + buildId: build.id, + runUUID: build.runUUID, + name: webhook.name, + type: webhook.type, + state: webhook.state, + yamlConfig: JSON.stringify(webhook), + metadata, + status: 'completed', + }); break; } case 'docker': { + const invocation = await this.db.models.WebhookInvocations.create({ + buildId: build.id, + runUUID: build.runUUID, + name: webhook.name, + type: webhook.type, + state: webhook.state, + yamlConfig: JSON.stringify(webhook), + metadata: { status: 'starting' }, + status: 'executing', + }); + logger.info(`[BUILD ${build.uuid}] Docker webhook (${webhook.name}) invoked`); + + // Execute webhook (this waits for completion) const result = await executeDockerWebhook(webhook, build, data); logger.info(`[BUILD ${build.uuid}] Docker webhook (${webhook.name}) executed: ${result.jobName}`); - metadata = { - jobName: result.jobName, - success: result.success, - ...result.metadata, - }; - status = result.success ? 'completed' : 'failed'; + + // Update the invocation record with final status + await invocation.$query().patch({ + metadata: { + jobName: result.jobName, + success: result.success, + ...result.metadata, + }, + status: result.success ? 'completed' : 'failed', + }); + break; } case 'command': { + const invocation = await this.db.models.WebhookInvocations.create({ + buildId: build.id, + runUUID: build.runUUID, + name: webhook.name, + type: webhook.type, + state: webhook.state, + yamlConfig: JSON.stringify(webhook), + metadata: { status: 'starting' }, + status: 'executing', + }); + logger.info(`[BUILD ${build.uuid}] Command webhook (${webhook.name}) invoked`); + + // Execute webhook (this waits for completion) const result = await executeCommandWebhook(webhook, build, data); logger.info(`[BUILD ${build.uuid}] Command webhook (${webhook.name}) executed: ${result.jobName}`); - metadata = { - jobName: result.jobName, - success: result.success, - ...result.metadata, - }; - status = result.success ? 'completed' : 'failed'; + + // Update the invocation record with final status + await invocation.$query().patch({ + metadata: { + jobName: result.jobName, + success: result.success, + ...result.metadata, + }, + status: result.success ? 'completed' : 'failed', + }); + break; } - default: throw new Error(`Unsupported webhook type: ${webhook.type}`); } - // create the invocation history record - await this.db.models.WebhookInvocations.create({ - buildId: build.id, - runUUID: build.runUUID, - name: webhook.name, - type: webhook.type, - state: webhook.state, - yamlConfig: JSON.stringify(webhook), - metadata, - status, - }); logger.debug(`[BUILD ${build.uuid}] Webhook history added for runUUID: ${build.runUUID}`); } catch (error) { logger.error(`[BUILD ${build.uuid}] Error invoking webhook: ${error}`); From a875883ee4004b4f9fc5e1b644c085fc9c53fc17 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Thu, 3 Jul 2025 23:48:23 -0700 Subject: [PATCH 3/3] remove unused styles, vars --- src/pages/builds/[uuid]/webhooks.tsx | 30 ---------------------------- 1 file changed, 30 deletions(-) diff --git a/src/pages/builds/[uuid]/webhooks.tsx b/src/pages/builds/[uuid]/webhooks.tsx index b73117cb..383261a6 100644 --- a/src/pages/builds/[uuid]/webhooks.tsx +++ b/src/pages/builds/[uuid]/webhooks.tsx @@ -27,7 +27,6 @@ import { EmptyTerminalState, LogViewer, EventsViewer, - JobHistoryTable, useWebSocketLogs, useJobPolling, } from '../../../components/logs'; @@ -296,24 +295,6 @@ export default function WebhookHistory() { return containerName; }; - const getStatusFromWebhookStatus = (status: string): 'Active' | 'Complete' | 'Failed' | 'Pending' => { - switch (status) { - case 'completed': - return 'Complete'; - case 'failed': - return 'Failed'; - case 'executing': - return 'Active'; - default: - return 'Pending'; - } - }; - - const webhooksForTable = webhooks.map((webhook) => ({ - ...webhook, - status: getStatusFromWebhookStatus(webhook.status), - })); - const renderWebhookBadges = (webhook: WebhookInvocation) => (
- {error && !selectedWebhook && }