diff --git a/src/pages/api/v1/builds/[uuid]/index.ts b/src/pages/api/v1/builds/[uuid]/index.ts index 5d56c66..3df9d70 100644 --- a/src/pages/api/v1/builds/[uuid]/index.ts +++ b/src/pages/api/v1/builds/[uuid]/index.ts @@ -14,14 +14,105 @@ * limitations under the License. */ +import { nanoid } from 'nanoid'; import { NextApiRequest, NextApiResponse } from 'next/types'; import rootLogger from 'server/lib/logger'; +import { Build } from 'server/models'; import BuildService from 'server/services/build'; +import OverrideService from 'server/services/override'; const logger = rootLogger.child({ filename: 'builds/[uuid]/index.ts', }); +async function retrieveBuild(req: NextApiRequest, res: NextApiResponse) { + const { uuid } = req.query; + + try { + const buildService = new BuildService(); + + const build = await buildService.db.models.Build.query() + .findOne({ uuid }) + .select( + 'id', + 'uuid', + 'status', + 'statusMessage', + 'enableFullYaml', + 'sha', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'pullRequestId', + 'manifest', + 'webhooksYaml', + 'dashboardLinks', + 'isStatic', + 'namespace' + ); + + if (!build) { + logger.info(`Build with UUID ${uuid} not found`); + return res.status(404).json({ error: 'Build not found' }); + } + + return res.status(200).json(build); + } catch (error) { + logger.error(`Error fetching build ${uuid}:`, error); + return res.status(500).json({ error: 'An unexpected error occurred' }); + } +} + +async function updateBuild(req: NextApiRequest, res: NextApiResponse) { + const { uuid } = req.query; + const { uuid: newUuid } = req.body; + + if (!newUuid || typeof newUuid !== 'string') { + logger.info(`[${uuid}] Missing or invalid uuid in request body`); + return res.status(400).json({ error: 'uuid is required' }); + } + + try { + const override = new OverrideService(); + + const build: Build = await override.db.models.Build.query().findOne({ uuid }).withGraphFetched('pullRequest'); + + if (!build) { + logger.info(`[${uuid}] Build not found, cannot patch uuid.`); + return res.status(404).json({ error: 'Build not found' }); + } + + if (newUuid === build.uuid) { + logger.info(`[${uuid}] Attempted to update UUID to same value: ${newUuid}`); + return res.status(400).json({ error: 'UUID must be different' }); + } + + const validation = await override.validateUuid(newUuid); + if (!validation.valid) { + logger.info(`[${uuid}] UUID validation failed on attempt to change: ${validation.error}`); + return res.status(400).json({ error: validation.error }); + } + + const result = await override.updateBuildUuid(build, newUuid); + + if (build.pullRequest?.deployOnUpdate) { + await new BuildService().resolveAndDeployBuildQueue.add('resolve-deploy', { + buildId: build.id, + runUUID: nanoid(), + }); + } + + return res.status(200).json({ + data: { + ...result.build, + }, + }); + } catch (error) { + logger.error({ error }, `[${uuid}] Error updating UUID to ${newUuid}: ${error}`); + return res.status(500).json({ error: 'An unexpected error occurred' }); + } +} + /** * @openapi * /api/v1/builds/{uuid}: @@ -99,50 +190,168 @@ const logger = rootLogger.child({ * properties: * error: * type: string + * patch: + * summary: Update build UUID + * description: | + * Updates the UUID (custom identifier) for a build and all related records. + * This changes the build's public URL and namespace. + * tags: + * - Builds + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The current UUID of the build to update + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * uuid: + * type: string + * description: The new UUID (3-50 characters, alphanumeric + hyphens) + * example: my-custom-environment + * required: + * - uuid + * responses: + * 200: + * description: UUID updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: object + * description: The updated build object + * properties: + * id: + * type: number + * example: 12345 + * uuid: + * type: string + * example: my-custom-environment + * namespace: + * type: string + * example: env-my-custom-environment + * updatedAt: + * type: string + * example: 2025-09-09T10:30:00Z + * status: + * type: string + * example: active + * statusMessage: + * type: string + * example: Build is running + * enableFullYaml: + * type: boolean + * example: true + * sha: + * type: string + * example: abc123def456 + * createdAt: + * type: string + * format: date-time + * example: 2025-09-09T09:00:00Z + * deletedAt: + * type: string + * format: date-time + * nullable: true + * example: null + * pullRequestId: + * type: integer + * example: 42 + * manifest: + * type: object + * example: {} + * webhooksYaml: + * type: object + * example: {} + * dashboardLinks: + * type: object + * example: {} + * isStatic: + * type: boolean + * example: false + * 400: + * description: Invalid request + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * examples: + * missing_uuid: + * value: uuid is required + * same_uuid: + * value: UUID must be different + * invalid_format: + * value: UUID can only contain letters, numbers, and hyphens + * invalid_length: + * value: UUID must be between 3 and 50 characters + * invalid_boundaries: + * value: UUID cannot start or end with a hyphen + * 404: + * description: Build not found + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: Build not found + * 405: + * description: Method not allowed + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: GET is not allowed + * 409: + * description: UUID conflict + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: UUID is not available + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: An unexpected error occurred */ // eslint-disable-next-line import/no-anonymous-default-export export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - const { uuid } = req.query; if (!uuid || typeof uuid !== 'string') { return res.status(400).json({ error: 'Invalid UUID' }); } - try { - const buildService = new BuildService(); - - const build = await buildService.db.models.Build.query() - .findOne({ uuid }) - .select( - 'id', - 'uuid', - 'status', - 'statusMessage', - 'enableFullYaml', - 'sha', - 'createdAt', - 'updatedAt', - 'deletedAt', - 'pullRequestId', - 'manifest', - 'webhooksYaml', - 'dashboardLinks', - 'isStatic', - 'namespace' - ); - - if (!build) { - logger.info(`Build with UUID ${uuid} not found`); - return res.status(404).json({ error: 'Build not found' }); - } - - return res.status(200).json(build); - } catch (error) { - logger.error(`Error fetching build ${uuid}:`, error); - return res.status(500).json({ error: 'An unexpected error occurred' }); + switch (req.method) { + case 'GET': + return retrieveBuild(req, res); + case 'PATCH': + return updateBuild(req, res); + default: + return res.status(405).json({ error: `${req.method} is not allowed` }); } }; diff --git a/src/server/services/activityStream.ts b/src/server/services/activityStream.ts index e6f6c9b..3f3f766 100644 --- a/src/server/services/activityStream.ts +++ b/src/server/services/activityStream.ts @@ -19,10 +19,10 @@ import rootLogger from 'server/lib/logger'; import { Build, PullRequest, Deploy, Repository } from 'server/models'; import * as github from 'server/lib/github'; import { APP_HOST, QUEUE_NAMES } from 'shared/config'; -import * as k8s from 'server/lib/kubernetes'; import { Metrics } from 'server/lib/metrics'; import * as psl from 'psl'; import { CommentHelper } from 'server/lib/comment'; +import OverrideService from './override'; import { BuildStatus, DeployStatus, @@ -205,7 +205,8 @@ export default class ActivityStream extends BaseService { // handle build uuid updates here if (vanityUrl && vanityUrl !== build.uuid) { - await this.handleVanityUrlChange(build, deploys, vanityUrl); + const override = new OverrideService(); + await override.updateBuildUuid(build, vanityUrl); } // if pull request should be built and deployed again, add it to build queue @@ -292,42 +293,6 @@ export default class ActivityStream extends BaseService { } } - /** - * vanity url update is basically overriding the uuid with a custom string - * @param build - The Build object to update. - * @param deploys - The list of Deploy objects associated with the build. - * @param vanityUrl - The new vanity URL (custom UUID) to assign. - */ - private async handleVanityUrlChange(build: Build, deploys: Deploy[], vanityUrl: string) { - logger.info(`[BUILD ${build.uuid}] Build UUID updated to '${vanityUrl}'`); - // delete the old namespace for cleanup - // dont await, if failed will cleanup later - k8s.deleteNamespace(build.namespace); - - await build.$query().patch({ - uuid: vanityUrl, - namespace: `env-${vanityUrl}`, - }); - - await this.db.models.Deployable.query().where('buildId', build.id).patch({ buildUUID: vanityUrl }); - - // update all deploys - // this will not work for database configured services - await Promise.all( - deploys.map(async (d) => { - const newUuid = `${d.deployable.name}-${vanityUrl}`; - await d.$query().patch({ - uuid: newUuid, - internalHostname: newUuid, - publicUrl: build.enableFullYaml - ? this.db.services.Deploy.hostForDeployableDeploy(d, d.deployable) - : this.db.services.Deploy.hostForServiceDeploy(d, d.service), - }); - }) - ); - logger.info(`[BUILD ${build.uuid}] Patched build and deploys for UUID update`); - } - private async updateMissionControlComment( build: Build, deploys: Deploy[], diff --git a/src/server/services/override.ts b/src/server/services/override.ts new file mode 100644 index 0000000..e99875e --- /dev/null +++ b/src/server/services/override.ts @@ -0,0 +1,131 @@ +/** + * 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 BaseService from './_service'; +import rootLogger from 'server/lib/logger'; +import { Build } from 'server/models'; +import * as k8s from 'server/lib/kubernetes'; +import DeployService from './deploy'; + +const logger = rootLogger.child({ + filename: 'services/override.ts', +}); + +interface ValidationResult { + valid: boolean; + error?: string; +} + +interface UpdateResult { + build: Build; + deploysUpdated: number; +} + +export default class OverrideService extends BaseService { + /** + * Validate UUID format and uniqueness + * @param uuid The UUID to validate + * @returns ValidationResult with validation status and error details + */ + async validateUuid(uuid: string): Promise { + if (uuid.length < 3 || uuid.length > 50) { + return { valid: false, error: 'UUID must be between 3 and 50 characters' }; + } + + if (!/^[a-zA-Z0-9-]+$/.test(uuid)) { + return { valid: false, error: 'UUID can only contain letters, numbers, and hyphens' }; + } + + if (uuid.startsWith('-') || uuid.endsWith('-')) { + return { valid: false, error: 'UUID cannot start or end with a hyphen' }; + } + + try { + const existingBuild = await this.db.models.Build.query().findOne({ uuid }); + + if (existingBuild) { + return { valid: false, error: 'UUID is not available' }; + } + } catch (error) { + logger.error('Error checking UUID uniqueness:', error); + return { valid: false, error: 'Unable to validate UUID' }; + } + + return { valid: true }; + } + + /** + * Update build UUID and all related records + * @param build The build to update + * @param newUuid The new UUID to set + * @returns UpdateResult with updated build and count of updated deploys + */ + async updateBuildUuid(build: Build, newUuid: string): Promise { + const oldUuid = build.uuid; + const oldNamespace = build.namespace; + + logger.info(`[BUILD ${oldUuid}] Updating UUID to '${newUuid}'`); + + try { + return await this.db.models.Build.transact(async (trx) => { + await build.$query(trx).patch({ + uuid: newUuid, + namespace: `env-${newUuid}`, + }); + + await this.db.models.Deployable.query(trx).where('buildId', build.id).patch({ buildUUID: newUuid }); + + const deploys = await this.db.models.Deploy.query(trx) + .where('buildId', build.id) + .withGraphFetched('[service, deployable]'); + + // Update all deploys + // this will not work for database configured services + const deployService = new DeployService(); + const updateDeploys = deploys.map(async (deploy) => { + const newDeployUuid = `${deploy.deployable.name}-${newUuid}`; + return deploy.$query(trx).patch({ + uuid: newDeployUuid, + internalHostname: newDeployUuid, + publicUrl: build.enableFullYaml + ? deployService.hostForDeployableDeploy(deploy, deploy.deployable) + : deployService.hostForServiceDeploy(deploy, deploy.service), + }); + }); + + await Promise.all(updateDeploys); + + const updatedBuild = await this.db.models.Build.query(trx).findById(build.id); + + // Delete the old namespace for cleanup (non-blocking, outside transaction) + k8s.deleteNamespace(oldNamespace).catch((error) => { + logger.warn(`[BUILD ${oldUuid}] Failed to delete old namespace ${oldNamespace}:`, error); + }); + logger.info( + `[BUILD ${newUuid}] Successfully updated UUID from '${oldUuid}' to '${newUuid}', updated ${deploys.length} deploys` + ); + + return { + build: updatedBuild, + deploysUpdated: deploys.length, + }; + }); + } catch (error) { + logger.error(`[BUILD ${oldUuid}] Failed to update UUID to '${newUuid}': ${error}`, error); + throw error; + } + } +}