From be04f3e8f257cd7a5c8efd8f37bdc078e22f56bd Mon Sep 17 00:00:00 2001 From: Levon Tikoyan Date: Mon, 12 Jan 2026 19:32:40 -0500 Subject: [PATCH] v2 deploy log streaming --- .../[name]/deploy-jobs/[jobName]/route.ts | 124 +++++++++++++++ .../services/[name]/deploy-jobs/route.ts | 113 ++++++++++++++ .../[uuid]/services/[name]/deployLogs.ts | 139 +---------------- .../lib/kubernetes/getDeploymentJobs.ts | 143 ++++++++++++++++++ src/server/services/logStreaming.ts | 2 +- src/shared/openApiSpec.ts | 85 +++++++++++ 6 files changed, 467 insertions(+), 139 deletions(-) create mode 100644 src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/[jobName]/route.ts create mode 100644 src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/route.ts create mode 100644 src/server/lib/kubernetes/getDeploymentJobs.ts diff --git a/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/[jobName]/route.ts b/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/[jobName]/route.ts new file mode 100644 index 0000000..2630159 --- /dev/null +++ b/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/[jobName]/route.ts @@ -0,0 +1,124 @@ +/** + * 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 { NextRequest } from 'next/server'; +import rootLogger from 'server/lib/logger'; +import { LogStreamingService } from 'server/services/logStreaming'; +import { HttpError } from '@kubernetes/client-node'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; + +const logger = rootLogger.child({ + filename: __filename, +}); + +interface RouteParams { + uuid: string; + name: string; + jobName: string; +} +/** + * @openapi + * /api/v2/builds/{uuid}/services/{name}/deploy-jobs/{jobName}: + * get: + * summary: Get log streaming info for a deploy job + * description: | + * Returns log streaming information for a specific deploy job within a service. + * tags: + * - Deployments + * - Logs + * operationId: getDeployJobLogStreamInfo + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The UUID of the deploy + * - in: path + * name: name + * required: true + * schema: + * type: string + * description: The name of the service + * - in: path + * name: jobName + * required: true + * schema: + * type: string + * description: The name of the deploy job + * responses: + * '200': + * description: Successful response with WebSocket information + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LogStreamSuccessResponse' + * '400': + * description: Invalid parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Deploy or job not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '502': + * description: Kubernetes communication error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '500': + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest, { params }: { params: RouteParams }) => { + const { uuid, name: serviceName, jobName } = params; + + if (!uuid || !jobName || !serviceName) { + logger.warn({ uuid, serviceName, jobName }, 'Missing or invalid path parameters'); + return errorResponse('Missing or invalid parameters', { status: 400 }, req); + } + + try { + const logService = new LogStreamingService(); + + const response = await logService.getLogStreamInfo(uuid, jobName, serviceName, 'deploy'); + + return successResponse(response, { status: 200 }, req); + } catch (error: any) { + logger.error({ err: error, uuid, serviceName, jobName }, 'Error getting log streaming info'); + + if (error.message === 'Deploy not found') { + return errorResponse('Deploy not found', { status: 404 }, req); + } + + if (error instanceof HttpError || error.message?.includes('Kubernetes') || error.statusCode === 502) { + return errorResponse('Failed to communicate with Kubernetes.', { status: 502 }, req); + } + + return errorResponse('Internal server error occurred.', { status: 500 }, req); + } +}; + +export const GET = createApiHandler(getHandler); diff --git a/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/route.ts b/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/route.ts new file mode 100644 index 0000000..e5f58fb --- /dev/null +++ b/src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/route.ts @@ -0,0 +1,113 @@ +/** + * 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 { NextRequest } from 'next/server'; +import rootLogger from 'server/lib/logger'; +import { HttpError } from '@kubernetes/client-node'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import { getDeploymentJobs } from 'server/lib/kubernetes/getDeploymentJobs'; + +const logger = rootLogger.child({ + filename: __filename, +}); + +/** + * @openapi + * /api/v2/builds/{uuid}/services/{name}/deploys: + * get: + * summary: List deployment jobs for a service + * description: | + * Returns a list of all deployment jobs for a specific service within a build. + * This includes both Helm deployment jobs and GitHub-type deployment jobs. + * tags: + * - Deployments + * operationId: listDeploymentJobs + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The UUID of the build + * - in: path + * name: name + * required: true + * schema: + * type: string + * description: The name of the service + * responses: + * '200': + * description: List of deployment jobs + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/GetDeployLogsSuccessResponse' + * '400': + * description: Invalid parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Environment or service not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '502': + * description: Failed to communicate with Kubernetes + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '500': + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest, { params }: { params: { uuid: string; name: string } }) => { + const { uuid, name } = params; + + if (!uuid || !name) { + logger.warn({ uuid, name }, 'Missing or invalid path parameters'); + return errorResponse('Missing or invalid uuid or name parameters', { status: 400 }, req); + } + + try { + const namespace = `env-${uuid}`; + const deployments = await getDeploymentJobs(name, namespace); + + const response = { deployments }; + + return successResponse(response, { status: 200 }, req); + } catch (error) { + logger.error({ err: error }, `Error getting deploy logs for service ${name} in environment ${uuid}.`); + + if (error instanceof HttpError) { + if (error.response?.statusCode === 404) { + return errorResponse('Environment or service not found.', { status: 404 }, req); + } + return errorResponse('Failed to communicate with Kubernetes.', { status: 502 }, req); + } + + return errorResponse('Internal server error occurred.', { status: 500 }, req); + } +}; + +export const GET = createApiHandler(getHandler); diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts index e15cc2e..ad69c3b 100644 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts +++ b/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts @@ -16,154 +16,17 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import rootLogger from 'server/lib/logger'; -import * as k8s from '@kubernetes/client-node'; import { HttpError } from '@kubernetes/client-node'; +import { DeploymentJobInfo, getDeploymentJobs } from 'server/lib/kubernetes/getDeploymentJobs'; const logger = rootLogger.child({ filename: __filename, }); -interface DeploymentJobInfo { - jobName: string; - deployUuid: string; - sha: string; - status: 'Active' | 'Complete' | 'Failed'; - startedAt?: string; - completedAt?: string; - duration?: number; - error?: string; - podName?: string; - deploymentType: 'helm' | 'github'; -} - interface DeployLogsListResponse { deployments: DeploymentJobInfo[]; } -async function getDeploymentJobs(serviceName: string, namespace: string): Promise { - const kc = new k8s.KubeConfig(); - kc.loadFromDefault(); - const batchV1Api = kc.makeApiClient(k8s.BatchV1Api); - const coreV1Api = kc.makeApiClient(k8s.CoreV1Api); - - try { - const helmLabelSelector = `app.kubernetes.io/name=native-helm,service=${serviceName}`; - const k8sApplyLabelSelector = `app=lifecycle-deploy,type=kubernetes-apply`; - - const [helmJobsResponse, k8sJobsResponse] = await Promise.all([ - batchV1Api.listNamespacedJob(namespace, undefined, undefined, undefined, undefined, helmLabelSelector), - batchV1Api.listNamespacedJob(namespace, undefined, undefined, undefined, undefined, k8sApplyLabelSelector), - ]); - - const helmJobs = helmJobsResponse.body.items || []; - const k8sJobs = k8sJobsResponse.body.items || []; - - const relevantK8sJobs = k8sJobs.filter((job) => { - const annotations = job.metadata?.annotations || {}; - if (annotations['lifecycle/service-name'] === serviceName) { - return true; - } - - const labels = job.metadata?.labels || {}; - return labels['service'] === serviceName; - }); - - const allJobs = [...helmJobs, ...relevantK8sJobs]; - const deploymentJobs: DeploymentJobInfo[] = []; - - for (const job of allJobs) { - const jobName = job.metadata?.name || ''; - const labels = job.metadata?.labels || {}; - - const nameParts = jobName.split('-'); - const deployUuid = nameParts.slice(0, -3).join('-'); - const sha = nameParts[nameParts.length - 1]; - - const deploymentType: 'helm' | 'github' = labels['app.kubernetes.io/name'] === 'native-helm' ? 'helm' : 'github'; - - let status: DeploymentJobInfo['status'] = 'Pending'; - let error: string | undefined; - - if (job.status?.succeeded && job.status.succeeded > 0) { - status = 'Complete'; - } else if (job.status?.failed && job.status.failed > 0) { - status = 'Failed'; - const failedCondition = job.status.conditions?.find((c) => c.type === 'Failed' && c.status === 'True'); - error = failedCondition?.message || 'Job failed'; - } else if (job.status?.active && job.status.active > 0) { - status = 'Active'; - } - - const startedAt = job.status?.startTime; - const completedAt = job.status?.completionTime; - let duration: number | undefined; - - if (startedAt) { - const startTime = new Date(startedAt).getTime(); - - if (completedAt) { - const endTime = new Date(completedAt).getTime(); - duration = Math.floor((endTime - startTime) / 1000); - } else if (status === 'Active') { - duration = Math.floor((Date.now() - startTime) / 1000); - } - } - - let podName: string | undefined; - if (job.spec?.selector?.matchLabels) { - const podLabelSelector = Object.entries(job.spec.selector.matchLabels) - .map(([key, value]) => `${key}=${value}`) - .join(','); - - try { - const podListResponse = await coreV1Api.listNamespacedPod( - namespace, - undefined, - undefined, - undefined, - undefined, - podLabelSelector - ); - const pods = podListResponse.body.items || []; - if (pods.length > 0) { - podName = pods[0].metadata?.name; - - if (status === 'Active' && pods[0].status?.phase === 'Pending') { - status = 'Pending'; - } - } - } catch (podError) { - logger.warn(`Failed to get pods for job ${jobName}:`, podError); - } - } - - deploymentJobs.push({ - jobName, - deployUuid, - sha, - status, - startedAt: startedAt ? new Date(startedAt).toISOString() : undefined, - completedAt: completedAt ? new Date(completedAt).toISOString() : undefined, - duration, - error, - podName, - deploymentType, - }); - } - - deploymentJobs.sort((a, b) => { - const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0; - const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0; - return bTime - aTime; - }); - - return deploymentJobs; - } catch (error) { - logger.error(`Error listing deployment jobs for service ${serviceName}:`, error); - throw error; - } -} - /** * @openapi * /api/v1/builds/{uuid}/services/{name}/deployLogs: diff --git a/src/server/lib/kubernetes/getDeploymentJobs.ts b/src/server/lib/kubernetes/getDeploymentJobs.ts new file mode 100644 index 0000000..48294d1 --- /dev/null +++ b/src/server/lib/kubernetes/getDeploymentJobs.ts @@ -0,0 +1,143 @@ +import * as k8s from '@kubernetes/client-node'; +import rootLogger from 'server/lib/logger'; + +export interface DeploymentJobInfo { + jobName: string; + deployUuid: string; + sha: string; + status: 'Active' | 'Complete' | 'Failed' | 'Pending'; + startedAt?: string; + completedAt?: string; + duration?: number; + error?: string; + podName?: string; + deploymentType: 'helm' | 'github'; +} + +const logger = rootLogger.child({ + filename: __filename, +}); + +export async function getDeploymentJobs(serviceName: string, namespace: string): Promise { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + const batchV1Api = kc.makeApiClient(k8s.BatchV1Api); + const coreV1Api = kc.makeApiClient(k8s.CoreV1Api); + + try { + const helmLabelSelector = `app.kubernetes.io/name=native-helm,service=${serviceName}`; + const k8sApplyLabelSelector = `app=lifecycle-deploy,type=kubernetes-apply`; + + const [helmJobsResponse, k8sJobsResponse] = await Promise.all([ + batchV1Api.listNamespacedJob(namespace, undefined, undefined, undefined, undefined, helmLabelSelector), + batchV1Api.listNamespacedJob(namespace, undefined, undefined, undefined, undefined, k8sApplyLabelSelector), + ]); + + const helmJobs = helmJobsResponse.body.items || []; + const k8sJobs = k8sJobsResponse.body.items || []; + + const relevantK8sJobs = k8sJobs.filter((job) => { + const annotations = job.metadata?.annotations || {}; + if (annotations['lifecycle/service-name'] === serviceName) { + return true; + } + + const labels = job.metadata?.labels || {}; + return labels['service'] === serviceName; + }); + + const allJobs = [...helmJobs, ...relevantK8sJobs]; + const deploymentJobs: DeploymentJobInfo[] = []; + + for (const job of allJobs) { + const jobName = job.metadata?.name || ''; + const labels = job.metadata?.labels || {}; + + const nameParts = jobName.split('-'); + const deployUuid = nameParts.slice(0, -3).join('-'); + const sha = nameParts[nameParts.length - 1]; + + const deploymentType: 'helm' | 'github' = labels['app.kubernetes.io/name'] === 'native-helm' ? 'helm' : 'github'; + + let status: DeploymentJobInfo['status'] = 'Pending'; + let error: string | undefined; + + if (job.status?.succeeded && job.status.succeeded > 0) { + status = 'Complete'; + } else if (job.status?.failed && job.status.failed > 0) { + status = 'Failed'; + const failedCondition = job.status.conditions?.find((c) => c.type === 'Failed' && c.status === 'True'); + error = failedCondition?.message || 'Job failed'; + } else if (job.status?.active && job.status.active > 0) { + status = 'Active'; + } + + const startedAt = job.status?.startTime; + const completedAt = job.status?.completionTime; + let duration: number | undefined; + + if (startedAt) { + const startTime = new Date(startedAt).getTime(); + + if (completedAt) { + const endTime = new Date(completedAt).getTime(); + duration = Math.floor((endTime - startTime) / 1000); + } else if (status === 'Active') { + duration = Math.floor((Date.now() - startTime) / 1000); + } + } + + let podName: string | undefined; + if (job.spec?.selector?.matchLabels) { + const podLabelSelector = Object.entries(job.spec.selector.matchLabels) + .map(([key, value]) => `${key}=${value}`) + .join(','); + + try { + const podListResponse = await coreV1Api.listNamespacedPod( + namespace, + undefined, + undefined, + undefined, + undefined, + podLabelSelector + ); + const pods = podListResponse.body.items || []; + if (pods.length > 0) { + podName = pods[0].metadata?.name; + + if (status === 'Active' && pods[0].status?.phase === 'Pending') { + status = 'Pending'; + } + } + } catch (podError) { + logger.warn(`Failed to get pods for job ${jobName}:`, podError); + } + } + + deploymentJobs.push({ + jobName, + deployUuid, + sha, + status, + startedAt: startedAt ? new Date(startedAt).toISOString() : undefined, + completedAt: completedAt ? new Date(completedAt).toISOString() : undefined, + duration, + error, + podName, + deploymentType, + }); + } + + deploymentJobs.sort((a, b) => { + const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0; + const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0; + return bTime - aTime; + }); + + return deploymentJobs; + } catch (error) { + logger.error(`Error listing deployment jobs for service ${serviceName}:`, error); + throw error; + } +} diff --git a/src/server/services/logStreaming.ts b/src/server/services/logStreaming.ts index 11763c2..c22e753 100644 --- a/src/server/services/logStreaming.ts +++ b/src/server/services/logStreaming.ts @@ -34,7 +34,7 @@ export class LogStreamingService { uuid: string, jobName: string, serviceName?: string, // Optional for webhooks - explicitType?: string + explicitType?: LogType ): Promise { // 1. Validate Build Existence const build = await this.buildService.db.models.Build.query().findOne({ uuid }); diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index d257b62..887f815 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -221,6 +221,91 @@ export const openApiSpecificationForV2Api: OAS3Options = { required: ['jobName', 'buildUuid', 'sha', 'status', 'engine'], }, + /** + * @description A single deployment job record for a service within a build. + */ + DeploymentJobInfo: { + type: 'object', + properties: { + jobName: { + type: 'string', + description: 'Kubernetes job name', + example: 'deploy-uuid-helm-123-abc123', + }, + deployUuid: { + type: 'string', + description: 'Deploy UUID', + example: 'deploy-uuid', + }, + sha: { + type: 'string', + description: 'Git commit SHA', + example: 'abc123', + }, + status: { + type: 'string', + enum: ['Active', 'Complete', 'Failed', 'Pending'], + description: 'Current status of the deployment job', + }, + startedAt: { + type: 'string', + format: 'date-time', + description: 'When the job started', + }, + completedAt: { + type: 'string', + format: 'date-time', + description: 'When the job completed', + }, + duration: { + type: 'number', + description: 'Deployment duration in seconds', + }, + error: { + type: 'string', + description: 'Error message if job failed', + example: 'Job failed due to ...', + }, + podName: { + type: 'string', + description: 'Name of the pod running the job', + example: 'deploy-uuid-helm-123-abc123-pod', + }, + deploymentType: { + type: 'string', + enum: ['helm', 'github'], + description: 'Type of deployment job', + }, + }, + required: ['jobName', 'deployUuid', 'sha', 'status', 'deploymentType'], + }, + + /** + * @description The specific success response for + * GET /api/v2/builds/{uuid}/services/{name}/deploy-jobs + */ + GetDeployLogsSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + deployments: { + type: 'array', + items: { $ref: '#/components/schemas/DeploymentJobInfo' }, + }, + }, + required: ['deployments'], + }, + }, + required: ['data'], + }, + ], + }, + /** * @description The specific success response for * GET /api/v2/builds/{uuid}/services/{name}/build-jobs