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
281 changes: 245 additions & 36 deletions src/pages/api/v1/builds/[uuid]/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Comment thread
vigneshrajsb marked this conversation as resolved.
}

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}:
Expand Down Expand Up @@ -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` });
}
};
41 changes: 3 additions & 38 deletions src/server/services/activityStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[],
Expand Down
Loading