diff --git a/src/app/api/v2/builds/[uuid]/destroy/route.ts b/src/app/api/v2/builds/[uuid]/destroy/route.ts new file mode 100644 index 0000000..e704066 --- /dev/null +++ b/src/app/api/v2/builds/[uuid]/destroy/route.ts @@ -0,0 +1,81 @@ +/** + * 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 { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import BuildService from 'server/services/build'; + +/** + * @openapi + * /api/v2/builds/{uuid}/destroy: + * put: + * summary: Tear down a build environment + * description: | + * Changes the status of all Deploys, Builds and Deployables associated with the specified + * UUID to torn_down. This effectively marks the environment as deleted. + * tags: + * - Builds + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The UUID of the environment + * responses: + * 200: + * description: Build has been successfully torn down + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TearDownBuildSuccessResponse' + * 404: + * description: Build not found or is a static environment + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * 400: + * description: Bad request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const PutHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => { + const { uuid: buildUuid } = params; + + const buildService = new BuildService(); + + const response = await buildService.tearDownBuild(buildUuid); + + if (response.status === 'success') { + return successResponse(response, { status: 200 }, req); + } else if (response.status === 'not_found') { + return errorResponse(response.message, { status: 404 }, req); + } else { + return errorResponse(response.message, { status: 400 }, req); + } +}; + +export const PUT = createApiHandler(PutHandler); diff --git a/src/app/api/v2/builds/[uuid]/redeploy/route.ts b/src/app/api/v2/builds/[uuid]/redeploy/route.ts new file mode 100644 index 0000000..71a0423 --- /dev/null +++ b/src/app/api/v2/builds/[uuid]/redeploy/route.ts @@ -0,0 +1,81 @@ +/** + * 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 { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import BuildService from 'server/services/build'; + +/** + * @openapi + * /api/v2/builds/{uuid}/redeploy: + * put: + * summary: Redeploy an entire build + * description: | + * Triggers a redeployment of an entire build. The build + * will be queued for deployment and its status will be updated accordingly. + * tags: + * - Builds + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The UUID of the build + * responses: + * 200: + * description: Build has been successfully queued for redeployment + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RedeployBuildSuccessResponse' + * 404: + * description: Build not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * 400: + * description: Bad request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const PutHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => { + const { uuid: buildUuid } = params; + + const buildService = new BuildService(); + + const response = await buildService.redeployBuild(buildUuid); + + if (response.status === 'success') { + return successResponse(response, { status: 200 }, req); + } else if (response.status === 'not_found') { + return errorResponse(response.message, { status: 404 }, req); + } else { + return errorResponse(response.message, { status: 400 }, req); + } +}; + +export const PUT = createApiHandler(PutHandler); diff --git a/src/app/api/v2/builds/[uuid]/services/[name]/redeploy/route.ts b/src/app/api/v2/builds/[uuid]/services/[name]/redeploy/route.ts new file mode 100644 index 0000000..5e7a264 --- /dev/null +++ b/src/app/api/v2/builds/[uuid]/services/[name]/redeploy/route.ts @@ -0,0 +1,87 @@ +/** + * 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 { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import BuildService from 'server/services/build'; + +/** + * @openapi + * /api/v2/builds/{uuid}/services/{name}/redeploy: + * put: + * summary: Redeploy a service within an environment + * description: | + * Triggers a redeployment of a specific service within an environment. The service + * will be queued for deployment and its status will be updated accordingly. + * tags: + * - Services + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The UUID of the environment + * - in: path + * name: name + * required: true + * schema: + * type: string + * description: The name of the service to redeploy + * responses: + * 200: + * description: Service has been successfully queued for redeployment + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RedeployServiceSuccessResponse' + * 404: + * description: Build or service not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * 400: + * description: Bad request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const PutHandler = async (req: NextRequest, { params }: { params: { uuid: string; name: string } }) => { + const { uuid: buildUuid, name: serviceName } = params; + + const buildService = new BuildService(); + + const response = await buildService.redeployServiceFromBuild(buildUuid, serviceName); + + if (response.status === 'success') { + return successResponse(response, { status: 200 }, req); + } else if (response.status === 'not_found') { + return errorResponse(response.message, { status: 404 }, req); + } else { + return errorResponse(response.message, { status: 400 }, req); + } +}; + +export const PUT = createApiHandler(PutHandler); diff --git a/src/app/api/v2/builds/[uuid]/webhooks/route.ts b/src/app/api/v2/builds/[uuid]/webhooks/route.ts new file mode 100644 index 0000000..daa4cae --- /dev/null +++ b/src/app/api/v2/builds/[uuid]/webhooks/route.ts @@ -0,0 +1,83 @@ +/** + * 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 { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import BuildService from 'server/services/build'; + +/** + * @openapi + * /api/v2/builds/{uuid}/webhooks: + * post: + * summary: Invoke webhooks for a build + * description: | + * Triggers the execution of configured webhooks for a specific build. + * The webhooks must be defined in the build's webhooksYaml configuration. + * tags: + * - Builds + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The UUID of the build + * responses: + * 200: + * description: Webhooks successfully queued + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/InvokeWebhooksSuccessResponse' + * 404: + * description: Build not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * 400: + * description: Bad request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const PutHandler = async (req: NextRequest, { params }: { params: { uuid: string } }) => { + const { uuid: buildUuid } = params; + + const buildService = new BuildService(); + + const response = await buildService.invokeWebhooksForBuild(buildUuid); + + if (response.status === 'success') { + return successResponse(response, { status: 200 }, req); + } else if (response.status === 'not_found') { + return errorResponse(response.message, { status: 404 }, req); + } else if (response.status === 'no_content') { + return successResponse(response, { status: 200 }, req); + } else { + return errorResponse(response.message, { status: 400 }, req); + } +}; + +export const PUT = createApiHandler(PutHandler); diff --git a/src/server/lib/kubernetes/getDeploymentPods.ts b/src/server/lib/kubernetes/getDeploymentPods.ts index 74aa917..e71d35a 100644 --- a/src/server/lib/kubernetes/getDeploymentPods.ts +++ b/src/server/lib/kubernetes/getDeploymentPods.ts @@ -160,19 +160,38 @@ export async function getDeploymentPods(deploymentName: string, uuid: string): P const namespace = `env-${uuid}`; const fullDeploymentName = `${deploymentName}-${uuid}`; - let deployment: k8s.V1Deployment; - - try { - const deployResp = await appsV1.readNamespacedDeployment(fullDeploymentName, namespace); - deployment = deployResp.body; - } catch (err: any) { - if (err?.statusCode === 404) { - return []; + const workloadSelector = `app.kubernetes.io/instance=${fullDeploymentName}`; + let matchLabels: Record | undefined; + + // Try to find a Deployment using the label selector + const deployResp = await appsV1.listNamespacedDeployment( + namespace, + undefined, + undefined, + undefined, + undefined, + workloadSelector + ); + + if (deployResp.body.items.length > 0) { + matchLabels = deployResp.body.items[0].spec?.selector?.matchLabels; + } else { + // if no Deployment found, try to find a StatefulSet + const stsResp = await appsV1.listNamespacedStatefulSet( + namespace, + undefined, + undefined, + undefined, + undefined, + workloadSelector + ); + + if (stsResp.body.items.length > 0) { + matchLabels = stsResp.body.items[0].spec?.selector?.matchLabels; } - throw err; } - const matchLabels = deployment.spec?.selector?.matchLabels; + // If neither found or no labels to match, return empty if (!matchLabels || Object.keys(matchLabels).length === 0) { return []; } @@ -209,7 +228,7 @@ export async function getDeploymentPods(deploymentName: string, uuid: string): P }; }); } catch (error) { - getLogger().error({ error }, `K8s: failed to list deployment pods service=${deploymentName}`); + getLogger().error({ error }, `K8s: failed to list workload pods service=${deploymentName}`); throw error; } } diff --git a/src/server/services/build.ts b/src/server/services/build.ts index 9a17a3e..2e26a99 100644 --- a/src/server/services/build.ts +++ b/src/server/services/build.ts @@ -44,6 +44,7 @@ import { generateGraph } from 'server/lib/dependencyGraph'; import GlobalConfigService from './globalConfig'; import { paginate, PaginationMetadata, PaginationParams } from 'server/lib/paginate'; import { getYamlFileContentFromBranch } from 'server/lib/github'; +import WebhookService from './webhook'; const tracer = Tracer.getInstance(); tracer.initialize('build-service'); @@ -202,6 +203,172 @@ export default class BuildService extends BaseService { return build; } + async redeployServiceFromBuild(buildUuid: string, serviceName: string) { + const build = await this.db.models.Build.query() + .findOne({ + uuid: buildUuid, + }) + .withGraphFetched('deploys.deployable'); + + if (!build) { + getLogger().debug(`Build not found for ${buildUuid}.`); + return { + status: 'not_found', + message: `Build not found for ${buildUuid}.`, + }; + } + + const buildId = build.id; + + const deploy = build.deploys?.find((deploy) => deploy.deployable?.name === serviceName); + + if (!deploy || !deploy.deployable) { + getLogger().debug(`Deployable ${serviceName} not found for ${buildUuid}.`); + throw new Error(`Deployable ${serviceName} not found for ${buildUuid}.`); + } + + const githubRepositoryId = Number(deploy.deployable.repositoryId); + + const runUUID = nanoid(); + + await this.resolveAndDeployBuildQueue.add('resolve-deploy', { + buildId, + githubRepositoryId, + runUUID, + ...extractContextForQueue(), + }); + + getLogger({ stage: LogStage.BUILD_QUEUED }).info(`Build: service redeploy queued service=${serviceName}`); + + const deployService = new DeployService(); + + await deploy.$query().patchAndFetch({ + runUUID, + }); + + await deployService.patchAndUpdateActivityFeed( + deploy, + { + status: DeployStatus.QUEUED, + }, + runUUID, + githubRepositoryId + ); + + return { + status: 'success', + message: `Redeploy for service ${serviceName} in environment ${buildUuid} has been queued`, + }; + } + + async redeployBuild(buildUuid: string) { + const correlationId = `api-redeploy-${Date.now()}-${nanoid(8)}`; + return withLogContext({ correlationId, buildUuid }, async () => { + const build = await this.db.models.Build.query() + .findOne({ uuid: buildUuid }) + .withGraphFetched('deploys.deployable'); + + if (!build) { + getLogger().debug(`Build not found for ${buildUuid}.`); + return { + status: 'not_found', + message: `Build not found for ${buildUuid}.`, + }; + } + + const buildId = build.id; + + await this.resolveAndDeployBuildQueue.add('resolve-deploy', { + buildId, + runUUID: nanoid(), + correlationId, + }); + + getLogger({ stage: LogStage.BUILD_QUEUED }).info('Build: redeploy queued'); + + return { + status: 'success', + message: `Redeploy for build ${buildUuid} has been queued`, + }; + }); + } + + async tearDownBuild(uuid: string) { + return withLogContext({ buildUuid: uuid }, async () => { + const build = await this.db.models.Build.query() + .findOne({ + uuid, + }) + .withGraphFetched('[deploys]'); + + if (!build || build.isStatic) { + getLogger().debug('Build does not exist or is static environment'); + return { + status: 'not_found', + message: `Build not found for ${uuid} or is static environment.`, + }; + } + + const deploysIds = build.deploys?.map((deploy) => deploy.id) ?? []; + + await this.db.models.Build.query().findById(build.id).patch({ + status: BuildStatus.TORN_DOWN, + statusMessage: 'Namespace was deleted successfully', + }); + + await this.db.models.Deploy.query() + .whereIn('id', deploysIds) + .patch({ status: DeployStatus.TORN_DOWN, statusMessage: 'Namespace was deleted successfully' }); + + const updatedDeploys = await this.db.models.Deploy.query() + .whereIn('id', deploysIds) + .select('id', 'uuid', 'status'); + + return { + status: 'success', + message: `Build ${uuid} has been torn down`, + namespacesUpdated: updatedDeploys, + }; + }); + } + + async invokeWebhooksForBuild(uuid: string) { + const correlationId = `api-webhook-invoke-${Date.now()}-${nanoid(8)}`; + + return withLogContext({ correlationId, buildUuid: uuid }, async () => { + const build = await this.db.models.Build.query().findOne({ uuid }); + + if (!build) { + getLogger().debug('Build not found'); + return { + status: 'not_found', + message: `Build not found for ${uuid}.`, + }; + } + + if (!build.webhooksYaml) { + getLogger().debug('No webhooks found for build'); + return { + status: 'no_content', + message: `No webhooks found for build ${uuid}.`, + }; + } + + const webhookService = new WebhookService(); + await webhookService.webhookQueue.add('webhook', { + buildId: build.id, + correlationId, + }); + + getLogger({ stage: LogStage.WEBHOOK_PROCESSING }).info('Webhook invocation queued via API'); + + return { + status: 'success', + message: `Webhooks for build ${uuid} have been queued`, + }; + }); + } + async validateLifecycleSchema(repo: string, branch: string): Promise<{ valid: boolean }> { const content = (await getYamlFileContentFromBranch(repo, branch)) as string; const parser = new YamlConfigParser(); diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index fa8a2e0..3fa22ed 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -306,6 +306,105 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + /** + * @description The specific success response for + * PUT /api/v2/builds/{uuid}/services/{name}/redeploy + */ + RedeployServiceSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + status: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['status', 'message'], + }, + }, + }, + ], + }, + + /** + * @description The specific success response for + * PUT /api/v2/builds/{uuid}/redeploy + */ + RedeployBuildSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + status: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['status', 'message'], + }, + }, + required: ['data'], + }, + ], + }, + + /** + * @description The specific success response for + * PUT /api/v2/builds/{uuid}/destroy + */ + TearDownBuildSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + status: { type: 'string' }, + message: { type: 'string' }, + namespacesUpdated: { + type: 'array', + items: { $ref: '#/components/schemas/Deploy' }, + }, + }, + required: ['status', 'message', 'namespacesUpdated'], + }, + required: ['data'], + }, + }, + ], + }, + + /** + * @description The specific success response for + * POST /api/v2/builds/{uuid}/webhooks + */ + InvokeWebhooksSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + status: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['status', 'message'], + }, + }, + required: ['data'], + }, + ], + }, + /** * @description Information about a deployment pod. */