From b32707ef52113719d217bdd244470169ebb3e79c Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Fri, 8 May 2026 11:31:00 -0700 Subject: [PATCH] feat: add build service override state --- .../api/v2/builds/[uuid]/services/route.ts | 28 ++- src/server/services/__tests__/build.test.ts | 97 +++++++++ .../services/__tests__/override.test.ts | 194 ++++++++++++++++- src/server/services/build.ts | 47 ++++ src/server/services/override.ts | 205 +++++++++++++++++- src/shared/openApiSpec.test.ts | 33 ++- src/shared/openApiSpec.ts | 126 ++++++++++- 7 files changed, 704 insertions(+), 26 deletions(-) diff --git a/src/app/api/v2/builds/[uuid]/services/route.ts b/src/app/api/v2/builds/[uuid]/services/route.ts index e281a68..826d534 100644 --- a/src/app/api/v2/builds/[uuid]/services/route.ts +++ b/src/app/api/v2/builds/[uuid]/services/route.ts @@ -19,6 +19,7 @@ import { NextRequest } from 'next/server'; import { createApiHandler } from 'server/lib/createApiHandler'; import { errorResponse, successResponse } from 'server/lib/response'; import OverrideService, { + ServiceOverrideNotEditableError, ServiceOverrideNotFoundError, type ServiceOverridePatchInput, } from 'server/services/override'; @@ -40,12 +41,12 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride return new Error(`serviceOverrides[${index}] must be an object`); } - const serviceName = value.serviceName; + const name = value.name; const hasActive = hasOwn(value, 'active'); const hasBranchOrExternalUrl = hasOwn(value, 'branchOrExternalUrl'); - if (typeof serviceName !== 'string' || serviceName.length === 0) { - return new Error(`serviceOverrides[${index}].serviceName must be a non-empty string`); + if (typeof name !== 'string' || name.length === 0) { + return new Error(`serviceOverrides[${index}].name must be a non-empty string`); } if (!hasActive && !hasBranchOrExternalUrl) { @@ -61,7 +62,7 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride } return { - serviceName, + name, ...(hasActive ? { active: value.active as boolean } : {}), ...(hasBranchOrExternalUrl ? { branchOrExternalUrl: value.branchOrExternalUrl as string } : {}), }; @@ -95,7 +96,7 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride * content: * application/json: * schema: - * $ref: '#/components/schemas/BuildOverrideUpdateSuccessResponse' + * $ref: '#/components/schemas/UpdateBuildServiceOverridesSuccessResponse' * '400': * description: Invalid request body. * content: @@ -136,7 +137,7 @@ const patchHandler = async (req: NextRequest, { params }: { params: { uuid: stri const override = new OverrideService(); const build = await override.db.models.Build.query() .findOne({ uuid: params.uuid }) - .withGraphFetched('[pullRequest, deploys.[service, deployable]]'); + .withGraphFetched('[pullRequest, environment.[defaultServices, optionalServices], deploys.[service, deployable]]'); if (!build) { return errorResponse(new Error(`Build with UUID ${params.uuid} not found`), { status: 404 }, req); @@ -150,13 +151,26 @@ const patchHandler = async (req: NextRequest, { params }: { params: { uuid: stri serviceOverrides, runUuid: nanoid(), }); + const updatedBuild = await override.db.models.Build.query() + .findOne({ uuid: params.uuid }) + .withGraphFetched('[environment.[defaultServices, optionalServices], deploys.[service, deployable]]'); - return successResponse(result, { status: 200 }, req); + if (!updatedBuild) { + return errorResponse(new Error(`Build with UUID ${params.uuid} not found`), { status: 404 }, req); + } + + const updatedServiceOverrides = await override.getServiceOverrideStates(updatedBuild, updatedBuild.deploys || []); + + return successResponse({ serviceOverrides: updatedServiceOverrides, queued: result.queued }, { status: 200 }, req); } catch (error) { if (error instanceof ServiceOverrideNotFoundError) { return errorResponse(error, { status: 404 }, req); } + if (error instanceof ServiceOverrideNotEditableError) { + return errorResponse(error, { status: 400 }, req); + } + throw error; } }; diff --git a/src/server/services/__tests__/build.test.ts b/src/server/services/__tests__/build.test.ts index f67ba08..666047e 100644 --- a/src/server/services/__tests__/build.test.ts +++ b/src/server/services/__tests__/build.test.ts @@ -23,6 +23,7 @@ const mockIsFeatureEnabled = jest.fn(); const mockQueueAdd = jest.fn(); const mockCleanupDeploy = jest.fn(); const mockDeleteServiceRows = jest.fn(); +const mockGetServiceOverrideStates = jest.fn(); jest.mock('server/lib/dependencies', () => ({ defaultDb: {}, @@ -142,6 +143,13 @@ jest.mock('server/services/webhook', () => ({ })), })); +jest.mock('server/services/override', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + getServiceOverrideStates: (...args: any[]) => mockGetServiceOverrideStates(...args), + })), +})); + jest.mock('server/lib/fastly', () => jest.fn().mockImplementation(() => ({ getServiceDashboardUrl: jest.fn(), @@ -245,6 +253,95 @@ describe('BuildService build response queries', () => { expect(query.findOne).toHaveBeenCalledWith({ uuid: 'sample-build' }); expect(query.select.mock.calls[0]).toEqual(expect.arrayContaining(['commentRuntimeEnv', 'commentInitEnv'])); }); + + test('attaches service override edit state to deploys when loading a build by UUID', async () => { + const build = { + id: 10, + uuid: 'sample-build', + deploys: [ + { + uuid: 'api-sample-build', + deployable: { name: 'api' }, + }, + { + uuid: 'internal-sample-build', + deployable: { name: 'internal' }, + }, + ], + }; + const buildForServiceOverrides = { + id: 10, + uuid: 'sample-build', + deploys: [{ uuid: 'api-sample-build' }], + }; + const query: any = { + findOne: jest.fn(() => query), + select: jest.fn(() => query), + withGraphFetched: jest.fn(() => query), + modifyGraph: jest.fn(() => query), + then: (resolve: (value: any) => void, reject: (reason: unknown) => void) => + Promise.resolve(build).then(resolve, reject), + }; + const serviceOverrideQuery: any = { + findOne: jest.fn(() => serviceOverrideQuery), + select: jest.fn(() => serviceOverrideQuery), + withGraphFetched: jest.fn(() => serviceOverrideQuery), + then: (resolve: (value: any) => void, reject: (reason: unknown) => void) => + Promise.resolve(buildForServiceOverrides).then(resolve, reject), + }; + const buildService = new BuildService( + { + models: { + Build: { + query: jest.fn().mockReturnValueOnce(query).mockReturnValueOnce(serviceOverrideQuery), + }, + }, + } as any, + {} as any, + {} as any, + createQueueManager() as any + ); + mockGetServiceOverrideStates.mockResolvedValueOnce([ + { + name: 'api', + active: true, + branchOrExternalUrl: 'feature/api', + status: 'deployed', + statusMessage: null, + updatedAt: '2026-05-08T12:00:00.000Z', + group: 'default', + editable: true, + }, + ]); + + await expect(buildService.getBuildByUUID('sample-build')).resolves.toBe(build); + + expect(serviceOverrideQuery.findOne).toHaveBeenCalledWith({ id: 10 }); + expect(serviceOverrideQuery.withGraphFetched).toHaveBeenCalledWith( + '[environment.[defaultServices, optionalServices], deploys.[service, deployable]]' + ); + expect(mockGetServiceOverrideStates).toHaveBeenCalledWith( + buildForServiceOverrides, + buildForServiceOverrides.deploys + ); + expect(build.deploys).toEqual([ + { + uuid: 'api-sample-build', + deployable: { name: 'api' }, + serviceOverride: { + name: 'api', + branchOrExternalUrl: 'feature/api', + group: 'default', + editable: true, + }, + }, + { + uuid: 'internal-sample-build', + deployable: { name: 'internal' }, + serviceOverride: null, + }, + ]); + }); }); describe('BuildService failure boundaries', () => { diff --git a/src/server/services/__tests__/override.test.ts b/src/server/services/__tests__/override.test.ts index 0949de7..c4da190 100644 --- a/src/server/services/__tests__/override.test.ts +++ b/src/server/services/__tests__/override.test.ts @@ -57,6 +57,7 @@ jest.mock('../build', () => ({ })); import OverrideService, { ApplyBuildOverridesArgs, BuildConfigPatchInput, BuildOverrideInput } from '../override'; +import { DeployTypes } from 'shared/constants'; const createPatchable = () => { const patch = jest.fn().mockResolvedValue(undefined); @@ -107,26 +108,37 @@ function createFullYamlArgs(overrides: Partial = {}): ApplyB name: 'api', buildUUID: 'current-build', buildId: 42, + active: true, + type: DeployTypes.GITHUB, $query: deployablePatchable.model.$query, }; const deploy = { + active: true, + branchName: 'main', + publicUrl: 'api-public-url', deployable, service: { id: 7, name: 'api', + type: DeployTypes.GITHUB, }, $query: deployPatchable.model.$query, }; const dependentDeploy = { + active: true, deployable: { name: 'api-worker', dependsOnDeployableName: 'api', + dependsOnServiceId: 7, buildUUID: 'current-build', buildId: 42, + active: true, + type: DeployTypes.GITHUB, }, service: { id: 8, name: 'api-worker', + type: DeployTypes.GITHUB, }, $query: dependentPatchable.model.$query, }; @@ -166,32 +178,50 @@ function createClassicArgs(overrides: Partial = {}): ApplyBu id: 42, uuid: 'current-build', enableFullYaml: false, + environment: { + defaultServices: [ + { + id: 7, + }, + ], + optionalServices: [], + }, $query: buildPatchable.model.$query, }; const deployable = { name: 'api', buildUUID: 'current-build', buildId: 42, + type: DeployTypes.GITHUB, $query: deployablePatchable.model.$query, }; const deploy = { + serviceId: 7, + active: true, + branchName: 'main', + publicUrl: 'classic-public-url', deployable, service: { id: 7, name: 'api', + type: DeployTypes.GITHUB, }, $query: deployPatchable.model.$query, }; const dependentDeploy = { + serviceId: 8, + active: true, deployable: { name: 'api-worker', buildUUID: 'current-build', buildId: 42, + type: DeployTypes.GITHUB, }, service: { id: 8, name: 'api-worker', dependsOnServiceId: 7, + type: DeployTypes.GITHUB, }, $query: dependentPatchable.model.$query, }; @@ -428,7 +458,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, ], @@ -464,7 +494,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: undefined, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, ], @@ -495,7 +525,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', branchOrExternalUrl: 'feature/api', }, ], @@ -522,7 +552,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, branchOrExternalUrl: 'api.example.com', }, @@ -552,7 +582,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, ], @@ -577,12 +607,18 @@ describe('OverrideService.applyBuildOverrides', () => { name: 'web', buildUUID: 'current-build', buildId: 42, + active: true, + type: DeployTypes.GITHUB, $query: webDeployablePatchable.model.$query, }, service: { id: 9, name: 'web', + type: DeployTypes.GITHUB, }, + active: true, + branchName: 'main', + publicUrl: 'web-public-url', $query: webDeployPatchable.model.$query, }; args.deploys.push(webDeploy as any); @@ -593,11 +629,11 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, { - serviceName: 'web', + name: 'web', branchOrExternalUrl: 'feature/web', }, ], @@ -633,11 +669,11 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, { - serviceName: 'missing-service', + name: 'missing-service', active: true, }, ], @@ -663,7 +699,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', active: false, }, ], @@ -687,7 +723,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', branchOrExternalUrl: 'feature/api', }, ], @@ -716,7 +752,7 @@ describe('OverrideService.applyBuildOverrides', () => { pullRequest: args.pullRequest, serviceOverrides: [ { - serviceName: 'api', + name: 'api', branchOrExternalUrl: 'feature/api', }, ], @@ -728,6 +764,140 @@ describe('OverrideService.applyBuildOverrides', () => { publicUrl: 'deployable-host', }); }); + + it('returns full-yaml service override edit state and excludes internal dependencies', async () => { + const { service } = createService(); + const args = createFullYamlArgs(); + const dockerDeploy = { + active: false, + status: 'built', + statusMessage: 'Ready', + updatedAt: '2026-05-08T12:00:00.000Z', + deployable: { + name: 'worker', + active: false, + type: DeployTypes.DOCKER, + dockerImage: 'repo/worker', + defaultTag: 'latest', + }, + service: { + id: 9, + name: 'worker', + type: DeployTypes.DOCKER, + }, + }; + args.deploys.push(dockerDeploy as any); + + await expect(service.getServiceOverrideStates(args.build, args.deploys)).resolves.toEqual([ + expect.objectContaining({ + name: 'api', + active: true, + branchOrExternalUrl: 'main', + group: 'default', + editable: true, + }), + expect.objectContaining({ + name: 'worker', + active: false, + branchOrExternalUrl: 'repo/worker@latest', + status: 'built', + statusMessage: 'Ready', + updatedAt: '2026-05-08T12:00:00.000Z', + group: 'optional', + editable: false, + }), + ]); + }); + + it('returns classic service override edit state grouped by environment membership', async () => { + const { service } = createService(); + const args = createClassicArgs(); + const optionalDeploy = { + serviceId: 9, + active: false, + branchName: 'feature/worker', + publicUrl: 'worker-public-url', + deployable: { + name: 'worker', + type: DeployTypes.HELM, + }, + service: { + id: 9, + name: 'worker', + type: DeployTypes.HELM, + }, + }; + (args.build.environment!.optionalServices as any[]).push({ id: 9 }); + args.deploys.push(optionalDeploy as any); + + await expect(service.getServiceOverrideStates(args.build, args.deploys)).resolves.toEqual([ + expect.objectContaining({ + name: 'api', + branchOrExternalUrl: 'main', + group: 'default', + editable: true, + }), + expect.objectContaining({ + name: 'worker', + branchOrExternalUrl: 'feature/worker', + group: 'optional', + editable: true, + }), + ]); + }); + + it('ignores unchanged display-only branch values while applying active changes', async () => { + const { service } = createService(); + const args = createFullYamlArgs(); + args.deploys[0]!.deployable!.type = DeployTypes.DOCKER; + args.deploys[0]!.deployable!.dockerImage = 'repo/api'; + args.deploys[0]!.deployable!.defaultTag = 'latest'; + + await service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + name: 'api', + active: false, + branchOrExternalUrl: 'repo/api@latest', + }, + ], + runUuid: 'run-uuid', + }); + + expect(args.deploys[0]!.deployable!.$query().patch).not.toHaveBeenCalled(); + expect(args.deploys[0]!.$query().patch).toHaveBeenCalledWith({ + active: false, + }); + }); + + it('rejects changed display-only branch values before patching', async () => { + const { service } = createService(); + const args = createFullYamlArgs(); + args.deploys[0]!.deployable!.type = DeployTypes.DOCKER; + args.deploys[0]!.deployable!.dockerImage = 'repo/api'; + args.deploys[0]!.deployable!.defaultTag = 'latest'; + + await expect( + service.applyServiceOverrides({ + build: args.build, + deploys: args.deploys, + pullRequest: args.pullRequest, + serviceOverrides: [ + { + name: 'api', + active: false, + branchOrExternalUrl: 'repo/api@changed', + }, + ], + runUuid: 'run-uuid', + }) + ).rejects.toThrow('Service api branchOrExternalUrl is not editable'); + + expect(args.deploys[0]!.$query().patch).not.toHaveBeenCalled(); + }); }); describe('OverrideService.validateUuid', () => { diff --git a/src/server/services/build.ts b/src/server/services/build.ts index 9de166d..41c6b23 100644 --- a/src/server/services/build.ts +++ b/src/server/services/build.ts @@ -50,6 +50,7 @@ import { paginate, PaginationMetadata, PaginationParams } from 'server/lib/pagin import { getYamlFileContentFromBranch } from 'server/lib/github'; import WebhookService from './webhook'; import { compactStatusMessage, statusMessageFromError } from 'server/lib/terminalFailure'; +import OverrideService, { type BuildServiceOverrideState } from './override'; const tracer = Tracer.getInstance(); tracer.initialize('build-service'); @@ -65,6 +66,11 @@ export interface IngressConfiguration { readonly ingressAnnotations?: Record; } +type DeployServiceOverrideState = Pick< + BuildServiceOverrideState, + 'name' | 'branchOrExternalUrl' | 'group' | 'editable' +>; + export default class BuildService extends BaseService { ingressService = new IngressService(this.db, this.redis, this.redlock, this.queueManager); /** @@ -358,9 +364,50 @@ export default class BuildService extends BaseService { b.select('fullName'); }); + if (!build) { + return null; + } + + await this.attachServiceOverrideStateToDeploys(build); + return build; } + private async attachServiceOverrideStateToDeploys(build: Build): Promise { + if (!build.id || !Array.isArray(build.deploys)) { + return; + } + + const buildForServiceOverrides = await this.db.models.Build.query() + .findOne({ id: build.id }) + .select('id', 'uuid', 'environmentId', 'enableFullYaml') + .withGraphFetched('[environment.[defaultServices, optionalServices], deploys.[service, deployable]]'); + + if (!buildForServiceOverrides) { + return; + } + + const overrideService = new OverrideService(this.db, this.redis, this.redlock, this.queueManager); + const overrideStates = await overrideService.getServiceOverrideStates( + buildForServiceOverrides, + buildForServiceOverrides.deploys || [] + ); + const overrideStateByName = new Map(overrideStates.map((state) => [state.name, state])); + + build.deploys.forEach((deploy) => { + const serviceName = deploy.deployable?.name || deploy.service?.name; + const state = serviceName ? overrideStateByName.get(serviceName) : null; + (deploy as Deploy & { serviceOverride: DeployServiceOverrideState | null }).serviceOverride = state + ? { + name: state.name, + branchOrExternalUrl: state.branchOrExternalUrl, + group: state.group, + editable: state.editable, + } + : null; + }); + } + async redeployServiceFromBuild(buildUuid: string, serviceName: string) { const build = await this.db.models.Build.query() .findOne({ diff --git a/src/server/services/override.ts b/src/server/services/override.ts index 7de6d84..9c03b17 100644 --- a/src/server/services/override.ts +++ b/src/server/services/override.ts @@ -21,6 +21,7 @@ import { Build, Deploy, PullRequest } from 'server/models'; import * as k8s from 'server/lib/kubernetes'; import DeployService from './deploy'; import * as psl from 'psl'; +import { DeployTypes } from 'shared/constants'; export interface ValidationResult { valid: boolean; @@ -69,7 +70,7 @@ export interface ApplyBuildConfigPatchArgs { } export interface ServiceOverridePatchInput { - serviceName: string; + name: string; active?: boolean; branchOrExternalUrl?: string; } @@ -88,6 +89,17 @@ export interface OverrideApplyResult { status: 'success'; } +export interface BuildServiceOverrideState { + name: string; + active: boolean; + branchOrExternalUrl: string | null; + status: string | null; + statusMessage: string | null; + updatedAt: string | Date | null; + group: 'default' | 'optional'; + editable: boolean; +} + export class BuildUuidValidationError extends Error { constructor(message: string) { super(message); @@ -102,10 +114,58 @@ export class ServiceOverrideNotFoundError extends Error { } } +export class ServiceOverrideNotEditableError extends Error { + constructor(serviceName: string) { + super(`Service ${serviceName} branchOrExternalUrl is not editable`); + this.name = 'ServiceOverrideNotEditableError'; + } +} + function hasOwn(value: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } +function isBranchOrExternalUrlEditable(deployType?: DeployTypes): boolean { + if (!deployType) { + return false; + } + + return [DeployTypes.GITHUB, DeployTypes.HELM, DeployTypes.EXTERNAL_HTTP].includes(deployType); +} + +function getDockerDisplayValue(dockerImage?: string | null, defaultTag?: string | null): string | null { + if (dockerImage && defaultTag) { + return `${dockerImage}@${defaultTag}`; + } + + return dockerImage || defaultTag || null; +} + +function getBranchOrExternalUrl(deploy: Deploy, deployConfig?: any): string | null | undefined { + switch (deployConfig?.type) { + case DeployTypes.GITHUB: + return deploy.branchName ?? deploy.publicUrl ?? null; + case DeployTypes.HELM: + case DeployTypes.CODEFRESH: + case DeployTypes.CONFIGURATION: + return deploy.branchName ?? null; + case DeployTypes.EXTERNAL_HTTP: + return deploy.publicUrl ?? deployConfig.defaultPublicUrl ?? null; + case DeployTypes.DOCKER: + return getDockerDisplayValue(deployConfig.dockerImage, deployConfig.defaultTag); + default: + return undefined; + } +} + +function sortServiceOverrideStates(left: BuildServiceOverrideState, right: BuildServiceOverrideState): number { + if (left.group !== right.group) { + return left.group === 'default' ? -1 : 1; + } + + return left.name.localeCompare(right.name); +} + export default class OverrideService extends BaseService { async applyBuildOverrides({ build, @@ -213,18 +273,59 @@ export default class OverrideService extends BaseService { throw new Error('serviceOverrides is required'); } + const overrideStates = await this.getServiceOverrideStates(build, deploys); + const overrideStateByName = new Map(overrideStates.map((state) => [state.name, state])); + const sanitizedServiceOverrides: ServiceOverridePatchInput[] = []; + for (const serviceOverride of serviceOverrides) { if (serviceOverride.active == null && serviceOverride.branchOrExternalUrl == null) { throw new Error('active or branchOrExternalUrl is required'); } - if (!this.findDeployForService(build, deploys, serviceOverride.serviceName)) { - throw new ServiceOverrideNotFoundError(serviceOverride.serviceName); + if (!this.findDeployForService(build, deploys, serviceOverride.name)) { + throw new ServiceOverrideNotFoundError(serviceOverride.name); + } + + const overrideState = overrideStateByName.get(serviceOverride.name); + if (!overrideState) { + throw new ServiceOverrideNotFoundError(serviceOverride.name); } + + const sanitizedServiceOverride = { ...serviceOverride }; + if (serviceOverride.branchOrExternalUrl != null && !overrideState.editable) { + if (serviceOverride.branchOrExternalUrl !== overrideState.branchOrExternalUrl) { + throw new ServiceOverrideNotEditableError(serviceOverride.name); + } + + delete sanitizedServiceOverride.branchOrExternalUrl; + } + + if (sanitizedServiceOverride.active != null || sanitizedServiceOverride.branchOrExternalUrl != null) { + sanitizedServiceOverrides.push(sanitizedServiceOverride); + } + } + + if (sanitizedServiceOverrides.length === 0) { + return { + buildUuid: build.uuid, + queued: false, + status: 'success', + }; } await Promise.all( - serviceOverrides.map((serviceOverride) => this.patchServiceOverride(build, deploys, serviceOverride, true, true)) + sanitizedServiceOverrides.map((serviceOverride) => + this.patchServiceOverride( + build, + deploys, + { + ...serviceOverride, + serviceName: serviceOverride.name, + }, + true, + true + ) + ) ); const queued = await this.enqueueRedeployIfEnabled(build, pullRequest, runUuid); @@ -235,6 +336,22 @@ export default class OverrideService extends BaseService { }; } + async getServiceOverrideStates(build: Build, deploys: Deploy[]): Promise { + if (build.enableFullYaml) { + return deploys + .map((deploy) => this.getFullYamlServiceOverrideState(deploy)) + .filter((state): state is BuildServiceOverrideState => state != null) + .sort(sortServiceOverrideStates); + } + + const groups = await this.getClassicServiceGroups(build); + + return deploys + .map((deploy) => this.getClassicServiceOverrideState(deploy, groups)) + .filter((state): state is BuildServiceOverrideState => state != null) + .sort(sortServiceOverrideStates); + } + private async patchServiceOverride( build: Build, deploys: Deploy[], @@ -345,6 +462,86 @@ export default class OverrideService extends BaseService { : deploys.find((deploy) => deploy.service.name === serviceName); } + private getFullYamlServiceOverrideState(deploy: Deploy): BuildServiceOverrideState | null { + const { deployable } = deploy; + + if (!deployable || deployable.dependsOnServiceId != null) { + return null; + } + + return this.getServiceOverrideStateForDeploy(deploy, deployable.name, deployable.active ? 'default' : 'optional'); + } + + private async getClassicServiceGroups(build: Build): Promise<{ + defaultServiceIds: Set; + optionalServiceIds: Set; + }> { + if (!build.environment && typeof build.$fetchGraph === 'function') { + await build.$fetchGraph('environment'); + } + + if ( + build.environment && + (!Array.isArray(build.environment.defaultServices) || !Array.isArray(build.environment.optionalServices)) && + typeof build.environment.$fetchGraph === 'function' + ) { + await build.environment.$fetchGraph('[defaultServices, optionalServices]'); + } + + return { + defaultServiceIds: new Set((build.environment?.defaultServices || []).map((service) => service.id)), + optionalServiceIds: new Set((build.environment?.optionalServices || []).map((service) => service.id)), + }; + } + + private getClassicServiceOverrideState( + deploy: Deploy, + { + defaultServiceIds, + optionalServiceIds, + }: { + defaultServiceIds: Set; + optionalServiceIds: Set; + } + ): BuildServiceOverrideState | null { + if (!deploy.service) { + return null; + } + + const serviceId = deploy.serviceId ?? deploy.service.id; + const group = defaultServiceIds.has(serviceId) ? 'default' : optionalServiceIds.has(serviceId) ? 'optional' : null; + if (!group) { + return null; + } + + return this.getServiceOverrideStateForDeploy(deploy, deploy.service.name, group); + } + + private getServiceOverrideStateForDeploy( + deploy: Deploy, + name: string, + group: 'default' | 'optional' + ): BuildServiceOverrideState | null { + const deployConfig = deploy.deployable || deploy.service; + const deployType = deployConfig?.type; + const branchOrExternalUrl = getBranchOrExternalUrl(deploy, deployConfig); + + if (branchOrExternalUrl === undefined) { + return null; + } + + return { + name, + active: deploy.active, + branchOrExternalUrl, + status: deploy.status ?? null, + statusMessage: deploy.statusMessage ?? null, + updatedAt: deploy.updatedAt ?? null, + group, + editable: isBranchOrExternalUrlEditable(deployType), + }; + } + private async enqueueRedeployIfEnabled( build: Build, pullRequest: PullRequest | null | undefined, diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index e528337..9adb241 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -31,6 +31,7 @@ describe('OpenAPI v2 agent session contract', () => { expect(getOperation('/api/v2/builds/{uuid}/environment-overrides', 'patch')).toBeUndefined(); expect(getOperation('/api/v2/builds/{uuid}/options', 'patch')).toBeUndefined(); expect(getOperation('/api/v2/builds/{uuid}', 'patch')?.tags).toEqual(['Builds']); + expect(getOperation('/api/v2/builds/{uuid}/services', 'get')).toBeUndefined(); expect(getOperation('/api/v2/builds/{uuid}/services', 'patch')?.tags).toEqual(['Builds']); expect(schemas.UpdateBuildConfigSuccessResponse.allOf[1].properties.data).toEqual({ $ref: '#/components/schemas/Build', @@ -65,11 +66,41 @@ describe('OpenAPI v2 agent session contract', () => { { required: ['commentRuntimeEnv'] }, { required: ['commentInitEnv'] }, ]); - expect(schemas.BuildServiceOverridePatch.required).toEqual(['serviceName']); + expect(schemas.BuildServiceOverridePatch.required).toEqual(['name']); expect(schemas.BuildServiceOverridePatch.additionalProperties).toBe(true); + expect(schemas.BuildServiceOverrideState.required).toEqual([ + 'name', + 'active', + 'branchOrExternalUrl', + 'status', + 'statusMessage', + 'updatedAt', + 'group', + 'editable', + ]); + expect(schemas.BuildServiceOverrideState.properties.group.enum).toEqual(['default', 'optional']); + expect(schemas.DeployServiceOverrideState.required).toEqual(['name', 'branchOrExternalUrl', 'group', 'editable']); + expect(schemas.DeployServiceOverrideState.properties.group.enum).toEqual(['default', 'optional']); + expect(schemas.Deploy.properties.serviceOverride).toEqual({ + nullable: true, + allOf: [{ $ref: '#/components/schemas/DeployServiceOverrideState' }], + description: + 'Computed service override edit state. Present on GET /api/v2/builds/{uuid}; null for deploys that are not editable through the service override form.', + }); expect(schemas.UpdateBuildServiceOverridesRequest.required).toEqual(['serviceOverrides']); expect(schemas.UpdateBuildServiceOverridesRequest.properties.serviceOverrides.minItems).toBe(1); expect(schemas.UpdateBuildServiceOverridesRequest.additionalProperties).toBe(true); + expect(schemas.GetBuildServiceOverridesSuccessResponse).toBeUndefined(); + expect( + schemas.UpdateBuildServiceOverridesSuccessResponse.allOf[1].properties.data.properties.serviceOverrides + ).toEqual({ + type: 'array', + items: { $ref: '#/components/schemas/BuildServiceOverrideState' }, + }); + expect(schemas.UpdateBuildServiceOverridesSuccessResponse.allOf[1].properties.data.required).toEqual([ + 'serviceOverrides', + 'queued', + ]); expect(schemas.BuildOverrideUpdateResult.required).toEqual(['status', 'buildUuid', 'queued']); }); diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 837cfd4..49c3dcb 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -3073,7 +3073,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { BuildServiceOverridePatch: { type: 'object', properties: { - serviceName: { + name: { type: 'string', example: 'backend', description: 'The service name to update.', @@ -3089,11 +3089,97 @@ export const openApiSpecificationForV2Api: OAS3Options = { description: 'The branch name or external URL override for the service.', }, }, - required: ['serviceName'], + required: ['name'], anyOf: [{ required: ['active'] }, { required: ['branchOrExternalUrl'] }], additionalProperties: true, }, + BuildServiceOverrideState: { + type: 'object', + properties: { + name: { + type: 'string', + example: 'backend', + description: 'The service name.', + }, + active: { + type: 'boolean', + example: true, + description: 'Whether the service is selected for the build.', + }, + branchOrExternalUrl: { + type: 'string', + nullable: true, + example: 'feature/api', + description: 'The current branch, external URL, or display-only value shown for the service.', + }, + status: { + type: 'string', + nullable: true, + example: 'deployed', + description: 'Current deploy status for the service.', + }, + statusMessage: { + type: 'string', + nullable: true, + example: 'Successfully deployed', + description: 'Current deploy status message for the service.', + }, + updatedAt: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'When the deploy was last updated.', + }, + group: { + type: 'string', + enum: ['default', 'optional'], + description: 'Whether the service belongs to the default or optional service group.', + }, + editable: { + type: 'boolean', + description: 'Whether branchOrExternalUrl can be edited for this service.', + }, + }, + required: [ + 'name', + 'active', + 'branchOrExternalUrl', + 'status', + 'statusMessage', + 'updatedAt', + 'group', + 'editable', + ], + }, + + DeployServiceOverrideState: { + type: 'object', + properties: { + name: { + type: 'string', + example: 'backend', + description: 'The service name used when submitting service override PATCH requests.', + }, + branchOrExternalUrl: { + type: 'string', + nullable: true, + example: 'feature/api', + description: 'The current branch, external URL, or display-only value shown for the service.', + }, + group: { + type: 'string', + enum: ['default', 'optional'], + description: 'Whether the service belongs to the default or optional service group.', + }, + editable: { + type: 'boolean', + description: 'Whether branchOrExternalUrl can be edited for this service.', + }, + }, + required: ['name', 'branchOrExternalUrl', 'group', 'editable'], + }, + UpdateBuildServiceOverridesRequest: { type: 'object', properties: { @@ -3249,6 +3335,12 @@ export const openApiSpecificationForV2Api: OAS3Options = { initEnv: { type: 'object', example: { PORT: '8080' } }, deployable: { $ref: '#/components/schemas/Deployable' }, repository: { $ref: '#/components/schemas/Repository' }, + serviceOverride: { + nullable: true, + allOf: [{ $ref: '#/components/schemas/DeployServiceOverrideState' }], + description: + 'Computed service override edit state. Present on GET /api/v2/builds/{uuid}; null for deploys that are not editable through the service override form.', + }, }, required: [ 'id', @@ -3344,6 +3436,36 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + /** + * @description The specific success response for the PATCH /builds/{uuid}/services endpoint. + */ + UpdateBuildServiceOverridesSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + serviceOverrides: { + type: 'array', + items: { $ref: '#/components/schemas/BuildServiceOverrideState' }, + }, + queued: { + type: 'boolean', + example: true, + description: 'Whether the build was queued for redeploy after the override update.', + }, + }, + required: ['serviceOverrides', 'queued'], + }, + }, + required: ['data'], + }, + ], + }, + /** * @description The specific success response for PATCH build override endpoints. */