Skip to content
Open
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
2 changes: 0 additions & 2 deletions helm/environments/local/lifecycle.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ global:
value: 'lifecycle-logs'
- name: OBJECT_STORE_USE_SSL
value: 'false'
- name: ALLOWED_ORIGINS
value: 'http://localhost:3000'
envFrom:
- secretRef:
name: app-secrets
Expand Down
102 changes: 102 additions & 0 deletions src/app/api/v2/builds/[uuid]/pods/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* 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 { getLogger } from 'server/lib/logger';
import { HttpError } from '@kubernetes/client-node';
import { createApiHandler } from 'server/lib/createApiHandler';
import { errorResponse, successResponse } from 'server/lib/response';
import { getEnvironmentPods } from 'server/lib/kubernetes/getEnvironmentPods';

/**
* @openapi
* /api/v2/builds/{uuid}/pods:
* get:
* summary: List all pods for a build
* description: |
* Returns a list of all pods running in the environment namespace for a specific build.
* Each pod includes its service name, status, age, restarts, readiness, and container information.
* tags:
* - Deployments
* operationId: listEnvironmentPods
* parameters:
* - in: path
* name: uuid
* required: true
* schema:
* type: string
* description: The UUID of the build
* responses:
* '200':
* description: List of pods
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/GetEnvironmentPodsSuccessResponse'
* '400':
* description: Invalid parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '404':
* description: Environment 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 } }) => {
const { uuid } = params;

if (!uuid) {
getLogger().warn(`API: invalid params uuid=${uuid}`);
return errorResponse('Missing or invalid uuid parameter', { status: 400 }, req);
}

try {
const pods = await getEnvironmentPods(uuid);

const response = { pods };

return successResponse(response, { status: 200 }, req);
} catch (error) {
getLogger().error({ error }, `API: pods fetch failed for build uuid=${uuid}`);

if (error instanceof HttpError) {
if (error.response?.statusCode === 404) {
return errorResponse('Environment 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);
53 changes: 51 additions & 2 deletions src/app/api/v2/builds/[uuid]/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,40 @@ import BuildService from 'server/services/build';
/**
* @openapi
* /api/v2/builds/{uuid}/webhooks:
* get:
* summary: Retrieve webhook invocations for a build
* description: |
* Retrieves all webhook invocations for a specific build,
* ordered by creation date in descending order.
* tags:
* - Builds
* operationId: getWebhooksForBuild
* parameters:
* - in: path
* name: uuid
* required: true
* schema:
* type: string
* description: The UUID of the build
* responses:
* '200':
* description: List of webhook invocations
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/GetWebhooksSuccessResponse'
* '404':
* description: Build not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '500':
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* post:
* summary: Invoke webhooks for a build
* description: |
Expand Down Expand Up @@ -62,7 +96,21 @@ import BuildService from 'server/services/build';
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
*/
const PutHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => {
const getHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => {
const { uuid: buildUuid } = params;

const buildService = new BuildService();

const response = await buildService.getWebhooksForBuild(buildUuid);

if (response.status === 'not_found') {
return errorResponse(response.message, { status: 404 }, req);
}

return successResponse(response.data, { status: 200 }, req);
};

const putHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => {
const { uuid: buildUuid } = params;

const buildService = new BuildService();
Expand All @@ -80,4 +128,5 @@ const PutHandler = async (req: NextRequest, { params }: { params: { uuid: string
}
};

export const PUT = createApiHandler(PutHandler);
export const GET = createApiHandler(getHandler);
export const PUT = createApiHandler(putHandler);
14 changes: 7 additions & 7 deletions src/server/lib/kubernetes/getDeploymentPods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface PodInfo {
containers: ContainerInfo[];
}

function loadKubeConfig(): k8s.KubeConfig {
export function loadKubeConfig(): k8s.KubeConfig {
const kc = new k8s.KubeConfig();
try {
kc.loadFromCluster();
Expand All @@ -56,14 +56,14 @@ function buildLabelSelector(matchLabels: Record<string, string>): string {
.join(',');
}

function formatAge(seconds: number): string {
export function formatAge(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 172800) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}

function podStatus(pod: k8s.V1Pod): string {
export function podStatus(pod: k8s.V1Pod): string {
const phase = pod.status?.phase ?? 'Unknown';
const statuses = pod.status?.containerStatuses ?? [];

Expand All @@ -78,18 +78,18 @@ function podStatus(pod: k8s.V1Pod): string {
return phase;
}

function podRestarts(pod: k8s.V1Pod): number {
export function podRestarts(pod: k8s.V1Pod): number {
return (pod.status?.containerStatuses ?? []).reduce((sum, cs) => sum + (cs.restartCount ?? 0), 0);
}

function podReady(pod: k8s.V1Pod): string {
export function podReady(pod: k8s.V1Pod): string {
const statuses = pod.status?.containerStatuses ?? [];
const total = statuses.length;
const ready = statuses.filter((s) => s.ready).length;
return `${ready}/${total}`;
}

function podAgeSeconds(pod: k8s.V1Pod): number {
export function podAgeSeconds(pod: k8s.V1Pod): number {
const created = pod.metadata?.creationTimestamp;
if (!created) return 0;
return Math.max(0, Math.floor((Date.now() - new Date(created).getTime()) / 1000));
Expand All @@ -105,7 +105,7 @@ function containerState(cs?: k8s.V1ContainerStatus): { state: ContainerState; re
return { state: 'Unknown' };
}

function extractContainers(pod: k8s.V1Pod): ContainerInfo[] {
export function extractContainers(pod: k8s.V1Pod): ContainerInfo[] {
const containers: ContainerInfo[] = [];

// Init containers
Expand Down
77 changes: 77 additions & 0 deletions src/server/lib/kubernetes/getEnvironmentPods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* 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 * as k8s from '@kubernetes/client-node';

import { getLogger } from 'server/lib/logger';
import {
PodInfo,
loadKubeConfig,
podStatus,
podRestarts,
podReady,
podAgeSeconds,
formatAge,
extractContainers,
} from 'server/lib/kubernetes/getDeploymentPods';

export interface EnvironmentPodInfo extends PodInfo {
serviceName: string;
}

export async function getEnvironmentPods(uuid: string): Promise<EnvironmentPodInfo[]> {
const kc = loadKubeConfig();
const coreV1 = kc.makeApiClient(k8s.CoreV1Api);

try {
const namespace = `env-${uuid}`;

const podResp = await coreV1.listNamespacedPod(namespace);
const pods = podResp.body.items ?? [];

if (pods.length === 0) {
return [];
}

return pods
.filter((pod) => {
const appName = pod.metadata?.labels?.['app.kubernetes.io/name'];
return appName !== 'native-build' && appName !== 'native-helm';
})
.map((pod) => {
const ageSeconds = podAgeSeconds(pod);
const containers = extractContainers(pod);
const serviceName =
pod.metadata?.labels?.['tags.datadoghq.com/service'] ??
pod.metadata?.labels?.['app.kubernetes.io/name'] ??
'';

return {
podName: pod.metadata?.name ?? '',
serviceName,
status: podStatus(pod),
restarts: podRestarts(pod),
ageSeconds,
age: formatAge(ageSeconds),
ready: podReady(pod),
containers,
};
});
} catch (error) {
getLogger().error({ error }, `K8s: failed to list environment pods uuid=${uuid}`);
throw error;
}
}
16 changes: 16 additions & 0 deletions src/server/services/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,22 @@ export default class BuildService extends BaseService {
});
}

async getWebhooksForBuild(
uuid: string
): Promise<{ status: 'not_found'; message: string } | { status: 'success'; data: any[] }> {
const build = await this.db.models.Build.query().select('id').findOne({ uuid });

if (!build) {
return { status: 'not_found', message: `Build not found for ${uuid}.` };
}

const data = await this.db.models.WebhookInvocations.query()
.where('buildId', build.id)
.orderBy('createdAt', 'desc');

return { status: 'success', data };
}

async validateLifecycleSchema(repo: string, branch: string): Promise<{ valid: boolean }> {
try {
const content = (await getYamlFileContentFromBranch(repo, branch)) as string;
Expand Down
Loading
Loading