Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
113 changes: 113 additions & 0 deletions src/app/api/v2/builds/[uuid]/services/[name]/deploy-jobs/route.ts
Original file line number Diff line number Diff line change
@@ -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);
139 changes: 1 addition & 138 deletions src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeploymentJobInfo[]> {
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:
Expand Down
Loading