From 413c9531b1fd8ce96a58fa5f43285293adfdb9d5 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sun, 25 Oct 2026 10:00:38 -0700 Subject: [PATCH 01/10] fix: pass deployment data as literal command arguments --- src/server/lib/__tests__/cli.test.ts | 115 +++++++++++------- .../lib/__tests__/execFilePromise.test.ts | 104 ++++++++++++++++ src/server/lib/cli.ts | 82 ++++++++----- .../lib/codefresh/__tests__/command.test.ts | 15 +++ .../lib/codefresh/__tests__/index.test.ts | 115 +++++++++++++----- src/server/lib/codefresh/command.ts | 7 ++ src/server/lib/codefresh/index.ts | 26 ++-- .../__tests__/generateCodefreshCmd.test.ts | 97 +++++++++++++-- .../codefresh/utils/generateCodefreshCmd.ts | 32 +++-- .../lib/helm/__tests__/helm.behavior.test.ts | 61 ++++++++-- src/server/lib/helm/__tests__/helm.test.ts | 8 +- src/server/lib/helm/helm.ts | 18 ++- .../__tests__/externalSecret.test.ts | 40 ++++-- src/server/lib/kubernetes/externalSecret.ts | 19 ++- .../nativeBuild/__tests__/buildkit.test.ts | 2 +- .../lib/nativeBuild/__tests__/utils.test.ts | 96 +++++++++++++-- src/server/lib/nativeBuild/utils.ts | 21 ++-- .../__tests__/helm.behavior.test.ts | 82 ++++++++++--- .../lib/nativeHelm/__tests__/helm.test.ts | 22 ++-- src/server/lib/nativeHelm/helm.ts | 25 ++-- src/server/lib/shell.ts | 45 +++++++ src/server/lib/shellQuote.ts | 4 + src/server/services/__tests__/ingress.test.ts | 55 +++++++-- src/server/services/ingress.ts | 25 ++-- 24 files changed, 869 insertions(+), 247 deletions(-) create mode 100644 src/server/lib/__tests__/execFilePromise.test.ts create mode 100644 src/server/lib/codefresh/__tests__/command.test.ts create mode 100644 src/server/lib/codefresh/command.ts create mode 100644 src/server/lib/shellQuote.ts diff --git a/src/server/lib/__tests__/cli.test.ts b/src/server/lib/__tests__/cli.test.ts index 03c68228..9b983b56 100644 --- a/src/server/lib/__tests__/cli.test.ts +++ b/src/server/lib/__tests__/cli.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -const mockShellPromise = jest.fn(); +const mockExecute = jest.fn(); const mockGetAllConfigs = jest.fn(); const mockProcessSecretRefs = jest.fn(); const mockWaitForSecretSync = jest.fn(); @@ -30,7 +30,9 @@ const mockWithLogContext = jest.fn((_ctx, fn) => fn()); const mockDeployQuery = jest.fn(); jest.mock('server/lib/shell', () => ({ - shellPromise: (...args: any[]) => mockShellPromise(...args), + redactCommandOutput: jest.requireActual('server/lib/shell').redactCommandOutput, + shellPromise: (...args: any[]) => mockExecute(...args), + execFilePromise: (...args: any[]) => mockExecute(...args), })); jest.mock('server/lib/logger', () => ({ @@ -140,7 +142,7 @@ describe('codefresh cli external secret resolution', () => { beforeEach(() => { jest.clearAllMocks(); mockDeployQuery.mockReset(); - mockShellPromise.mockResolvedValue('codefresh-run-id\n'); + mockExecute.mockResolvedValue('codefresh-run-id\n'); mockGetAllConfigs.mockResolvedValue({ secretProviders }); mockProcessSecretRefs.mockImplementation(({ secretRefs }) => ({ secretRefs, @@ -167,20 +169,21 @@ describe('codefresh cli external secret resolution', () => { await expect(codefreshDeploy(deploy, deploy.build, deploy.deployable)).resolves.toBe('codefresh-run-id'); - const [command, options] = mockShellPromise.mock.calls[0]; - expect(command).toContain("-v 'API_TOKEN'='resolved-token'"); + const [file, command, options] = mockExecute.mock.calls[0]; + expect(file).toBe('codefresh'); + expect(command).toContain('--variable=API_TOKEN=resolved-token'); expect(mockCreateOrUpdateNamespace).toHaveBeenCalledWith({ name: 'env-build-uuid', buildUUID: 'build-uuid', staticEnv: false, waitForReady: true, }); - expect(options.redactCommand).toContain("-v 'API_TOKEN'='[REDACTED]'"); + expect(options.redactCommand).toContain("'--variable=API_TOKEN=[REDACTED]'"); expect(options.redactCommand).not.toContain('resolved-token'); expect(mockLoggerDebug.mock.calls.map(([message]) => message).join('\n')).not.toContain('resolved-token'); }); - test('shell-quotes resolved secret values before invoking Codefresh', async () => { + test('passes resolved secret values as one literal Codefresh argument', async () => { mockReadNamespacedSecret.mockResolvedValue({ body: { data: { @@ -192,21 +195,44 @@ describe('codefresh cli external secret resolution', () => { await codefreshDeploy(deploy, deploy.build, deploy.deployable); - const [command, options] = mockShellPromise.mock.calls[0]; - expect(command).toContain("-v 'API_TOKEN'='resolved'\\''token $(echo bad)'"); - expect(command).not.toContain("resolved'token $(echo bad)"); - expect(options.redactCommand).toContain("-v 'API_TOKEN'='[REDACTED]'"); + const [file, command, options] = mockExecute.mock.calls[0]; + expect(file).toBe('codefresh'); + expect(command).toContain("--variable=API_TOKEN=resolved'token $(echo bad)"); + expect(options.redactCommand).toContain("'--variable=API_TOKEN=[REDACTED]'"); }); test('uses an immutable source ref for Codefresh while preserving the branch default', async () => { const deploy = createDeploy({ env: { API_URL: 'https://example.invalid' } }); await codefreshDeploy(deploy, deploy.build, deploy.deployable, 'immutable-sha'); - expect(mockShellPromise.mock.calls[0][0]).toContain("-b 'immutable-sha'"); + expect(mockExecute.mock.calls[0][1][2]).toEqual('--branch=immutable-sha'); - mockShellPromise.mockClear(); + mockExecute.mockClear(); await codefreshDeploy(deploy, deploy.build, deploy.deployable); - expect(mockShellPromise.mock.calls[0][0]).toContain("-b 'feature-branch'"); + expect(mockExecute.mock.calls[0][1][2]).toEqual('--branch=feature-branch'); + }); + + test('passes deploy and destroy triggers as one literal argument', async () => { + const trigger = "deploy' $(id); --extra\nnext"; + const deploy = createDeploy({ + env: { API_URL: 'https://example.invalid' }, + deployable: { ...createDeploy().deployable, deployTrigger: trigger, destroyTrigger: trigger }, + }); + await codefreshDeploy(deploy, deploy.build, deploy.deployable); + await codefreshDestroy(deploy); + expect(mockExecute).toHaveBeenCalledTimes(2); + for (const [file, args] of mockExecute.mock.calls) { + expect(file).toBe('codefresh'); + expect(args.slice(-2)).toEqual([`--trigger=${trigger}`, '-d']); + } + }); + + test('redacts an echoed secret from debug output while preserving returned stdout', async () => { + const deploy = createDeploy(); + mockExecute.mockResolvedValueOnce('resolved-token\n'); + await expect(codefreshDeploy(deploy, deploy.build, deploy.deployable)).resolves.toBe('resolved-token'); + expect(mockExecute.mock.calls[0][2].redactValues).toContain('resolved-token'); + expect(mockLoggerDebug.mock.calls.map(([message]) => message).join('\n')).not.toContain('resolved-token'); }); test('resolves destroy env secret refs before invoking Codefresh and cleans up synced resources', async () => { @@ -224,17 +250,18 @@ describe('codefresh cli external secret resolution', () => { await expect(codefreshDestroy(deploy)).resolves.toBe('codefresh-run-id'); - const [command, options] = mockShellPromise.mock.calls[0]; - expect(command).toContain("codefresh run 'deployable/destroy' -b 'deployable-branch'"); - expect(command).toContain("-v 'BUILD_UUID'='build-uuid'"); - expect(command).toContain("-v 'API_TOKEN'='resolved-token'"); - expect(options.redactCommand).toContain("-v 'API_TOKEN'='[REDACTED]'"); + const [file, command, options] = mockExecute.mock.calls[0]; + expect(file).toBe('codefresh'); + expect(command.slice(0, 3)).toEqual(['run', 'deployable/destroy', '--branch=deployable-branch']); + expect(command).toContain('--variable=BUILD_UUID=build-uuid'); + expect(command).toContain('--variable=API_TOKEN=resolved-token'); + expect(options.redactCommand).toContain("'--variable=API_TOKEN=[REDACTED]'"); expect(mockDeleteExternalSecret).toHaveBeenCalledWith('example-service-aws-secrets', 'env-build-uuid'); expect(mockDeleteNamespacedSecret).toHaveBeenCalledWith('example-service-aws-secrets', 'env-build-uuid'); }); test('cleans up synced resources when Codefresh destroy fails', async () => { - mockShellPromise.mockRejectedValueOnce(new Error('destroy failed')); + mockExecute.mockRejectedValueOnce(new Error('destroy failed')); const deploy = createDeploy({ env: {}, build: { @@ -267,11 +294,12 @@ describe('codefresh cli external secret resolution', () => { await codefreshDeploy(deploy, deploy.build, deploy.deployable); - const [command, options] = mockShellPromise.mock.calls[0]; + const [file, command, options] = mockExecute.mock.calls[0]; + expect(file).toBe('codefresh'); expect(command).toContain( - `-v 'CONFIG'='${JSON.stringify({ credentials: { token: 'nested-token' }, mode: 'test' })}'` + `--variable=CONFIG=${JSON.stringify({ credentials: { token: 'nested-token' }, mode: 'test' })}` ); - expect(options.redactCommand).toContain("-v 'CONFIG'='[REDACTED]'"); + expect(options.redactCommand).toContain("'--variable=CONFIG=[REDACTED]'"); }); test('preserves no-secret behavior without loading secret provider config', async () => { @@ -285,7 +313,7 @@ describe('codefresh cli external secret resolution', () => { expect(mockGetAllConfigs).not.toHaveBeenCalled(); expect(mockProcessSecretRefs).not.toHaveBeenCalled(); - expect(mockShellPromise.mock.calls[0][0]).toContain("-v 'API_URL'='https://example.invalid'"); + expect(mockExecute.mock.calls[0][1]).toContain('--variable=API_URL=https://example.invalid'); }); test('fails before invoking Codefresh when a secret provider is missing', async () => { @@ -296,7 +324,7 @@ describe('codefresh cli external secret resolution', () => { 'external secret providers are not configured' ); - expect(mockShellPromise).not.toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); }); test('propagates CLI teardown failures so the owning delete worker can retry', async () => { @@ -364,7 +392,7 @@ describe('generic CLI deploy lifecycle', () => { beforeEach(() => { jest.clearAllMocks(); mockDeployQuery.mockReset(); - mockShellPromise.mockReset().mockResolvedValue('command-output'); + mockExecute.mockReset().mockResolvedValue('command-output'); mockGetAllConfigs.mockReset().mockResolvedValue(databaseSettings); }); @@ -380,7 +408,7 @@ describe('generic CLI deploy lifecycle', () => { { deployUuid: 'aurora-restore-deploy-uuid', serviceName: 'aurora-restore-service' }, expect.any(Function) ); - expect(mockShellPromise).toHaveBeenCalledWith( + expect(mockExecute).toHaveBeenCalledWith( `AWS_REGION=us-east-1 pnpm run babel-node -- scripts/aurora-helper.ts deploy --stackName build-uuid-build-sha --serviceName aurora-restore-service --buildUUID build-uuid --restore latest --settings '${JSON.stringify( databaseSettings.auroraRestoreSettings )}'` @@ -390,7 +418,7 @@ describe('generic CLI deploy lifecycle', () => { test('cliDeploy fetches relations and propagates shell failures', async () => { const failure = new Error('helper failed'); const deploy = createCliDeploy(); - mockShellPromise.mockRejectedValueOnce(failure); + mockExecute.mockRejectedValueOnce(failure); await expect(cliDeploy(deploy)).rejects.toBe(failure); @@ -409,7 +437,7 @@ describe('generic CLI deploy lifecycle', () => { await deleteDeploy(deploy); - expect(mockShellPromise).toHaveBeenCalledWith( + expect(mockExecute).toHaveBeenCalledWith( `AWS_REGION=us-west-2 pnpm run babel-node -- scripts/rds-helper.ts destroy --stackName build-uuid-build-sha --serviceName rds-service --buildUUID build-uuid --snapshot latest --settings '${JSON.stringify( databaseSettings.rdsRestoreSettings )}'` @@ -421,19 +449,16 @@ describe('generic CLI deploy lifecycle', () => { ['failed', false], [undefined, undefined], ])('waitForCodefresh maps status %p to %p', async (status, expected) => { - mockShellPromise.mockResolvedValueOnce(undefined).mockResolvedValueOnce(status); + mockExecute.mockResolvedValueOnce(undefined).mockResolvedValueOnce(status); await expect(waitForCodefresh('codefresh-id')).resolves.toBe(expected); - expect(mockShellPromise).toHaveBeenNthCalledWith(1, 'codefresh wait -t 60 codefresh-id'); - expect(mockShellPromise).toHaveBeenNthCalledWith( - 2, - 'codefresh get build codefresh-id --output json | jq -r ".status"' - ); + expect(mockExecute).toHaveBeenNthCalledWith(1, 'codefresh wait -t 60 codefresh-id'); + expect(mockExecute).toHaveBeenNthCalledWith(2, 'codefresh get build codefresh-id --output json | jq -r ".status"'); }); test('waitForCodefresh translates CLI failures into the public pipeline error', async () => { - mockShellPromise.mockRejectedValueOnce(new Error('timed out')); + mockExecute.mockRejectedValueOnce(new Error('timed out')); await expect(waitForCodefresh('codefresh-id')).rejects.toThrow( 'Codefresh Pipeline Failure. Status was Error: timed out' @@ -459,7 +484,7 @@ describe('generic CLI deploy lifecycle', () => { const withGraphFetched = jest.fn().mockResolvedValue([codefresh, aurora, inactive, docker]); const where = jest.fn(() => ({ withGraphFetched })); mockDeployQuery.mockReturnValue({ where }); - mockShellPromise.mockResolvedValue('destroy-id\n'); + mockExecute.mockResolvedValue('destroy-id\n'); await deleteBuild({ id: 42, uuid: 'build-uuid' } as any); @@ -467,10 +492,10 @@ describe('generic CLI deploy lifecycle', () => { expect(mockWithLogContext).toHaveBeenCalledTimes(2); expect(codefresh.$query().patch).toHaveBeenCalledWith({ sha: null }); expect( - mockShellPromise.mock.calls.some(([command]) => command.includes("codefresh run 'deployable/destroy'")) + mockExecute.mock.calls.some(([file, args]) => file === 'codefresh' && args.includes('deployable/destroy')) ).toBe(true); expect( - mockShellPromise.mock.calls.some(([command]) => + mockExecute.mock.calls.some(([command]) => command.includes('scripts/aurora-helper.ts destroy --stackName build-uuid-build-sha') ) ).toBe(true); @@ -486,7 +511,7 @@ describe('generic CLI deploy lifecycle', () => { await deleteBuild({ id: 42, uuid: 'build-uuid' } as any); - expect(mockShellPromise).not.toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); expect(mockLoggerInfo).not.toHaveBeenCalledWith('CLI: deleting'); }); @@ -499,15 +524,15 @@ describe('generic CLI deploy lifecycle', () => { destroyTrigger: undefined, }, }); - mockShellPromise.mockResolvedValueOnce('deploy-id\n'); + mockExecute.mockResolvedValueOnce('deploy-id\n'); await expect(codefreshDeploy(deploy, deploy.build, deploy.deployable)).resolves.toBe('deploy-id'); - expect(mockShellPromise.mock.calls[0][0]).not.toContain('--trigger'); + expect(mockExecute.mock.calls[0][1].some((arg: string) => arg.startsWith('--trigger='))).toBe(false); - mockShellPromise.mockClear(); - mockShellPromise.mockResolvedValueOnce(undefined); + mockExecute.mockClear(); + mockExecute.mockResolvedValueOnce(undefined); await expect(codefreshDestroy(deploy)).resolves.toBeUndefined(); - expect(mockShellPromise.mock.calls[0][0]).not.toContain('--trigger'); + expect(mockExecute.mock.calls[0][1].some((arg: string) => arg.startsWith('--trigger='))).toBe(false); expect(mockDeleteExternalSecret).not.toHaveBeenCalled(); expect(mockUpdateLogContext).toHaveBeenCalledWith({ buildUuid: 'build-uuid' }); }); diff --git a/src/server/lib/__tests__/execFilePromise.test.ts b/src/server/lib/__tests__/execFilePromise.test.ts new file mode 100644 index 00000000..6b46e313 --- /dev/null +++ b/src/server/lib/__tests__/execFilePromise.test.ts @@ -0,0 +1,104 @@ +import { existsSync, mkdtempSync, realpathSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const mockDebug = jest.fn(); +jest.mock('server/lib/logger', () => ({ getLogger: () => ({ debug: mockDebug }) })); + +import { execFilePromise, redactCommandOutput } from '../shell'; + +describe('execFilePromise process boundary', () => { + let directory: string; + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'lfc-command-')); + }); + afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + }); + + it('passes spaces, quotes, substitutions, newlines and option-like text literally', async () => { + const marker = join(directory, 'unexpected'); + const args = [ + '', + 'two words', + "O'Hare", + '$HOME', + '`uname`', + `$(touch ${marker})`, + `; touch ${marker}`, + 'line\nnext', + '--flag', + '雪', + ]; + const output = await execFilePromise(process.execPath, [ + '-e', + 'process.stdout.write(JSON.stringify(process.argv.slice(1)))', + '--', + ...args, + ]); + expect(JSON.parse(output)).toEqual(args); + expect(existsSync(marker)).toBe(false); + }); + + it('preserves stdout and nonfatal stderr, cwd and explicit environment', async () => { + const output = await execFilePromise( + process.execPath, + [ + '-e', + 'process.stderr.write("warning");process.stdout.write(JSON.stringify([process.cwd(),process.env.LFC_TEST]))', + ], + { + cwd: directory, + env: { LFC_TEST: 'literal value' }, + } + ); + expect(JSON.parse(output)).toEqual([realpathSync(directory), 'literal value']); + }); + + it('keeps the shell runner output budget above the Node default', async () => { + const output = await execFilePromise(process.execPath, ['-e', 'process.stdout.write("x".repeat(2 * 1024 * 1024))']); + expect(output.length).toBe(2 * 1024 * 1024); + }); + + it('rejects with string diagnostics and the redacted command instead of Node’s raw command error', async () => { + const failure = await execFilePromise( + process.execPath, + ['-e', 'process.stdout.write("partial");process.stderr.write("denied");process.exit(2)', 'synthetic-secret'], + { + redactCommand: 'test-tool [REDACTED]', + } + ).catch((error) => error); + expect(typeof failure).toBe('string'); + expect(failure).toContain('Exit code: 2'); + expect(failure).toContain('test-tool [REDACTED]'); + expect(failure).toContain('denied'); + expect(failure).toContain('partial'); + expect(failure).not.toContain('synthetic-secret'); + expect(JSON.stringify(mockDebug.mock.calls)).not.toContain('synthetic-secret'); + }); + + it('enforces timeout and reports an unavailable executable', async () => { + await expect( + execFilePromise(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { timeout: 50 }) + ).rejects.toContain('SIGTERM'); + await expect(execFilePromise(join(directory, 'missing-tool'), [])).rejects.toContain('ENOENT'); + }); + + it('redacts known values echoed on either failure stream but preserves successful stdout', async () => { + const secret = "synthetic' $value"; + const script = 'process.stdout.write(process.argv[1]);process.stderr.write(process.argv[1]);process.exit(2)'; + const failure = await execFilePromise(process.execPath, ['-e', script, secret], { + redactCommand: 'test-tool [REDACTED]', + redactValues: ['', secret, secret], + }).catch((error) => error); + expect(failure).not.toContain(secret); + expect(failure).toContain('stderr:\n[REDACTED]\n\nstdout:\n[REDACTED]'); + expect(JSON.stringify(mockDebug.mock.calls)).not.toContain(secret); + await expect( + execFilePromise(process.execPath, ['-e', 'process.stdout.write(process.argv[1])', secret], { + redactValues: [secret], + }) + ).resolves.toBe(secret); + expect(redactCommandOutput('long-secret secret', ['secret', 'long-secret'])).toBe('[REDACTED] [REDACTED]'); + }); +}); diff --git a/src/server/lib/cli.ts b/src/server/lib/cli.ts index 8a77ec06..c2e31ecb 100644 --- a/src/server/lib/cli.ts +++ b/src/server/lib/cli.ts @@ -17,7 +17,9 @@ import { merge } from 'lodash'; import { Build, Deploy, Deployable } from 'server/models'; import { CLIDeployTypes, DeployTypes } from 'shared/constants'; -import { shellPromise } from './shell'; +import { execFilePromise, redactCommandOutput, shellPromise } from './shell'; +import { shellQuote } from './shellQuote'; +import { codefreshRunArgs } from './codefresh/command'; import { getLogger, withLogContext, updateLogContext } from './logger'; import GlobalConfigService from 'server/services/globalConfig'; import { DatabaseSettings } from 'server/services/types/globalConfig'; @@ -85,21 +87,30 @@ export async function codefreshDeploy(deploy: Deploy, build: Build, deployable: staticEnv: build?.isStatic, secretProviders: globalConfigs?.secretProviders, }); - const { variables, redactedVariables } = codefreshVariables(resolvedEnv.env, resolvedEnv.secretEnvKeys); + const { variables, redactedVariables, redactValues } = codefreshVariables(resolvedEnv.env, resolvedEnv.secretEnvKeys); - const deployTrigger = deployable.deployTrigger ? `--trigger ${deployable.deployTrigger}` : ``; + const deployTrigger = deployable.deployTrigger ? [`--trigger=${deployable.deployTrigger}`] : []; const serviceDeployPipelineId = deployable.deployPipelineId; const revision = sourceRef ?? deploy.branchName; - const command = `codefresh run ${shellQuote(serviceDeployPipelineId)} -b ${shellQuote(revision)} ${variables.join( - ' ' - )} ${deployTrigger} -d`; - const redactedCommand = `codefresh run ${shellQuote(serviceDeployPipelineId)} -b ${shellQuote( - revision - )} ${redactedVariables.join(' ')} ${deployTrigger} -d`; + const command = codefreshRunArgs(String(serviceDeployPipelineId ?? ''), [ + `--branch=${revision ?? ''}`, + ...variables, + ...deployTrigger, + ]); + const redactedCommand = [ + 'codefresh', + ...codefreshRunArgs(String(serviceDeployPipelineId ?? ''), [ + `--branch=${revision ?? ''}`, + ...redactedVariables, + ...deployTrigger, + ]), + ] + .map(shellQuote) + .join(' '); getLogger().debug(`About to run codefresh command: command=${redactedCommand}`); - const output = await shellPromise(command, { redactCommand: redactedCommand }); - getLogger().debug(`Codefresh run output: output=${output}`); + const output = await execFilePromise('codefresh', command, { redactCommand: redactedCommand, redactValues }); + getLogger().debug(`Codefresh run output: output=${redactCommandOutput(output, redactValues)}`); const id = output.trim(); return id; } @@ -141,20 +152,32 @@ export async function codefreshDestroy(deploy: Deploy) { staticEnv: deploy.build?.isStatic, secretProviders: globalConfigs?.secretProviders, }); - const { variables, redactedVariables } = codefreshVariables(resolvedEnv.env, resolvedEnv.secretEnvKeys); + const { variables, redactedVariables, redactValues } = codefreshVariables( + resolvedEnv.env, + resolvedEnv.secretEnvKeys + ); - const destroyTrigger = deploy.deployable.destroyTrigger ? `--trigger ${deploy.deployable.destroyTrigger}` : ``; + const destroyTrigger = deploy.deployable.destroyTrigger ? [`--trigger=${deploy.deployable.destroyTrigger}`] : []; const destroyPipelineId = deploy.deployable.destroyPipelineId; const serviceBranchName = deploy.deployable.branchName; - const command = `codefresh run ${shellQuote(destroyPipelineId)} -b ${shellQuote( - serviceBranchName - )} ${variables.join(' ')} ${destroyTrigger} -d`; - const redactedCommand = `codefresh run ${shellQuote(destroyPipelineId)} -b ${shellQuote( - serviceBranchName - )} ${redactedVariables.join(' ')} ${destroyTrigger} -d`; + const command = codefreshRunArgs(String(destroyPipelineId ?? ''), [ + `--branch=${serviceBranchName ?? ''}`, + ...variables, + ...destroyTrigger, + ]); + const redactedCommand = [ + 'codefresh', + ...codefreshRunArgs(String(destroyPipelineId ?? ''), [ + `--branch=${serviceBranchName ?? ''}`, + ...redactedVariables, + ...destroyTrigger, + ]), + ] + .map(shellQuote) + .join(' '); getLogger().debug(`Destroy command: command=${redactedCommand}`); - const output = await shellPromise(command, { redactCommand: redactedCommand }); + const output = await execFilePromise('codefresh', command, { redactCommand: redactedCommand, redactValues }); const id = output?.trim(); return id; } finally { @@ -173,21 +196,24 @@ export async function codefreshDestroy(deploy: Deploy) { } function codefreshVariables(envVariables: Record, secretEnvKeys: Set) { - const variables = Object.keys(envVariables).map((key) => codefreshVariable(key, envVariables[key])); - const redactedVariables = Object.keys(envVariables).map((key) => + const variables = Object.keys(envVariables).flatMap((key) => codefreshVariable(key, envVariables[key])); + const redactedVariables = Object.keys(envVariables).flatMap((key) => codefreshVariable(key, secretEnvKeys.has(key) ? '[REDACTED]' : envVariables[key]) ); - return { variables, redactedVariables }; + const secretValues = (value: any): string[] => { + if (value && typeof value === 'object') { + return [JSON.stringify(value), ...Object.values(value).flatMap(secretValues)]; + } + return value == null ? [] : [String(value)]; + }; + const redactValues = [...secretEnvKeys].flatMap((key) => secretValues(envVariables[key])); + return { variables, redactedVariables, redactValues }; } function codefreshVariable(key: string, value: any) { const variableValue = typeof value === 'object' ? JSON.stringify(value) : value; - return ` -v ${shellQuote(key)}=${shellQuote(variableValue)}`; -} - -function shellQuote(value: string | number | boolean | null | undefined): string { - return `'${String(value ?? '').replace(/'/g, "'\\''")}'`; + return [`--variable=${key}=${String(variableValue ?? '')}`]; } /** diff --git a/src/server/lib/codefresh/__tests__/command.test.ts b/src/server/lib/codefresh/__tests__/command.test.ts new file mode 100644 index 00000000..a9faf328 --- /dev/null +++ b/src/server/lib/codefresh/__tests__/command.test.ts @@ -0,0 +1,15 @@ +import { codefreshRunArgs } from '../command'; + +describe('Codefresh run option boundaries', () => { + it.each(['--local', '-d', '--', '-pipeline'])('rejects option-like pipeline name %s', (pipeline) => { + expect(() => codefreshRunArgs(pipeline, ['--branch=main'])).toThrow('must not start with a hyphen'); + }); + + it.each(['org/pipeline', "org/o'hare;$(id)", '雪', ''])( + 'preserves pipeline name %s and attached values', + (pipeline) => { + const args = ['--branch=--local', '--trigger=--local', '--runtime-name=--local', '--variable=--local=value']; + expect(codefreshRunArgs(pipeline, args)).toEqual(['run', pipeline, ...args, '-d']); + } + ); +}); diff --git a/src/server/lib/codefresh/__tests__/index.test.ts b/src/server/lib/codefresh/__tests__/index.test.ts index 9eff5207..51dd3b9d 100644 --- a/src/server/lib/codefresh/__tests__/index.test.ts +++ b/src/server/lib/codefresh/__tests__/index.test.ts @@ -39,6 +39,7 @@ jest.mock('server/lib/metrics', () => ({ })); jest.mock('server/lib/shell', () => ({ shellPromise: jest.fn(), + execFilePromise: jest.fn(), })); jest.mock('server/lib/utils', () => ({ waitUntil: jest.fn(), @@ -60,10 +61,11 @@ import { } from 'server/lib/codefresh'; import * as codefreshUtils from 'server/lib/codefresh/utils'; import { Metrics } from 'server/lib/metrics'; -import { shellPromise } from 'server/lib/shell'; +import { execFilePromise, shellPromise } from 'server/lib/shell'; import { waitUntil } from 'server/lib/utils'; import GlobalConfigService from 'server/services/globalConfig'; +const mockExecFilePromise = execFilePromise as jest.MockedFunction; const mockShellPromise = shellPromise as jest.MockedFunction; const mockWaitUntil = waitUntil as jest.MockedFunction; const mockGenerateCodefreshCmd = codefreshUtils.generateCodefreshCmd as jest.MockedFunction< @@ -83,6 +85,7 @@ beforeEach(() => { jest.clearAllMocks(); mockGetAllConfigs.mockReset(); mockShellPromise.mockReset(); + mockExecFilePromise.mockReset(); mockWaitUntil.mockReset(); mockGenerateCodefreshCmd.mockReset(); mockConstructEcrTag.mockReset(); @@ -96,26 +99,38 @@ describe('tagExists', () => { mockGetAllConfigs.mockResolvedValue({ lifecycleDefaults: { ecrDomain: '123456789012.dkr.ecr.us-west-2.amazonaws.com' }, }); - mockShellPromise.mockResolvedValue(''); + mockExecFilePromise.mockResolvedValue(''); await expect(tagExists({ tag: 'sha-123', ecrRepo: 'custom-images', uuid: 'environment-123' })).resolves.toBe(true); - expect(mockShellPromise).toHaveBeenCalledWith( - 'aws ecr describe-images --repository-name=custom-images --image-ids=imageTag=sha-123 --no-paginate --no-cli-auto-prompt --registry-id 123456789012' - ); + expect(mockExecFilePromise).toHaveBeenCalledWith('aws', [ + 'ecr', + 'describe-images', + '--repository-name=custom-images', + '--image-ids=imageTag=sha-123', + '--no-paginate', + '--no-cli-auto-prompt', + '--registry-id=123456789012', + ]); expect(mockLoggerInfo).toHaveBeenCalledWith('ECR: exists tag=sha-123 repo=custom-images'); expect(mockLoggerDebug).not.toHaveBeenCalled(); }); it('uses the default repository and returns false when ECR does not contain the tag', async () => { mockGetAllConfigs.mockResolvedValue({ lifecycleDefaults: {} }); - mockShellPromise.mockRejectedValue(new Error('image not found')); + mockExecFilePromise.mockRejectedValue(new Error('image not found')); await expect(tagExists({ tag: 'missing' })).resolves.toBe(false); - expect(mockShellPromise).toHaveBeenCalledWith( - 'aws ecr describe-images --repository-name=lifecycle-deployments --image-ids=imageTag=missing --no-paginate --no-cli-auto-prompt --registry-id ' - ); + expect(mockExecFilePromise).toHaveBeenCalledWith('aws', [ + 'ecr', + 'describe-images', + '--repository-name=lifecycle-deployments', + '--image-ids=imageTag=missing', + '--no-paginate', + '--no-cli-auto-prompt', + '--registry-id=', + ]); expect(mockLoggerDebug).toHaveBeenCalledWith('ECR: tag=missing not found in lifecycle-deployments'); expect(mockLoggerInfo).not.toHaveBeenCalled(); }); @@ -125,14 +140,14 @@ describe('tagExists', () => { mockGetAllConfigs.mockRejectedValue(configError); await expect(tagExists({ tag: 'sha-123' })).rejects.toBe(configError); - expect(mockShellPromise).not.toHaveBeenCalled(); + expect(mockExecFilePromise).not.toHaveBeenCalled(); }); }); describe('buildImage', () => { it('builds an image and records the completed pipeline', async () => { - mockGenerateCodefreshCmd.mockReturnValue('codefresh run command'); - mockShellPromise.mockResolvedValue('Yaml\n672ea2c44b9c09ed7c91a8ef'); + mockGenerateCodefreshCmd.mockReturnValue(['run', 'command']); + mockExecFilePromise.mockResolvedValue('Yaml\n672ea2c44b9c09ed7c91a8ef'); mockGetCodefreshPipelineIdFromOutput.mockReturnValue('672ea2c44b9c09ed7c91a8ef'); await expect(buildImage(options)).resolves.toBe('672ea2c44b9c09ed7c91a8ef'); @@ -144,7 +159,7 @@ describe('buildImage', () => { sha: options.revision, }); expect(mockGenerateCodefreshCmd).toHaveBeenCalledWith(options); - expect(mockShellPromise).toHaveBeenCalledWith('codefresh run command'); + expect(mockExecFilePromise).toHaveBeenCalledWith('codefresh', ['run', 'command']); expect(mockGetCodefreshPipelineIdFromOutput).toHaveBeenCalledWith('Yaml\n672ea2c44b9c09ed7c91a8ef'); expect(mockMetricsIncrement).toHaveBeenCalledWith('total', { error: '', @@ -158,8 +173,8 @@ describe('buildImage', () => { }); it('rejects empty CLI output without attempting to parse a pipeline id', async () => { - mockGenerateCodefreshCmd.mockReturnValue('codefresh run command'); - mockShellPromise.mockResolvedValue(''); + mockGenerateCodefreshCmd.mockReturnValue(['run', 'command']); + mockExecFilePromise.mockResolvedValue(''); await expect(buildImage(options)).rejects.toThrow('no output from Codefresh'); @@ -176,8 +191,8 @@ describe('buildImage', () => { }); it('retains the current pipeline result when non-empty output omits the Yaml marker', async () => { - mockGenerateCodefreshCmd.mockReturnValue('codefresh run command'); - mockShellPromise.mockResolvedValue('672ea2c44b9c09ed7c91a8ef'); + mockGenerateCodefreshCmd.mockReturnValue(['run', 'command']); + mockExecFilePromise.mockResolvedValue('672ea2c44b9c09ed7c91a8ef'); mockGetCodefreshPipelineIdFromOutput.mockReturnValue('672ea2c44b9c09ed7c91a8ef'); await expect(buildImage(options)).resolves.toBe('672ea2c44b9c09ed7c91a8ef'); @@ -195,8 +210,8 @@ describe('buildImage', () => { }); it('rejects output that does not yield a pipeline id', async () => { - mockGenerateCodefreshCmd.mockReturnValue('codefresh run command'); - mockShellPromise.mockResolvedValue('Yaml\nno pipeline id'); + mockGenerateCodefreshCmd.mockReturnValue(['run', 'command']); + mockExecFilePromise.mockResolvedValue('Yaml\nno pipeline id'); mockGetCodefreshPipelineIdFromOutput.mockReturnValue(''); await expect(buildImage(options)).rejects.toThrow('no returned from Codefresh'); @@ -210,8 +225,8 @@ describe('buildImage', () => { it('propagates a CLI failure and does not try to parse its output', async () => { const cliError = new Error('Codefresh unavailable'); - mockGenerateCodefreshCmd.mockReturnValue('codefresh run command'); - mockShellPromise.mockRejectedValue(cliError); + mockGenerateCodefreshCmd.mockReturnValue(['run', 'command']); + mockExecFilePromise.mockRejectedValue(cliError); await expect(buildImage(options)).rejects.toBe(cliError); @@ -303,41 +318,79 @@ describe('waitForImage', () => { describe('triggerPipeline', () => { it('maps lowercase branch data and returns the parsed build id', async () => { - mockShellPromise.mockResolvedValue('Codefresh output'); + mockExecFilePromise.mockResolvedValue('Codefresh output'); mockGetCodefreshPipelineIdFromOutput.mockReturnValue('672ea2c44b9c09ed7c91a8ef'); await expect(triggerPipeline('pipeline', 'webhook', { branch: 'main' })).resolves.toBe('672ea2c44b9c09ed7c91a8ef'); - expect(mockShellPromise).toHaveBeenCalledWith( - 'codefresh run "pipeline" -d -b "main" --trigger "webhook" -v \'branch\'=\'main\' ' - ); + expect(mockExecFilePromise).toHaveBeenCalledWith('codefresh', [ + 'run', + 'pipeline', + '--branch=main', + '--trigger=webhook', + '--variable=branch=main', + '-d', + ]); expect(mockGetCodefreshPipelineIdFromOutput).toHaveBeenCalledWith('Codefresh output'); }); it('accepts the uppercase branch variable and forwards every webhook variable', async () => { - mockShellPromise.mockResolvedValue('Codefresh output'); + mockExecFilePromise.mockResolvedValue('Codefresh output'); mockGetCodefreshPipelineIdFromOutput.mockReturnValue('build-id'); await expect(triggerPipeline('pipeline', 'webhook', { BRANCH: 'release', SHA: 'abc123' })).resolves.toBe( 'build-id' ); - expect(mockShellPromise).toHaveBeenCalledWith( - "codefresh run \"pipeline\" -d -b \"release\" --trigger \"webhook\" -v 'BRANCH'='release' -v 'SHA'='abc123' " - ); + expect(mockExecFilePromise).toHaveBeenCalledWith('codefresh', [ + 'run', + 'pipeline', + '--branch=release', + '--trigger=webhook', + '--variable=BRANCH=release', + '--variable=SHA=abc123', + '-d', + ]); }); it('rejects missing branch data before invoking Codefresh', async () => { await expect(triggerPipeline('pipeline', 'webhook', { SHA: 'abc123' })).rejects.toThrow( '[triggerPipeline][WEBHOOK pipeline/webhook] webhook error: no "branch" env var.' ); - expect(mockShellPromise).not.toHaveBeenCalled(); + expect(mockExecFilePromise).not.toHaveBeenCalled(); expect(mockGetCodefreshPipelineIdFromOutput).not.toHaveBeenCalled(); }); it('propagates CLI failures without parsing a build id', async () => { const cliError = new Error('Codefresh unavailable'); - mockShellPromise.mockRejectedValue(cliError); + mockExecFilePromise.mockRejectedValue(cliError); await expect(triggerPipeline('pipeline', 'webhook', { branch: 'main' })).rejects.toBe(cliError); expect(mockGetCodefreshPipelineIdFromOutput).not.toHaveBeenCalled(); }); }); + +it('keeps ECR and webhook values as literal arguments across every interpolation field', async () => { + const value = "quote'$(id);`uname`\nnext"; + mockGetAllConfigs.mockResolvedValue({ lifecycleDefaults: { ecrDomain: `${value}.registry` } }); + mockExecFilePromise.mockResolvedValue('output'); + await tagExists({ tag: value, ecrRepo: value }); + expect(mockExecFilePromise).toHaveBeenCalledWith('aws', [ + 'ecr', + 'describe-images', + `--repository-name=${value}`, + `--image-ids=imageTag=${value}`, + '--no-paginate', + '--no-cli-auto-prompt', + `--registry-id=${value}`, + ]); + await triggerPipeline(value, value, { branch: value, [value]: value }); + expect(mockExecFilePromise).toHaveBeenLastCalledWith('codefresh', [ + 'run', + value, + `--branch=${value}`, + `--trigger=${value}`, + `--variable=branch=${value}`, + `--variable=${value}=${value}`, + '-d', + ]); + expect(mockShellPromise).not.toHaveBeenCalled(); +}); diff --git a/src/server/lib/codefresh/command.ts b/src/server/lib/codefresh/command.ts new file mode 100644 index 00000000..a456d4ee --- /dev/null +++ b/src/server/lib/codefresh/command.ts @@ -0,0 +1,7 @@ +export function codefreshRunArgs(pipeline: string, args: string[]): string[] { + // Codefresh 0.81.5 does not bind its pipeline positional after an option terminator. + if (pipeline.startsWith('-')) { + throw new Error('Codefresh pipeline must not start with a hyphen.'); + } + return ['run', pipeline, ...args, '-d']; +} diff --git a/src/server/lib/codefresh/index.ts b/src/server/lib/codefresh/index.ts index f89c591c..6aa82306 100644 --- a/src/server/lib/codefresh/index.ts +++ b/src/server/lib/codefresh/index.ts @@ -15,7 +15,8 @@ */ import { spawn } from 'child_process'; -import { shellPromise } from 'server/lib/shell'; +import { codefreshRunArgs } from './command'; +import { execFilePromise, shellPromise } from 'server/lib/shell'; import { getLogger } from 'server/lib/logger'; import { generateCodefreshCmd, constructEcrTag, getCodefreshPipelineIdFromOutput } from 'server/lib/codefresh/utils'; import { waitUntil } from 'server/lib/utils'; @@ -29,8 +30,15 @@ export const tagExists = async ({ tag, ecrRepo = 'lifecycle-deployments', uuid: const repoName = ecrRepo; const registryId = (lifecycleDefaults.ecrDomain?.split?.('.') || [])[0] || ''; try { - const command = `aws ecr describe-images --repository-name=${repoName} --image-ids=imageTag=${tag} --no-paginate --no-cli-auto-prompt --registry-id ${registryId}`; - await shellPromise(command); + await execFilePromise('aws', [ + 'ecr', + 'describe-images', + `--repository-name=${repoName}`, + `--image-ids=imageTag=${tag}`, + '--no-paginate', + '--no-cli-auto-prompt', + `--registry-id=${registryId}`, + ]); getLogger().info(`ECR: exists tag=${tag} repo=${repoName}`); return true; } catch (error) { @@ -49,7 +57,7 @@ export const buildImage = async (options: ContainerBuildOptions) => { }; try { const codefreshRunCommand = generateCodefreshCmd(options); - const output = await shellPromise(codefreshRunCommand); + const output = await execFilePromise('codefresh', codefreshRunCommand); const hasOutput = output?.length > 0; const hasYamlString = output?.includes('Yaml'); if (!hasOutput || !hasYamlString) { @@ -104,11 +112,11 @@ export const triggerPipeline = async ( ): Promise => { const branch = data?.branch || data?.BRANCH; if (!branch) throw Error(`[triggerPipeline][WEBHOOK ${pipelineId}/${trigger}] webhook error: no "branch" env var.`); - const variables = Object.keys(data) - .map((key) => ` -v '${key}'='${data[key]}' `) - .join(' '); - const command = `codefresh run "${pipelineId}" -d -b "${branch}" --trigger "${trigger}" ${variables}`; - const output = await shellPromise(command); + const variables = Object.keys(data).map((key) => `--variable=${key}=${data[key]}`); + const output = await execFilePromise( + 'codefresh', + codefreshRunArgs(pipelineId, [`--branch=${branch}`, `--trigger=${trigger}`, ...variables]) + ); const buildId = getCodefreshPipelineIdFromOutput(output); return buildId; }; diff --git a/src/server/lib/codefresh/utils/__tests__/generateCodefreshCmd.test.ts b/src/server/lib/codefresh/utils/__tests__/generateCodefreshCmd.test.ts index fc77bb67..78616f4c 100644 --- a/src/server/lib/codefresh/utils/__tests__/generateCodefreshCmd.test.ts +++ b/src/server/lib/codefresh/utils/__tests__/generateCodefreshCmd.test.ts @@ -21,16 +21,27 @@ import { waitForInProgressDeploys, } from 'server/lib/codefresh/utils/generateCodefreshCmd'; +jest.mock('fs', () => ({ mkdirSync: jest.fn(), writeFileSync: jest.fn() })); jest.mock('server/lib/codefresh/utils/generateYaml'); import * as yaml from 'server/lib/codefresh/utils/generateYaml'; describe('generateCodefreshCmd', () => { + it('rejects an option-like pipeline before writing a submission file', () => { + const { writeFileSync } = jest.requireMock('fs'); + writeFileSync.mockClear(); + jest.spyOn(yaml, 'generateYaml').mockReturnValue('yaml'); + expect(() => generateCodefreshCmd({ ...generateOptions, buildPipelineName: '--local' })).toThrow( + 'must not start with a hyphen' + ); + expect(writeFileSync).not.toHaveBeenCalled(); + }); + it('should generate command with master branch', () => { jest.spyOn(yaml, 'generateYaml').mockReturnValue('yaml'); const options = { ...generateOptions, branch: 'master' }; const result = generateCodefreshCmd(options); - expect(result).toContain('-b "master"'); + expect(result[2]).toEqual('--branch=master'); }); it('should generate command with unique branch', () => { @@ -42,10 +53,10 @@ describe('generateCodefreshCmd', () => { const result = generateCodefreshCmd(customBranchOptions); - expect(result).toContain('-b "unique-branch-name"'); + expect(result[2]).toEqual('--branch=unique-branch-name'); }); - it('should generate command with repo name', () => { + it('preserves the image-derived filename with path separators removed', () => { jest.spyOn(yaml, 'generateYaml').mockReturnValue('yaml'); const customImageOptions = { ...generateOptions, @@ -54,7 +65,7 @@ describe('generateCodefreshCmd', () => { const result = generateCodefreshCmd(customImageOptions); - expect(result).toContain('latest'); + expect(result.find((arg) => arg.startsWith('--yaml='))).toContain('latest'); }); it('includes the selected runtime and every build environment variable', () => { @@ -66,9 +77,9 @@ describe('generateCodefreshCmd', () => { envVars: { API_URL: 'https://api.example.test', EMPTY_VALUE: '' }, }); - expect(result).toContain('--runtime-name production-runtime'); - expect(result).toContain("-v 'API_URL'='https://api.example.test'"); - expect(result).toContain("-v 'EMPTY_VALUE'=''"); + expect(result).toContain('--runtime-name=production-runtime'); + expect(result).toContain('--variable=API_URL=https://api.example.test'); + expect(result).toContain('--variable=EMPTY_VALUE='); }); }); @@ -85,7 +96,7 @@ describe('Codefresh deployment coordination steps', () => { image: 'alpine/helm:3.7.2', fail_fast: false, commands: [ - 'helm list -n candidate-123 -m 1000 --pending -q | grep deploy-123 | xargs --no-run-if-empty helm uninstall --wait -n candidate-123', + "helm list --namespace='candidate-123' -m 1000 --pending -q | grep -- 'deploy-123' | xargs --no-run-if-empty helm uninstall --wait --namespace='candidate-123' --", ], }); }); @@ -101,7 +112,73 @@ describe('Codefresh deployment coordination steps', () => { }); expect(step.commands).toHaveLength(1); expect(step.commands[0]).toContain('pipeline=pipeline-456&status=running'); - expect(step.commands[0]).toContain('.value=="deploy-123"'); - expect(step.commands[0]).toContain('if [ "{}" != "$CF_BUILD_ID" ]'); + expect(step.commands[0]).toContain("--arg DEPLOY_UUID 'deploy-123'"); + expect(step.commands[0]).toContain('.value==$DEPLOY_UUID'); + expect(step.commands[0]).toContain('if [ "$1" != "$CF_BUILD_ID" ]'); + }); +}); + +describe('generated Codefresh commands preserve data boundaries', () => { + it('passes all CLI fields literally while preserving the existing env string coercion', () => { + jest.spyOn(yaml, 'generateYaml').mockReturnValue('yaml'); + const value = "quote'$(id);`uname`\nnext"; + const args = generateCodefreshCmd({ + ...generateOptions, + buildPipelineName: value, + branch: value, + runtimeName: value, + envVars: { [value]: value, OBJECT: { a: 1 }, NULL: null }, + } as any); + expect(args.slice(0, 4)).toEqual(['run', value, `--branch=${value}`, `--runtime-name=${value}`]); + expect(args).toContain(`--variable=${value}=${value}`); + expect(args).toContain('--variable=OBJECT=[object Object]'); + expect(args).toContain('--variable=NULL=null'); + }); + + it('binds jq data separately and never inserts returned pipeline IDs into a second shell program', () => { + const { execFileSync } = jest.requireActual('child_process'); + const { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } = jest.requireActual('fs'); + const { join } = jest.requireActual('path'); + const { tmpdir } = jest.requireActual('os'); + const directory = mkdtempSync(join(tmpdir(), 'lfc-codefresh-')); + try { + const log = join(directory, 'args.jsonl'); + const marker = join(directory, 'unexpected'); + const value = `quote' ; touch ${marker}; #`; + const returnedId = `$(touch\${IFS}${marker});id`; + for (const tool of ['curl', 'jq', 'cf_export', 'codefresh']) { + writeFileSync( + join(directory, tool), + `#!${process.execPath}\nrequire('fs').appendFileSync(process.env.ARGV_LOG,JSON.stringify([${JSON.stringify( + tool + )},...process.argv.slice(2)])+'\\n');if(${JSON.stringify( + tool + )}==='jq')process.stdout.write(process.env.PIPELINE_ID+'\\n');`, + { mode: 0o755 } + ); + } + const script = waitForInProgressDeploys({ deployUUID: value, pipelineId: value }).commands[0]; + execFileSync('/bin/sh', ['-c', script], { + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + ARGV_LOG: log, + PIPELINE_ID: returnedId, + CF_BUILD_ID: 'current', + CF_BUILD_TIMESTAMP: '42', + CODEFRESH_API_KEY: 'synthetic', + }, + }); + const calls = readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse); + expect(calls.find((args) => args[0] === 'curl')[3]).toBe( + `https://g.codefresh.io/api/workflow?pipeline=${encodeURIComponent(value)}&status=running` + ); + const jq = calls.find((args) => args[0] === 'jq'); + expect(jq.slice(5, 8)).toEqual(['--arg', 'DEPLOY_UUID', value]); + expect(calls.find((args) => args[0] === 'codefresh')).toEqual(['codefresh', 'wait', '-v', returnedId]); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } }); }); diff --git a/src/server/lib/codefresh/utils/generateCodefreshCmd.ts b/src/server/lib/codefresh/utils/generateCodefreshCmd.ts index d163cce8..42bc911d 100644 --- a/src/server/lib/codefresh/utils/generateCodefreshCmd.ts +++ b/src/server/lib/codefresh/utils/generateCodefreshCmd.ts @@ -20,26 +20,28 @@ import { generateYaml } from 'server/lib/codefresh/utils/generateYaml'; import { constructEcrTag } from 'server/lib/codefresh/utils'; import { ContainerBuildOptions } from 'server/lib/codefresh/types'; import { Deploy } from 'server/models'; +import { shellQuote } from 'server/lib/shellQuote'; +import { codefreshRunArgs } from '../command'; export const generateCodefreshCmd = (options: ContainerBuildOptions) => { const yaml = generateYaml(options); const { ecrRepo, envVars, tag, branch, runtimeName, buildPipelineName, ecrDomain } = options; - const ecrRepoTag = constructEcrTag({ repo: ecrRepo, tag, ecrDomain }); - const configFilename = ecrRepoTag.split('/').join(''); + const configFilename = constructEcrTag({ repo: ecrRepo, tag, ecrDomain }).split('/').join(''); const configPath = `${CODEFRESH_PATH}/${configFilename}.yaml`; - const runtimeArgument = runtimeName ? `--runtime-name ${runtimeName}` : ''; - const pipelineNameArgument = buildPipelineName; + const runtimeArgument = runtimeName ? [`--runtime-name=${runtimeName}`] : []; + + const variables = Object.keys(envVars).map((key) => `--variable=${key}=${envVars[key]}`); + const command = codefreshRunArgs(String(buildPipelineName), [ + `--branch=${branch}`, + ...runtimeArgument, + ...variables, + `--yaml=${configPath}`, + ]); mkdirSync(CODEFRESH_PATH, { recursive: true }); writeFileSync(configPath, yaml, 'utf8'); - const variables = Object.keys(envVars) - .map((key) => ` -v '${key}'='${envVars[key]}' `) - .join(' '); - - const command = `codefresh run "${pipelineNameArgument}" -b "${branch}" ${runtimeArgument} ${variables} -y ${configPath} -d`; - return command; }; @@ -50,7 +52,9 @@ export const deletePendingHelmReleaseStep = ({ deploy, namespace }: { deploy: De image: 'alpine/helm:3.7.2', fail_fast: false, commands: [ - `helm list -n ${namespace} -m 1000 --pending -q | grep ${deploy.uuid} | xargs --no-run-if-empty helm uninstall --wait -n ${namespace}`, + `helm list --namespace=${shellQuote(namespace)} -m 1000 --pending -q | grep -- ${shellQuote( + deploy.uuid + )} | xargs --no-run-if-empty helm uninstall --wait --namespace=${shellQuote(namespace)} --`, ], }; }; @@ -67,7 +71,11 @@ export const waitForInProgressDeploys = ({ deployUUID, pipelineId }: { deployUUI image: 'codefresh/cli:0.87.4', fail_fast: false, commands: [ - `curl -X GET 'https://g.codefresh.io/api/workflow?pipeline=${pipelineId}&status=running' -H "Authorization: $CODEFRESH_API_KEY" | jq -r --arg CF_BUILD_TIMESTAMP "$CF_BUILD_TIMESTAMP" '.workflows.docs[] | select(.annotations[] | select(.key=="deployUUID" and .value=="${deployUUID}")) | select(.exposedVariables.pipeline[] | select(.key=="CF_BUILD_TIMESTAMP" and (.value | tonumber) < ($CF_BUILD_TIMESTAMP | tonumber))) | .id' | xargs -I {} bash -c 'if [ "{}" != "$CF_BUILD_ID" ]; then cf_export terminate_CF_OUTPUT_URL="https://g.codefresh.io/build/{}"; codefresh wait -v {}; fi'`, + `curl -X GET ${shellQuote( + `https://g.codefresh.io/api/workflow?pipeline=${encodeURIComponent(pipelineId)}&status=running` + )} -H "Authorization: $CODEFRESH_API_KEY" | jq -r --arg CF_BUILD_TIMESTAMP "$CF_BUILD_TIMESTAMP" --arg DEPLOY_UUID ${shellQuote( + deployUUID + )} '.workflows.docs[] | select(.annotations[] | select(.key=="deployUUID" and .value==$DEPLOY_UUID)) | select(.exposedVariables.pipeline[] | select(.key=="CF_BUILD_TIMESTAMP" and (.value | tonumber) < ($CF_BUILD_TIMESTAMP | tonumber))) | .id' | xargs -I {} bash -c 'if [ "$1" != "$CF_BUILD_ID" ]; then cf_export "terminate_CF_OUTPUT_URL=https://g.codefresh.io/build/$1"; case "$1" in -*) exit 1;; esac; codefresh wait -v "$1"; fi' _ '{}'`, ], }; }; diff --git a/src/server/lib/helm/__tests__/helm.behavior.test.ts b/src/server/lib/helm/__tests__/helm.behavior.test.ts index 0da22ef9..ac2de6c4 100644 --- a/src/server/lib/helm/__tests__/helm.behavior.test.ts +++ b/src/server/lib/helm/__tests__/helm.behavior.test.ts @@ -19,7 +19,7 @@ import mockRedisClient from 'server/lib/__mocks__/redisClientMock'; mockRedisClient(); const mockDeployQuery = jest.fn(); -const mockShellPromise = jest.fn(); +const mockExecFilePromise = jest.fn(); const mockKubeContextStep = jest.fn(); const mockGenerateCheckoutStep = jest.fn(); const mockDeletePendingHelmReleaseStep = jest.fn(); @@ -56,7 +56,7 @@ jest.mock('server/models/Deploy', () => ({ }, })); jest.mock('server/lib/shell', () => ({ - shellPromise: (...args: unknown[]) => mockShellPromise(...args), + execFilePromise: (...args: unknown[]) => mockExecFilePromise(...args), })); jest.mock('server/lib/codefresh', () => ({ kubeContextStep: (...args: unknown[]) => mockKubeContextStep(...args), @@ -610,7 +610,7 @@ describe('Codefresh Helm YAML generation', () => { expect(result.steps.clone.steps.Checkout.git).toBe('REPLACE_ME_ORG'); }); - test('writes checkout YAML before returning the quoted Codefresh command', async () => { + test('writes checkout YAML before returning literal Codefresh arguments', async () => { const deploy = makePublicDeploy(); deploy.deployable.helm.chart.valueFiles = ['helm/preview.yaml']; @@ -618,12 +618,29 @@ describe('Codefresh Helm YAML generation', () => { expect(deploy.$fetchGraph).toHaveBeenCalledWith('build'); expect(mockMkdir).toHaveBeenCalledWith('/tmp/lifecycle/codefresh', { recursive: true }); - expect(configPath).toMatch(/^\/tmp\/lifecycle\/codefresh\/helm-deploy-Preview-ABC-[\w-]{10}\.yaml$/); + expect(configPath).toMatch(/^\/tmp\/lifecycle\/codefresh\/helm-deploy-[\w-]{10}\.yaml$/); expect(mockWriteFile).toHaveBeenCalledWith(configPath, expect.any(String), 'utf8'); const writtenYaml = yaml.load(mockWriteFile.mock.calls[0][1] as string) as any; expect(writtenYaml.steps.clone.type).toBe('parallel'); expect(writtenYaml.steps.deploy.arguments.custom_value_files).toEqual(['helm/preview.yaml']); - expect(command).toBe(`codefresh run goodrx/helm-deploy -b "feature/helm-preview" -y ${configPath} -v ENV=lfc -d`); + expect(command).toEqual([ + 'run', + 'goodrx/helm-deploy', + '--branch=feature/helm-preview', + `--yaml=${configPath}`, + '--variable=ENV=lfc', + '-d', + ]); + }); + + test('keeps a Git-valid branch literal and deploy identity out of the submission path', async () => { + const deploy = makePublicDeploy(); + deploy.branchName = "feature/o'hare#$(id)"; + deploy.uuid = '../../outside'; + const { command, configPath } = await generateCodefreshRunCommand(deploy); + expect(command[2]).toEqual(`--branch=${deploy.branchName}`); + expect(configPath).toMatch(/^\/tmp\/lifecycle\/codefresh\/helm-deploy-[\w-]{10}\.yaml$/); + expect(configPath).not.toContain('outside'); }); test('writes a separate file for every submission of the same service', async () => { @@ -636,7 +653,15 @@ describe('Codefresh Helm YAML generation', () => { expect(mockWriteFile).toHaveBeenCalledTimes(2); }); - test('logs and preserves a YAML write failure without constructing a command', async () => { + test('rejects an option-like pipeline before writing a submission file', async () => { + const configs = makeConfigs(); + configs.lifecycleDefaults.helmDeployPipeline = '--local'; + installConfigs(configs); + await expect(generateCodefreshRunCommand(makePublicDeploy())).rejects.toThrow('must not start with a hyphen'); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + test('logs and preserves a YAML write failure', async () => { const failure = new Error('temporary volume is read-only'); mockWriteFile.mockRejectedValueOnce(failure); const deploy = makePublicDeploy(); @@ -692,21 +717,31 @@ describe('image metadata and teardown', () => { }; const withGraphFetched = jest.fn().mockResolvedValue([activeDeploy, inactiveDeploy, queuedDeploy, nonHelmDeploy]); mockDeployQuery.mockReturnValue({ where: jest.fn(() => ({ withGraphFetched })) }); - mockShellPromise.mockResolvedValue('release "active-release" uninstalled'); + mockExecFilePromise.mockResolvedValue('release "active-release" uninstalled'); await uninstallHelmReleases({ id: 42, namespace: 'env-build-123' } as any); - expect(mockShellPromise).toHaveBeenCalledTimes(2); - expect(mockShellPromise).toHaveBeenCalledWith('helm uninstall active-release --namespace env-build-123'); - expect(mockShellPromise).toHaveBeenCalledWith('helm uninstall queued-release --namespace env-build-123'); + expect(mockExecFilePromise).toHaveBeenCalledTimes(2); + expect(mockExecFilePromise).toHaveBeenCalledWith('helm', [ + 'uninstall', + '--namespace=env-build-123', + '--', + 'active-release', + ]); + expect(mockExecFilePromise).toHaveBeenCalledWith('helm', [ + 'uninstall', + '--namespace=env-build-123', + '--', + 'queued-release', + ]); expect(successfulPatch).toHaveBeenCalledWith({ statusMessage: 'Uninstalled via Helm' }); expect(queuedPatch).toHaveBeenCalledWith({ statusMessage: 'Uninstalled via Helm' }); expect(skippedPatch).not.toHaveBeenCalled(); expect(mockLoggerDebug).toHaveBeenCalledWith('Helm: releases uninstalled'); }); - test('preserves shellPromise string failures in the status and rejection contract', async () => { - const shellFailure = 'shellPromise command failed: cluster credentials expired'; + test('preserves execFilePromise string failures in the status and rejection contract', async () => { + const shellFailure = 'execFilePromise command failed: cluster credentials expired'; const patch = jest.fn().mockResolvedValue(undefined); const deploy = { uuid: 'active-release', @@ -717,7 +752,7 @@ describe('image metadata and teardown', () => { }; const withGraphFetched = jest.fn().mockResolvedValue([deploy]); mockDeployQuery.mockReturnValue({ where: jest.fn(() => ({ withGraphFetched })) }); - mockShellPromise.mockRejectedValueOnce(shellFailure); + mockExecFilePromise.mockRejectedValueOnce(shellFailure); await expect(uninstallHelmReleases({ id: 42, namespace: 'env-build-123' } as any)).rejects.toBe(shellFailure); diff --git a/src/server/lib/helm/__tests__/helm.test.ts b/src/server/lib/helm/__tests__/helm.test.ts index 6c416d37..826e19a5 100644 --- a/src/server/lib/helm/__tests__/helm.test.ts +++ b/src/server/lib/helm/__tests__/helm.test.ts @@ -27,7 +27,7 @@ import type { Deploy } from 'server/models'; import GlobalConfigService from 'server/services/globalConfig'; const mockDeployQuery = jest.fn(); -const mockShellPromise = jest.fn(); +const mockExecFilePromise = jest.fn(); jest.mock('server/lib/envVariables', () => ({ EnvironmentVariables: class {}, @@ -41,7 +41,7 @@ jest.mock('server/models/Deploy', () => ({ }, })); jest.mock('server/lib/shell', () => ({ - shellPromise: (...args: unknown[]) => mockShellPromise(...args), + execFilePromise: (...args: unknown[]) => mockExecFilePromise(...args), })); jest.mock('server/lib/helm/utils', () => { const originalModule = jest.requireActual('server/lib/helm/utils'); @@ -74,7 +74,7 @@ describe('Helm tests', () => { }; const withGraphFetched = jest.fn().mockResolvedValue([deploy]); mockDeployQuery.mockReturnValue({ where: jest.fn(() => ({ withGraphFetched })) }); - mockShellPromise.mockRejectedValueOnce(new Error('uninstall: Release not loaded: release: not found')); + mockExecFilePromise.mockRejectedValueOnce(new Error('uninstall: Release not loaded: release: not found')); await expect(uninstallHelmReleases({ id: 42, namespace: 'env-build-uuid' } as any)).resolves.toBeUndefined(); expect(patch).toHaveBeenCalledWith({ statusMessage: 'Helm release not found, skipping uninstall.' }); @@ -92,7 +92,7 @@ describe('Helm tests', () => { }; const withGraphFetched = jest.fn().mockResolvedValue([deploy]); mockDeployQuery.mockReturnValue({ where: jest.fn(() => ({ withGraphFetched })) }); - mockShellPromise.mockRejectedValueOnce(failure); + mockExecFilePromise.mockRejectedValueOnce(failure); await expect(uninstallHelmReleases({ id: 42, namespace: 'env-build-uuid' } as any)).rejects.toBe(failure); expect(patch).toHaveBeenCalledWith({ statusMessage: 'Failed to uninstall via Helm\ncluster unavailable' }); diff --git a/src/server/lib/helm/helm.ts b/src/server/lib/helm/helm.ts index 788a65c3..464ce7ae 100644 --- a/src/server/lib/helm/helm.ts +++ b/src/server/lib/helm/helm.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { codefreshRunArgs } from 'server/lib/codefresh/command'; import yaml from 'js-yaml'; import fs from 'fs'; import { nanoid } from 'nanoid'; @@ -22,7 +23,7 @@ import GlobalConfigService from 'server/services/globalConfig'; import { TMP_PATH } from 'shared/config'; import { getLogger } from 'server/lib/logger'; import { getBuildSource, resolveBuildSourceRepository } from 'server/lib/buildSource'; -import { shellPromise } from 'server/lib/shell'; +import { execFilePromise } from 'server/lib/shell'; import { kubeContextStep } from 'server/lib/codefresh'; import Build from 'server/models/Build'; import { staticEnvTolerations } from './constants'; @@ -270,7 +271,7 @@ export async function deployHelm( * @param {Deploy} deploy - The deploy object containing deployment details. * @returns {Promise} A promise that resolves to the generated Codefresh command. */ -export async function generateCodefreshRunCommand(deploy: Deploy): Promise<{ command: string; configPath: string }> { +export async function generateCodefreshRunCommand(deploy: Deploy): Promise<{ command: string[]; configPath: string }> { const hasValueFiles = deploy?.deployable?.helm?.chart?.valueFiles?.length > 0; await deploy.$fetchGraph('build'); const yamlContent = hasValueFiles @@ -279,7 +280,14 @@ export async function generateCodefreshRunCommand(deploy: Deploy): Promise<{ com const generatedYaml = yaml.dump(yamlContent); // One file per submission: a superseded run preparing the same service must not overwrite a newer run's YAML. - const configPath = `${CODEFRESH_PATH}/helm-deploy-${deploy.uuid}-${nanoid(10)}.yaml`; + const configPath = `${CODEFRESH_PATH}/helm-deploy-${nanoid(10)}.yaml`; + + const { lifecycleDefaults } = await GlobalConfigService.getInstance().getAllConfigs(); + const command = codefreshRunArgs(String(lifecycleDefaults.helmDeployPipeline), [ + `--branch=${deploy.branchName}`, + `--yaml=${configPath}`, + '--variable=ENV=lfc', + ]); try { await fs.promises.mkdir(CODEFRESH_PATH, { recursive: true }); @@ -288,8 +296,6 @@ export async function generateCodefreshRunCommand(deploy: Deploy): Promise<{ com getLogger({ error }).error(`Codefresh: config write failed`); throw error; } - const { lifecycleDefaults } = await GlobalConfigService.getInstance().getAllConfigs(); - const command = `codefresh run ${lifecycleDefaults.helmDeployPipeline} -b "${deploy.branchName}" -y ${configPath} -v ENV=lfc -d`; return { command, configPath }; } @@ -426,7 +432,7 @@ export async function uninstallHelmReleases(build: Build) { if (!deploy.active) continue; try { - await shellPromise(`helm uninstall ${deploy.uuid} --namespace ${build.namespace}`); + await execFilePromise('helm', ['uninstall', `--namespace=${build.namespace}`, '--', deploy.uuid]); await deploy.$query().patch({ statusMessage: 'Uninstalled via Helm' }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/server/lib/kubernetes/__tests__/externalSecret.test.ts b/src/server/lib/kubernetes/__tests__/externalSecret.test.ts index 97bc0f8c..fcb48400 100644 --- a/src/server/lib/kubernetes/__tests__/externalSecret.test.ts +++ b/src/server/lib/kubernetes/__tests__/externalSecret.test.ts @@ -16,7 +16,7 @@ const mockMkdir = jest.fn(); const mockWriteFile = jest.fn(); -const mockShellPromise = jest.fn(); +const mockExecFilePromise = jest.fn(); const mockLoggerInfo = jest.fn(); const mockLoggerWarn = jest.fn(); @@ -31,7 +31,7 @@ jest.mock('fs', () => ({ })); jest.mock('server/lib/shell', () => ({ - shellPromise: (...args: unknown[]) => mockShellPromise(...args), + execFilePromise: (...args: unknown[]) => mockExecFilePromise(...args), })); jest.mock('server/lib/logger', () => ({ @@ -54,7 +54,7 @@ describe('externalSecret', () => { jest.clearAllMocks(); mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined); - mockShellPromise.mockResolvedValue({ stdout: '', stderr: '' }); + mockExecFilePromise.mockResolvedValue({ stdout: '', stderr: '' }); }); describe('generateSecretName', () => { @@ -248,12 +248,13 @@ describe('externalSecret', () => { expect(mockMkdir).toHaveBeenCalledWith('/tmp/lifecycle/manifests/externalsecrets', { recursive: true }); expect(mockWriteFile).toHaveBeenCalledWith( - '/tmp/lifecycle/manifests/externalsecrets/api-server-aws-secrets.yaml', + expect.stringMatching(/^\/tmp\/lifecycle\/manifests\/externalsecrets\/[a-f0-9-]+\.yaml$/), expect.stringContaining('kind: ExternalSecret'), 'utf8' ); - expect(mockShellPromise).toHaveBeenCalledWith( - 'kubectl apply -f /tmp/lifecycle/manifests/externalsecrets/api-server-aws-secrets.yaml --namespace environment', + expect(mockExecFilePromise).toHaveBeenCalledWith( + 'kubectl', + ['apply', '-f', mockWriteFile.mock.calls[0][0], '--namespace=environment'], { timeout: 90_000 } ); expect(mockLoggerInfo).toHaveBeenCalledWith( @@ -261,19 +262,35 @@ describe('externalSecret', () => { ); }); + it('keeps resource names in YAML and confines stable manifest paths to the intended directory', async () => { + const name = "../../escape'$(id)"; + const hostile = { ...manifest, metadata: { ...manifest.metadata, name } }; + await applyExternalSecret(hostile, "namespace'$(id)"); + const first = mockWriteFile.mock.calls[0][0]; + await applyExternalSecret(hostile, "namespace'$(id)"); + expect(first).toMatch(/^\/tmp\/lifecycle\/manifests\/externalsecrets\/[a-f0-9]{64}\.yaml$/); + expect(mockWriteFile.mock.calls[1][0]).toBe(first); + expect(mockWriteFile.mock.calls[0][1]).toContain(name); + expect(mockExecFilePromise).toHaveBeenCalledWith( + 'kubectl', + ['apply', '-f', first, "--namespace=namespace'$(id)"], + { timeout: 90_000 } + ); + }); + it('does not invoke kubectl when writing the manifest fails', async () => { const error = new Error('filesystem unavailable'); mockWriteFile.mockRejectedValue(error); await expect(applyExternalSecret(manifest, 'environment')).rejects.toBe(error); - expect(mockShellPromise).not.toHaveBeenCalled(); + expect(mockExecFilePromise).not.toHaveBeenCalled(); expect(mockLoggerInfo).not.toHaveBeenCalled(); }); it('propagates kubectl apply failures after recording the attempted resource', async () => { const error = new Error('apply rejected'); - mockShellPromise.mockRejectedValue(error); + mockExecFilePromise.mockRejectedValue(error); await expect(applyExternalSecret(manifest, 'environment')).rejects.toBe(error); @@ -290,8 +307,9 @@ describe('externalSecret', () => { expect(mockLoggerInfo).toHaveBeenCalledWith( 'ExternalSecret: deleting name=api-server-aws-secrets namespace=environment' ); - expect(mockShellPromise).toHaveBeenCalledWith( - 'kubectl delete externalsecret api-server-aws-secrets --namespace environment --ignore-not-found', + expect(mockExecFilePromise).toHaveBeenCalledWith( + 'kubectl', + ['delete', 'externalsecret', '--namespace=environment', '--ignore-not-found', '--', 'api-server-aws-secrets'], { timeout: 90_000 } ); expect(mockLoggerWarn).not.toHaveBeenCalled(); @@ -299,7 +317,7 @@ describe('externalSecret', () => { it('logs and absorbs deletion failures so cleanup can continue', async () => { const error = new Error('delete rejected'); - mockShellPromise.mockRejectedValue(error); + mockExecFilePromise.mockRejectedValue(error); await expect(deleteExternalSecret('api-server-aws-secrets', 'environment')).resolves.toBeUndefined(); diff --git a/src/server/lib/kubernetes/externalSecret.ts b/src/server/lib/kubernetes/externalSecret.ts index 5089b839..e2805554 100644 --- a/src/server/lib/kubernetes/externalSecret.ts +++ b/src/server/lib/kubernetes/externalSecret.ts @@ -16,7 +16,8 @@ import yaml from 'js-yaml'; import fs from 'fs'; -import { shellPromise } from 'server/lib/shell'; +import { createHash } from 'crypto'; +import { execFilePromise } from 'server/lib/shell'; import { getLogger } from 'server/lib/logger'; import { SecretRefWithEnvKey } from 'server/lib/secretRefs'; import { SecretProviderConfig } from 'server/services/types/globalConfig'; @@ -160,7 +161,9 @@ const MANIFEST_PATH = '/tmp/lifecycle/manifests/externalsecrets'; export async function applyExternalSecret(manifest: ExternalSecretManifest, namespace: string): Promise { const manifestYaml = yaml.dump(manifest); - const fileName = `${manifest.metadata.name}.yaml`; + const fileName = `${createHash('sha256') + .update(JSON.stringify([namespace, manifest.metadata.name])) + .digest('hex')}.yaml`; const localPath = `${MANIFEST_PATH}/${fileName}`; await fs.promises.mkdir(MANIFEST_PATH, { recursive: true }); @@ -168,7 +171,7 @@ export async function applyExternalSecret(manifest: ExternalSecretManifest, name getLogger().info(`ExternalSecret: applying name=${manifest.metadata.name} namespace=${namespace}`); - await shellPromise(`kubectl apply -f ${localPath} --namespace ${namespace}`, { + await execFilePromise('kubectl', ['apply', '-f', localPath, `--namespace=${namespace}`], { timeout: 90_000, }); } @@ -177,9 +180,13 @@ export async function deleteExternalSecret(name: string, namespace: string): Pro getLogger().info(`ExternalSecret: deleting name=${name} namespace=${namespace}`); try { - await shellPromise(`kubectl delete externalsecret ${name} --namespace ${namespace} --ignore-not-found`, { - timeout: 90_000, - }); + await execFilePromise( + 'kubectl', + ['delete', 'externalsecret', `--namespace=${namespace}`, '--ignore-not-found', '--', name], + { + timeout: 90_000, + } + ); } catch (error) { getLogger().warn({ error }, `ExternalSecret: delete failed name=${name}`); } diff --git a/src/server/lib/nativeBuild/__tests__/buildkit.test.ts b/src/server/lib/nativeBuild/__tests__/buildkit.test.ts index b75f1dc4..51735732 100644 --- a/src/server/lib/nativeBuild/__tests__/buildkit.test.ts +++ b/src/server/lib/nativeBuild/__tests__/buildkit.test.ts @@ -408,7 +408,7 @@ describe('buildkitBuild', () => { expect(result.success).toBe(true); expect(staticDeploy.$fetchGraph).toHaveBeenCalledWith('build'); - expect(fullCommand).toContain('git init /workspace/repo-repo-only'); + expect(fullCommand).toContain("git init -- '/workspace/repo-repo-only'"); expect(fullCommand).toContain('filename=Dockerfile'); expect(fullCommand).toContain('lfc/dockerfile: "Dockerfile"'); expect(fullCommand).toContain('ttlSecondsAfterFinished: 86400'); diff --git a/src/server/lib/nativeBuild/__tests__/utils.test.ts b/src/server/lib/nativeBuild/__tests__/utils.test.ts index c8044a2a..7b24f7c8 100644 --- a/src/server/lib/nativeBuild/__tests__/utils.test.ts +++ b/src/server/lib/nativeBuild/__tests__/utils.test.ts @@ -100,10 +100,10 @@ describe('nativeBuild/utils', () => { const branchOnly = createCloneScript('GoodRxOSS/lifecycle', 'main'); const exactSha = createCloneScript('GoodRxOSS/lifecycle', 'feature/test', 'abcdef123'); - expect(branchOnly).toContain('git clone --depth 1 --single-branch --progress -b main'); - expect(branchOnly).not.toContain('git fetch --depth 1 --progress origin'); - expect(exactSha).toContain('git fetch --depth 1 --progress origin abcdef123'); - expect(exactSha).toContain('git checkout abcdef123'); + expect(branchOnly).toContain("git clone --depth 1 --single-branch --progress -b 'main' --"); + expect(branchOnly).not.toContain('git fetch --depth 1 --progress -- origin'); + expect(exactSha).toContain("git fetch --depth 1 --progress -- origin 'abcdef123'"); + expect(exactSha).toContain("git checkout --end-of-options 'abcdef123'"); }); describe('createGitCloneContainer', () => { @@ -113,7 +113,7 @@ describe('nativeBuild/utils', () => { expect(container.name).toBe('git-clone'); expect(container.image).toBe('alpine/git:latest'); expect(container.command).toEqual(['sh', '-c']); - expect(container.args[0]).toContain('git fetch --depth 1 --progress origin abc123def456'); + expect(container.args[0]).toContain("git fetch --depth 1 --progress -- origin 'abc123def456'"); expect(container.args[0]).toContain('owner/repo'); expect(container.args[0]).toContain('git checkout FETCH_HEAD'); @@ -134,10 +134,10 @@ describe('nativeBuild/utils', () => { 'github-token-123' ); - expect(container.args[0]).toContain('safe.directory /workspace/lifecycle-ui'); - expect(container.args[0]).toContain('git init /workspace/lifecycle-ui'); + expect(container.args[0]).toContain("safe.directory '/workspace/lifecycle-ui'"); + expect(container.args[0]).toContain("git init -- '/workspace/lifecycle-ui'"); expect(container.args[0]).toContain('GoodRxOSS/lifecycle-ui.git'); - expect(container.args[0]).toContain('git fetch --depth 1 --progress origin feature/test'); + expect(container.args[0]).toContain("git fetch --depth 1 --progress -- origin 'feature/test'"); expect(container.env).toEqual([ { name: 'GIT_USERNAME', value: 'x-access-token' }, { name: 'GIT_PASSWORD', value: 'github-token-123' }, @@ -270,3 +270,83 @@ describe('nativeBuild/utils', () => { }); }); }); + +describe('clone script shell boundary', () => { + const { execFileSync, spawnSync } = require('child_process'); + const { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } = require('fs'); + const { tmpdir } = require('os'); + const { join } = require('path'); + let directory: string; + let log: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'lfc-clone-')); + log = join(directory, 'args.jsonl'); + writeFileSync( + join(directory, 'git'), + `#!${process.execPath}\nconst fs=require('fs'); const args=process.argv.slice(2); fs.appendFileSync(process.env.ARGV_LOG, JSON.stringify(args)+'\\n'); if(args[0]===process.env.FAIL_GIT_COMMAND) process.exit(7);`, + { mode: 0o755 } + ); + env = { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + ARGV_LOG: log, + GIT_USERNAME: 'synthetic-user', + GIT_PASSWORD: "fake'$(id)", + }; + }); + afterEach(() => rmSync(directory, { recursive: true, force: true })); + const run = (script: string) => { + execFileSync('/bin/sh', ['-c', `cd() { :; }; ${script}`], { env }); + return readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse); + }; + + it.each(['feature/$(id)', "release/o'hare#雪", 'topic/`uname`;test'])( + 'preserves Git-valid ref %s without expansion', + (branch) => { + execFileSync('git', ['check-ref-format', '--branch', branch]); + const calls = run(createCloneScript('org/repo', branch, 'abc123')); + expect(calls).toEqual([ + [ + 'clone', + '--depth', + '1', + '--single-branch', + '--progress', + '-b', + branch, + '--', + "https://synthetic-user:fake'$(id)@github.com/org/repo.git", + '/workspace', + ], + ['fetch', '--depth', '1', '--progress', '--', 'origin', 'abc123'], + ['checkout', '--end-of-options', 'abc123'], + ]); + } + ); + + it('keeps repository, revision and target directory out of the shell grammar', () => { + const marker = join(directory, 'unexpected'); + const value = `quote' ; touch ${marker}; #`; + const container = createRepoSpecificGitCloneContainer(value, '--upload-pack=unexpected', value, 'u', 't'); + const calls = run(container.args[0]); + expect(calls[0]).toEqual(['config', '--global', '--add', 'safe.directory', value]); + expect(calls[1]).toEqual(['init', '--', value]); + expect(calls[2]).toEqual(['remote', 'add', 'origin', `https://synthetic-user:fake'$(id)@github.com/${value}.git`]); + expect(calls[3]).toEqual(['fetch', '--depth', '1', '--progress', '--', 'origin', '--upload-pack=unexpected']); + expect(existsSync(marker)).toBe(false); + }); + + it('quotes the main build clone container revision and stops after a failed fetch', () => { + const revision = "ref'$(id)"; + const container = createGitCloneContainer('org/repo', revision, 'u', 't'); + const result = spawnSync('/bin/sh', ['-c', `cd() { :; }; ${container.args[0]}`], { + env: { ...env, FAIL_GIT_COMMAND: 'fetch' }, + }); + expect(result.status).toBe(7); + const calls = readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse); + expect(calls[calls.length - 1]).toEqual(['fetch', '--depth', '1', '--progress', '--', 'origin', revision]); + expect(calls.some((args) => args[0] === 'checkout')).toBe(false); + }); +}); diff --git a/src/server/lib/nativeBuild/utils.ts b/src/server/lib/nativeBuild/utils.ts index 94edcb5a..f81c9a8a 100644 --- a/src/server/lib/nativeBuild/utils.ts +++ b/src/server/lib/nativeBuild/utils.ts @@ -18,6 +18,7 @@ import { V1Job } from '@kubernetes/client-node'; import GlobalConfigService from '../../services/globalConfig'; import { createBuildJob } from '../kubernetes/jobFactory'; import { JobMonitor } from '../kubernetes/JobMonitor'; +import { shellQuote } from '../shellQuote'; export function createJob( name: string, @@ -163,9 +164,13 @@ export const GIT_USERNAME = 'x-access-token'; export const MANIFEST_PATH = '/tmp/manifests'; export function createCloneScript(repo: string, branch: string, sha?: string): string { - const cloneCmd = `git clone --depth 1 --single-branch --progress -b ${branch} https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/${repo}.git /workspace`; + const cloneCmd = `git clone --depth 1 --single-branch --progress -b ${shellQuote( + branch + )} -- "https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/"${shellQuote(`${repo}.git`)} /workspace`; const checkoutCmd = sha - ? ` && cd /workspace && git fetch --depth 1 --progress origin ${sha} && git checkout ${sha}` + ? ` && cd /workspace && git fetch --depth 1 --progress -- origin ${shellQuote( + sha + )} && git checkout --end-of-options ${shellQuote(sha)}` : ''; return `${cloneCmd}${checkoutCmd}`; } @@ -178,8 +183,8 @@ export function createGitCloneContainer(repo: string, revision: string, gitUsern args: [ `git config --global --add safe.directory /workspace && \ git init /workspace && cd /workspace && \ - git remote add origin https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/${repo}.git && \ - git fetch --depth 1 --progress origin ${revision} && \ + git remote add origin "https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/"${shellQuote(`${repo}.git`)} && \ + git fetch --depth 1 --progress -- origin ${shellQuote(revision)} && \ git checkout FETCH_HEAD`, ], env: [ @@ -213,10 +218,10 @@ export function createRepoSpecificGitCloneContainer( image: 'alpine/git:latest', command: ['sh', '-c'], args: [ - `git config --global --add safe.directory ${targetDir} && \ - git init ${targetDir} && cd ${targetDir} && \ - git remote add origin https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/${repo}.git && \ - git fetch --depth 1 --progress origin ${revision} && \ + `git config --global --add safe.directory ${shellQuote(targetDir)} && \ + git init -- ${shellQuote(targetDir)} && cd ${shellQuote(targetDir)} && \ + git remote add origin "https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/"${shellQuote(`${repo}.git`)} && \ + git fetch --depth 1 --progress -- origin ${shellQuote(revision)} && \ git checkout FETCH_HEAD`, ], env: [ diff --git a/src/server/lib/nativeHelm/__tests__/helm.behavior.test.ts b/src/server/lib/nativeHelm/__tests__/helm.behavior.test.ts index 4dd4974d..3e77eb36 100644 --- a/src/server/lib/nativeHelm/__tests__/helm.behavior.test.ts +++ b/src/server/lib/nativeHelm/__tests__/helm.behavior.test.ts @@ -23,7 +23,7 @@ const mockWithSpan = jest.fn(); const mockWithLogContext = jest.fn(); const mockMkdir = jest.fn(); const mockWriteFile = jest.fn(); -const mockShellPromise = jest.fn(); +const mockExecFilePromise = jest.fn(); const mockUnlink = jest.fn(); const mockNanoid = jest.fn(); const mockMetricsConstructor = jest.fn(); @@ -80,7 +80,7 @@ jest.mock('fs', () => ({ })); jest.mock('server/lib/shell', () => ({ - shellPromise: (...args: unknown[]) => mockShellPromise(...args), + execFilePromise: (...args: unknown[]) => mockExecFilePromise(...args), })); jest.mock('nanoid', () => ({ nanoid: () => mockNanoid() })); @@ -103,7 +103,7 @@ jest.mock('server/lib/helm/helm', () => ({ constructHelmDeploysBuildMetaData: (...args: unknown[]) => mockConstructBuildMetadata(...args), generateCodefreshRunCommand: async (...args: unknown[]) => { const plan = await mockGenerateCodefreshRunCommand(...args); - return typeof plan === 'string' ? { command: plan, configPath: null } : plan; + return typeof plan === 'string' ? { command: ['run', 'pipeline'], configPath: null } : plan; }, })); @@ -256,7 +256,7 @@ describe('native Helm orchestration behavior', () => { mockWithLogContext.mockImplementation(async (_context: unknown, action: () => Promise) => action()); mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined); - mockShellPromise.mockResolvedValue(''); + mockExecFilePromise.mockResolvedValue(''); mockNanoid.mockReturnValue('generated-run'); mockMetricsConstructor.mockReturnValue({ increment: mockMetricIncrement, event: mockMetricEvent }); mockPatchIngress.mockResolvedValue(undefined); @@ -332,11 +332,54 @@ describe('native Helm orchestration behavior', () => { limits: { cpu: '500m', memory: '512Mi' }, }, }); - expect(container.args[0]).toContain('kubectl get job helm-job -n preview-ns'); + expect(container.args[0]).toContain("kubectl get job --namespace='preview-ns'"); + expect(container.args[0]).toContain("-- 'helm-job'"); expect(container.args[0]).toContain('service=sample-service,app.kubernetes.io/name=native-helm'); expect(container.args[0]).toContain('Timed out after ${WAIT_TIMEOUT}s'); }); + it('keeps prior-deploy wait names and selectors literal through the generated shell', () => { + const { execFileSync } = jest.requireActual('child_process'); + const { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } = jest.requireActual('fs'); + const { join } = jest.requireActual('path'); + const { tmpdir } = jest.requireActual('os'); + const directory = mkdtempSync(join(tmpdir(), 'lfc-helm-wait-')); + try { + const log = join(directory, 'argv.jsonl'); + const marker = join(directory, 'unexpected'); + const value = `name'$(touch ${marker});next`; + writeFileSync( + join(directory, 'kubectl'), + `#!${process.execPath}\nrequire('fs').appendFileSync(process.env.ARGV_LOG,JSON.stringify(process.argv.slice(2))+'\\n');if(process.argv[3]==='job')process.stdout.write('2026-01-01T00:00:00Z');`, + { mode: 0o755 } + ); + const container = createWaitForPriorDeploysInitContainer(value, value, value); + execFileSync('/bin/bash', ['-c', container.args[0]], { + env: { ...process.env, PATH: `${directory}:${process.env.PATH}`, ARGV_LOG: log }, + }); + const calls = readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse); + expect(calls[0]).toEqual([ + 'get', + 'job', + `--namespace=${value}`, + '-o', + 'jsonpath={.metadata.creationTimestamp}', + '--', + value, + ]); + expect(calls[1].slice(0, 5)).toEqual([ + 'get', + 'jobs', + `--namespace=${value}`, + '-l', + `service=${value},app.kubernetes.io/name=native-helm`, + ]); + expect(existsSync(marker)).toBe(false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + it('returns false without a deployable and rejects manifest generation before external calls', async () => { const { deploy } = createDeploy({ deployable: undefined }); @@ -438,14 +481,13 @@ describe('native Helm orchestration behavior', () => { expect(mockSecretProcessorConstructor).not.toHaveBeenCalled(); expect(mockMkdir).toHaveBeenCalledWith('/tmp/lifecycle-manifests/helm/', { recursive: true }); expect(mockWriteFile).toHaveBeenCalledWith( - '/tmp/lifecycle-manifests/helm/deploy-uuid-helm-no-sha', + expect.stringMatching(/^\/tmp\/lifecycle-manifests\/helm\/[a-f0-9]{64}\.yaml$/), expect.stringContaining('kind: Job'), 'utf8' ); - expect(mockShellPromise).toHaveBeenCalledWith( - 'kubectl apply -f /tmp/lifecycle-manifests/helm/deploy-uuid-helm-no-sha', - { timeout: 90_000 } - ); + expect(mockExecFilePromise).toHaveBeenCalledWith('kubectl', ['apply', '-f', mockWriteFile.mock.calls[0][0]], { + timeout: 90_000, + }); expect(where).not.toHaveBeenCalled(); expect(mockArchiveLogs).not.toHaveBeenCalled(); }); @@ -494,7 +536,9 @@ describe('native Helm orchestration behavior', () => { expect(mockProcessSecretRefs).toHaveBeenCalledTimes(1); expect(mockWaitForSecretSync).not.toHaveBeenCalled(); - expect(mockShellPromise).toHaveBeenCalledWith(expect.stringContaining('kubectl apply -f '), { timeout: 90_000 }); + expect(mockExecFilePromise).toHaveBeenCalledWith('kubectl', ['apply', '-f', mockWriteFile.mock.calls[0][0]], { + timeout: 90_000, + }); }); it('does not write or apply a manifest when a secret mutation gate supersedes the deploy', async () => { @@ -510,7 +554,7 @@ describe('native Helm orchestration behavior', () => { expect(secretMutationGate).toHaveBeenCalledWith(deploy, expect.any(Function)); expect(mockProcessSecretRefs).not.toHaveBeenCalled(); expect(mockWriteFile).not.toHaveBeenCalled(); - expect(mockShellPromise).not.toHaveBeenCalled(); + expect(mockExecFilePromise).not.toHaveBeenCalled(); }); it('preserves deploy success when log archival fails and reports the warning', async () => { @@ -570,7 +614,7 @@ describe('native Helm orchestration behavior', () => { 'Native helm configuration validation failed: chart is required, release is invalid' ); expect(mockWriteFile).not.toHaveBeenCalled(); - expect(mockShellPromise).not.toHaveBeenCalled(); + expect(mockExecFilePromise).not.toHaveBeenCalled(); }); it.each([ @@ -637,7 +681,7 @@ describe('native Helm orchestration behavior', () => { }, }); mockPatchIngress.mockRejectedValueOnce(patchError); - mockShellPromise.mockResolvedValueOnce('pipeline output'); + mockExecFilePromise.mockResolvedValueOnce('pipeline output'); await expect(deployHelm([deploy])).resolves.toBeUndefined(); @@ -645,7 +689,7 @@ describe('native Helm orchestration behavior', () => { expect(patch).toHaveBeenCalledWith({ runUUID: 'generated-run' }); expect(deploy.runUUID).toBe('generated-run'); expect(mockGenerateCodefreshRunCommand).toHaveBeenCalledWith(deploy); - expect(mockShellPromise).toHaveBeenCalledWith('codefresh run pipeline'); + expect(mockExecFilePromise).toHaveBeenCalledWith('codefresh', ['run', 'pipeline']); expect(mockGetPipelineId).toHaveBeenCalledWith('pipeline output'); expect(mockCheckPipelineStatus).toHaveBeenCalledWith('pipeline-1'); expect(mockWaitForPipeline).toHaveBeenCalledTimes(1); @@ -693,14 +737,14 @@ describe('native Helm orchestration behavior', () => { deployable: { name: 'sample-service', repository: { fullName: 'example/repository' } }, }); mockGenerateCodefreshRunCommand.mockResolvedValueOnce({ - command: 'codefresh run pipeline', + command: ['run', 'pipeline'], configPath: '/tmp/lifecycle/codefresh/helm-deploy-sample-abc.yaml', }); mockUnlink.mockResolvedValue(undefined); await expect(deployHelm([deploy])).resolves.toBeUndefined(); - expect(mockShellPromise).toHaveBeenCalledWith('codefresh run pipeline'); + expect(mockExecFilePromise).toHaveBeenCalledWith('codefresh', ['run', 'pipeline']); expect(mockUnlink).toHaveBeenCalledWith('/tmp/lifecycle/codefresh/helm-deploy-sample-abc.yaml'); }); @@ -709,14 +753,14 @@ describe('native Helm orchestration behavior', () => { deployable: { name: 'sample-service', repository: { fullName: 'example/repository' } }, }); mockGenerateCodefreshRunCommand.mockResolvedValueOnce({ - command: 'codefresh run pipeline', + command: ['run', 'pipeline'], configPath: '/tmp/x.yaml', }); mockUnlink.mockResolvedValue(undefined); await expect(deployHelm([deploy], { providerSubmissionGate: async () => false })).rejects.toThrow(); - expect(mockShellPromise).not.toHaveBeenCalled(); + expect(mockExecFilePromise).not.toHaveBeenCalled(); expect(mockUnlink).toHaveBeenCalledWith('/tmp/x.yaml'); }); diff --git a/src/server/lib/nativeHelm/__tests__/helm.test.ts b/src/server/lib/nativeHelm/__tests__/helm.test.ts index 8973e93a..8c295196 100644 --- a/src/server/lib/nativeHelm/__tests__/helm.test.ts +++ b/src/server/lib/nativeHelm/__tests__/helm.test.ts @@ -31,7 +31,7 @@ import Deploy from 'server/models/Deploy'; import GlobalConfigService from 'server/services/globalConfig'; import { buildDeployJobName } from 'server/lib/kubernetes/jobNames'; import { waitForJobAndGetLogs } from 'server/lib/nativeBuild/utils'; -import { shellPromise } from 'server/lib/shell'; +import { execFilePromise } from 'server/lib/shell'; import { getLogArchivalService } from 'server/services/logArchival'; import { YamlConfigParser } from 'server/lib/yamlConfigParser'; import * as YamlService from 'server/models/yaml'; @@ -63,7 +63,7 @@ jest.mock('server/lib/random', () => ({ randomAlphanumeric: jest.fn().mockReturnValue('k4hlde'), })); jest.mock('server/lib/shell', () => ({ - shellPromise: jest.fn(), + execFilePromise: jest.fn(), })); jest.mock('server/lib/nativeBuild/utils', () => { const originalModule = jest.requireActual('server/lib/nativeBuild/utils'); @@ -1760,7 +1760,7 @@ describe('Native Helm', () => { nativeHelm: {}, }); - (shellPromise as jest.Mock).mockResolvedValue(''); + (execFilePromise as jest.Mock).mockResolvedValue(''); (waitForJobAndGetLogs as jest.Mock).mockResolvedValue({ logs: 'helm logs', success: true, @@ -1804,10 +1804,18 @@ describe('Native Helm', () => { expect(waitForSecretSync).toHaveBeenCalledWith({ 'sample-secret': ['TOKEN'] }, 'testns', 60000, { 'sample-secret': 'sync-c', }); - expect(shellPromise).toHaveBeenCalledWith(expect.stringContaining('kubectl apply -f '), { - timeout: 90_000, - }); - expect(shellPromise).not.toHaveBeenCalledWith(expect.stringContaining('--request-timeout'), expect.anything()); + expect(execFilePromise).toHaveBeenCalledWith( + 'kubectl', + ['apply', '-f', expect.stringMatching(/\/helm\/[a-f0-9]{64}\.yaml$/)], + { + timeout: 90_000, + } + ); + expect(execFilePromise).not.toHaveBeenCalledWith( + 'kubectl', + expect.arrayContaining(['--request-timeout']), + expect.anything() + ); expect(outputPatch.where).toHaveBeenCalledWith({ id: 42, runUUID: 'run-c' }); expect(archiveLogs).toHaveBeenCalledWith(expect.objectContaining({ jobName: expectedJobName }), 'helm logs'); expect(result).toEqual({ diff --git a/src/server/lib/nativeHelm/helm.ts b/src/server/lib/nativeHelm/helm.ts index 0c49723b..b5afb74b 100644 --- a/src/server/lib/nativeHelm/helm.ts +++ b/src/server/lib/nativeHelm/helm.ts @@ -20,7 +20,9 @@ import Deploy from 'server/models/Deploy'; import Deployable from 'server/models/Deployable'; import GlobalConfigService from 'server/services/globalConfig'; import { getLogger, withSpan, withLogContext } from 'server/lib/logger'; -import { shellPromise } from 'server/lib/shell'; +import { execFilePromise } from 'server/lib/shell'; +import { shellQuote } from 'server/lib/shellQuote'; +import { createHash } from 'crypto'; import { randomAlphanumeric } from 'server/lib/random'; import { nanoid } from 'nanoid'; import { Metrics } from 'server/lib/metrics'; @@ -146,8 +148,10 @@ export async function createHelmContainer( export function createWaitForPriorDeploysInitContainer(namespace: string, serviceName: string, jobName: string): any { const script = [ 'set -euo pipefail', - `echo "Checking for prior deploy jobs for service=${serviceName}"`, - `MY_CREATED=$(kubectl get job ${jobName} -n ${namespace} -o jsonpath='{.metadata.creationTimestamp}')`, + `printf '%s\\n' ${shellQuote(`Checking for prior deploy jobs for service=${serviceName}`)}`, + `MY_CREATED=$(kubectl get job --namespace=${shellQuote( + namespace + )} -o jsonpath='{.metadata.creationTimestamp}' -- ${shellQuote(jobName)})`, `WAIT_TIMEOUT=${HELM_WAIT_TIMEOUT_SECONDS}`, 'POLL_INTERVAL=10', 'wait_start=$(date +%s)', @@ -158,7 +162,11 @@ export function createWaitForPriorDeploysInitContainer(namespace: string, servic ' exit 1', ' fi', '', - ` blocking_jobs=$(kubectl get jobs -n ${namespace} -l "service=${serviceName},app.kubernetes.io/name=native-helm" -o jsonpath='{range .items[*]}{.metadata.name}{"\\t"}{.metadata.creationTimestamp}{"\\t"}{.status.active}{"\\n"}{end}' | awk -v my_name="${jobName}" -v my_ts="$MY_CREATED" '`, + ` blocking_jobs=$(kubectl get jobs --namespace=${shellQuote(namespace)} -l ${shellQuote( + `service=${serviceName},app.kubernetes.io/name=native-helm` + )} -o jsonpath='{range .items[*]}{.metadata.name}{"\\t"}{.metadata.creationTimestamp}{"\\t"}{.status.active}{"\\n"}{end}' | awk -v my_name=${shellQuote( + jobName + )} -v my_ts="$MY_CREATED" '`, ' BEGIN { result = "" }', ' $1 != my_name && (($2 < my_ts) || ($2 == my_ts && $1 < my_name)) && ($3 + 0) > 0 {', ' result = result (result ? " " : "") $1', @@ -364,10 +372,13 @@ export async function nativeHelmDeploy( const manifest = await generateHelmManifest(deploy, jobName, options, helmConfig); - const localPath = `${MANIFEST_PATH}/helm/${deploy.uuid}-helm-${shortSha}`; + const manifestName = createHash('sha256') + .update(JSON.stringify([deploy.uuid, shortSha])) + .digest('hex'); + const localPath = `${MANIFEST_PATH}/helm/${manifestName}.yaml`; await fs.promises.mkdir(`${MANIFEST_PATH}/helm/`, { recursive: true }); await fs.promises.writeFile(localPath, manifest, 'utf8'); - await shellPromise(`kubectl apply -f ${localPath}`, { timeout: 90_000 }); + await execFilePromise('kubectl', ['apply', '-f', localPath], { timeout: 90_000 }); const jobResult = await waitForJobAndGetLogs(jobName, namespace, `[HELM ${deploy.uuid}]`); @@ -487,7 +498,7 @@ async function deployCodefreshHelm( if (options.providerSubmissionGate && !(await options.providerSubmissionGate(deploy))) { throw new DeploymentSupersededError(); } - output = await shellPromise(codefreshRunCommand); + output = await execFilePromise('codefresh', codefreshRunCommand); } finally { if (configPath) await fs.promises.unlink(configPath).catch(() => undefined); } diff --git a/src/server/lib/shell.ts b/src/server/lib/shell.ts index 285bdb8e..4c9fbb4f 100644 --- a/src/server/lib/shell.ts +++ b/src/server/lib/shell.ts @@ -15,7 +15,9 @@ */ import { getLogger } from 'server/lib/logger'; +import { execFile } from 'child_process'; import shell, { ExecOptions } from 'shelljs'; +import { shellQuote } from './shellQuote'; interface Options extends ExecOptions { debug?: boolean; @@ -49,3 +51,46 @@ export async function shellPromise(cmd: string, options: Options = {}): Promise< }); }); } + +interface FileOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeout?: number; + redactCommand?: string; + redactValues?: string[]; +} + +export function redactCommandOutput(output: string, values: string[] = []): string { + return [...new Set(values)] + .filter(Boolean) + .sort((a, b) => b.length - a.length) + .reduce((redacted, value) => redacted.split(value).join('[REDACTED]'), output); +} + +export async function execFilePromise(file: string, args: string[], options: FileOptions = {}): Promise { + const { redactCommand, redactValues, ...execOptions } = options; + const displayCommand = redactCommandOutput(redactCommand || [file, ...args].map(shellQuote).join(' '), redactValues); + return new Promise((resolve, reject) => { + execFile( + file, + args, + { ...execOptions, shell: false, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }, + (error, stdout, stderr) => { + if (!error) { + resolve(stdout); + return; + } + const safeStderr = redactCommandOutput(stderr, redactValues); + const safeStdout = redactCommandOutput(stdout, redactValues); + if (stderr.length > 0) { + getLogger().debug(`Command failed: cmd=${displayCommand} stderr=${safeStderr}`); + } + reject( + `execFilePromise command failed:\nExit code: ${ + error.code ?? error.signal ?? 'unknown' + }\nCommand:\n${displayCommand},\n\nstderr:\n${safeStderr}\n\nstdout:\n${safeStdout}` + ); + } + ); + }); +} diff --git a/src/server/lib/shellQuote.ts b/src/server/lib/shellQuote.ts new file mode 100644 index 00000000..44d0334f --- /dev/null +++ b/src/server/lib/shellQuote.ts @@ -0,0 +1,4 @@ +/** Quote one literal argument for a generated POSIX shell script. */ +export function shellQuote(value: unknown): string { + return `'${String(value ?? '').replace(/'/g, "'\\''")}'`; +} diff --git a/src/server/services/__tests__/ingress.test.ts b/src/server/services/__tests__/ingress.test.ts index a5146fe5..511d6197 100644 --- a/src/server/services/__tests__/ingress.test.ts +++ b/src/server/services/__tests__/ingress.test.ts @@ -43,7 +43,7 @@ jest.mock('server/lib/logger', () => ({ }, })); -jest.mock('server/lib/shell', () => ({ shellPromise: jest.fn() })); +jest.mock('server/lib/shell', () => ({ execFilePromise: jest.fn() })); jest.mock('fs', () => { const actualFs = jest.requireActual('fs'); @@ -72,7 +72,7 @@ jest.mock('server/services/globalConfig', () => ({ import yaml from 'js-yaml'; import { withLogContext } from 'server/lib/logger'; -import { shellPromise } from 'server/lib/shell'; +import { execFilePromise } from 'server/lib/shell'; import IngressService from '../ingress'; const queueManager = { @@ -100,7 +100,7 @@ describe('IngressService', () => { lifecycleDefaults: {}, domainDefaults: { altHttp: [] }, }); - (shellPromise as jest.Mock).mockResolvedValue(undefined); + (execFilePromise as jest.Mock).mockResolvedValue(undefined); mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined); }); @@ -133,7 +133,25 @@ describe('IngressService', () => { const service = new IngressService({} as any, {} as any, {} as any, queueManager as any); await expect(service.updateIngressManifest()).resolves.toBe(true); - expect(shellPromise).not.toHaveBeenCalled(); + expect(execFilePromise).not.toHaveBeenCalled(); + }); + + test('keeps manifest names inside the temporary directory without changing resource data', async () => { + const service = new IngressService({} as any, {} as any, {} as any, queueManager as any); + const name = '../../outside'; + const namespace = '--context=other'; + const manifest = 'kind: Ingress\nmetadata:\n name: original'; + await expect((service as any).applyManifests(manifest, name, namespace)).resolves.toBe(true); + const path = mockWriteFile.mock.calls[0][0]; + expect(path).toMatch(/\/global-ingress\/[a-f0-9]{64}\.yaml$/); + expect(mockWriteFile).toHaveBeenCalledWith(path, manifest, 'utf8'); + expect(execFilePromise).toHaveBeenCalledWith('kubectl', ['apply', '-f', path, `--namespace=${namespace}`], { + timeout: 90_000, + }); + await (service as any).applyManifests(manifest, name, namespace); + expect(mockWriteFile.mock.calls[1][0]).toBe(path); + await (service as any).applyManifests(manifest, name, 'another-namespace'); + expect(mockWriteFile.mock.calls[2][0]).not.toBe(path); }); test('cleans up every service ingress and tolerates an already-missing ingress', async () => { @@ -145,7 +163,7 @@ describe('IngressService', () => { services: { BuildService: { configurationsForBuildId, getNamespace } }, }; const service = new IngressService(db as any, {} as any, {} as any, queueManager as any); - (shellPromise as jest.Mock) + (execFilePromise as jest.Mock) .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('api ingress not found')); @@ -172,8 +190,18 @@ describe('IngressService', () => { ); expect(configurationsForBuildId).toHaveBeenCalledWith(7, true); expect(getNamespace).toHaveBeenCalledWith({ id: 7 }); - expect(shellPromise).toHaveBeenCalledWith('kubectl delete ingress ingress-web-build-1 --namespace env-build-1'); - expect(shellPromise).toHaveBeenCalledWith('kubectl delete ingress ingress-api-build-1 --namespace env-build-1'); + expect(execFilePromise).toHaveBeenCalledWith('kubectl', [ + 'delete', + 'ingress', + 'ingress-web-build-1', + '--namespace=env-build-1', + ]); + expect(execFilePromise).toHaveBeenCalledWith('kubectl', [ + 'delete', + 'ingress', + 'ingress-api-build-1', + '--namespace=env-build-1', + ]); expect(mockLoggerWarn).toHaveBeenCalledWith('Error: api ingress not found'); expect(mockLoggerInfo).toHaveBeenCalledWith('Ingress: cleaned up'); }); @@ -189,7 +217,7 @@ describe('IngressService', () => { }; const service = new IngressService(db as any, {} as any, {} as any, queueManager as any); const error = new Error('shell unavailable'); - (shellPromise as jest.Mock).mockImplementationOnce(() => { + (execFilePromise as jest.Mock).mockImplementationOnce(() => { throw error; }); @@ -238,7 +266,7 @@ describe('IngressService', () => { recursive: true, }); expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining('/global-ingress/7-0-nginx-ingress.yaml'), + expect.stringMatching(/\/global-ingress\/[a-f0-9]{64}\.yaml$/), expect.any(String), 'utf8' ); @@ -263,8 +291,9 @@ describe('IngressService', () => { expect.objectContaining({ host: 'web-build-1.preview.example.test' }), ]) ); - expect(shellPromise).toHaveBeenCalledWith( - expect.stringMatching(/^kubectl apply -f .*7-0-nginx-ingress\.yaml --namespace env-build-1$/), + expect(execFilePromise).toHaveBeenCalledWith( + 'kubectl', + ['apply', '-f', mockWriteFile.mock.calls[0][0], '--namespace=env-build-1'], { timeout: 90_000 } ); expect(mockLoggerInfo).toHaveBeenCalledWith('Ingress: created'); @@ -322,7 +351,7 @@ describe('IngressService', () => { }, }; const error = new Error('route rejected'); - (shellPromise as jest.Mock).mockRejectedValueOnce(error); + (execFilePromise as jest.Mock).mockRejectedValueOnce(error); const service = new IngressService(db as any, {} as any, {} as any, queueManager as any); await expect(service.createOrUpdateIngressForBuild({ data: { buildId: 7 } })).resolves.toBeUndefined(); @@ -353,7 +382,7 @@ describe('IngressService', () => { }; mockGetAllConfigs.mockResolvedValueOnce({}); const applyError = new Error('route rejected'); - (shellPromise as jest.Mock).mockRejectedValueOnce(applyError); + (execFilePromise as jest.Mock).mockRejectedValueOnce(applyError); const service = new IngressService(db as any, {} as any, {} as any, queueManager as any); const recordFailure = jest .spyOn(service as any, 'recordIngressFailureOnBuild') diff --git a/src/server/services/ingress.ts b/src/server/services/ingress.ts index 6aeb5499..ccf958ac 100644 --- a/src/server/services/ingress.ts +++ b/src/server/services/ingress.ts @@ -18,9 +18,10 @@ import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; import BaseService from './_service'; import fs from 'fs'; +import { createHash } from 'crypto'; import { TMP_PATH, QUEUE_NAMES } from 'shared/config'; import { IngressConfiguration } from '../../server/services/build'; -import { shellPromise } from 'server/lib/shell'; +import { execFilePromise } from 'server/lib/shell'; import yaml from 'js-yaml'; import { redisClient } from 'server/lib/dependencies'; import GlobalConfigService from './globalConfig'; @@ -75,12 +76,15 @@ export default class IngressService extends BaseService { try { await Promise.all( configurations.map((configuration) => - shellPromise(`kubectl delete ingress ingress-${configuration.deployUUID} --namespace ${namespace}`).catch( - (error) => { - getLogger({ stage: LogStage.INGRESS_PROCESSING }).warn(`${error}`); - return null; - } - ) + execFilePromise('kubectl', [ + 'delete', + 'ingress', + `ingress-${configuration.deployUUID}`, + `--namespace=${namespace}`, + ]).catch((error) => { + getLogger({ stage: LogStage.INGRESS_PROCESSING }).warn(`${error}`); + return null; + }) ) ); getLogger({ stage: LogStage.INGRESS_COMPLETE }).info('Ingress: cleaned up'); @@ -244,12 +248,15 @@ export default class IngressService extends BaseService { expectedGeneration?: number ) => { try { - const localPath = `${MANIFEST_PATH}/global-ingress/${ingressName}-ingress.yaml`; + const manifestName = createHash('sha256') + .update(JSON.stringify([namespace, ingressName])) + .digest('hex'); + const localPath = `${MANIFEST_PATH}/global-ingress/${manifestName}.yaml`; await fs.promises.mkdir(`${MANIFEST_PATH}/global-ingress/`, { recursive: true, }); await fs.promises.writeFile(localPath, manifest, 'utf8'); - await shellPromise(`kubectl apply -f ${localPath} --namespace ${namespace}`, { + await execFilePromise('kubectl', ['apply', '-f', localPath, `--namespace=${namespace}`], { timeout: 90_000, }); return true; From 75fed7e06e01195326ab0b00214b603128034fed Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sun, 25 Oct 2026 10:00:56 -0700 Subject: [PATCH 02/10] fix: retire v1 API and protect GitHub App setup --- docs/upgrades/api-v1-retirement.md | 35 + public/utils/0-banner.js | 320 +--------- src/__tests__/pages-api/buildRoutes.test.ts | 597 ------------------ .../pages-api/kubernetesRoutes.test.ts | 443 ------------- src/__tests__/pages-api/logRoutes.test.ts | 340 ---------- .../pages-api/operationalRoutes.test.ts | 215 ------- .../pages-api/resourceRoutes.test.ts | 431 ------------- .../pages-api/schemaValidate.test.ts | 138 ---- src/__tests__/pages-api/setupRoutes.test.ts | 382 ----------- src/__tests__/pages-api/v1Retirement.test.ts | 23 + .../api/setup/__tests__/setupRoutes.test.ts | 348 ++++++++++ src/app/api/setup/callback/route.ts | 98 +++ src/app/api/setup/configure/route.ts | 96 +++ src/app/api/setup/installed/route.ts | 67 ++ src/app/api/setup/manifest/route.ts | 112 ++++ src/app/api/setup/setupAccess.ts | 253 ++++++++ src/app/api/setup/status/route.ts | 41 ++ src/components/SetupTokenField.tsx | 52 ++ src/middleware.test.ts | 2 +- src/middleware.ts | 2 +- src/pages/api/v1/admin/ttl/cleanup.ts | 213 ------- src/pages/api/v1/builds/[uuid]/deploy.ts | 126 ---- src/pages/api/v1/builds/[uuid]/graph.ts | 114 ---- src/pages/api/v1/builds/[uuid]/index.ts | 367 ----------- .../v1/builds/[uuid]/jobs/[jobName]/events.ts | 264 -------- .../v1/builds/[uuid]/jobs/[jobName]/logs.ts | 111 ---- .../v1/builds/[uuid]/services/[name]/build.ts | 147 ----- .../[uuid]/services/[name]/buildLogs.ts | 192 ------ .../services/[name]/buildLogs/[jobName].ts | 109 ---- .../[uuid]/services/[name]/deployLogs.ts | 154 ----- .../services/[name]/deployLogs/[jobName].ts | 151 ----- .../[uuid]/services/[name]/deployment.ts | 296 --------- .../v1/builds/[uuid]/services/[name]/logs.ts | 245 ------- .../[uuid]/services/[name]/logs/[jobName].ts | 219 ------- src/pages/api/v1/builds/[uuid]/torndown.ts | 150 ----- src/pages/api/v1/builds/[uuid]/webhooks.ts | 300 --------- src/pages/api/v1/builds/index.ts | 210 ------ src/pages/api/v1/config/cache.ts | 138 ---- src/pages/api/v1/deploy-summary.ts | 161 ----- src/pages/api/v1/deployables.ts | 165 ----- src/pages/api/v1/deploys.ts | 216 ------- src/pages/api/v1/pull-requests/[id]/builds.ts | 155 ----- src/pages/api/v1/pull-requests/[id]/index.ts | 150 ----- src/pages/api/v1/pull-requests/index.ts | 277 -------- src/pages/api/v1/repos/index.ts | 186 ------ src/pages/api/v1/schema/validate.ts | 165 ----- src/pages/api/v1/setup/callback.ts | 95 --- src/pages/api/v1/setup/configure.ts | 71 --- src/pages/api/v1/setup/index.ts | 120 ---- src/pages/api/v1/setup/installed.ts | 64 -- src/pages/api/v1/setup/status.ts | 45 -- src/pages/api/v1/users/index.ts | 186 ------ src/pages/schema/validate.tsx | 145 +---- src/pages/setup/complete.tsx | 215 ++++--- src/pages/setup/index.tsx | 482 +++++++------- src/pages/v1/docs.tsx | 65 +- .../lib/__tests__/buildSourceGate.test.ts | 2 - src/shared/config.ts | 1 + 58 files changed, 1551 insertions(+), 8916 deletions(-) create mode 100644 docs/upgrades/api-v1-retirement.md delete mode 100644 src/__tests__/pages-api/buildRoutes.test.ts delete mode 100644 src/__tests__/pages-api/kubernetesRoutes.test.ts delete mode 100644 src/__tests__/pages-api/logRoutes.test.ts delete mode 100644 src/__tests__/pages-api/operationalRoutes.test.ts delete mode 100644 src/__tests__/pages-api/resourceRoutes.test.ts delete mode 100644 src/__tests__/pages-api/schemaValidate.test.ts delete mode 100644 src/__tests__/pages-api/setupRoutes.test.ts create mode 100644 src/__tests__/pages-api/v1Retirement.test.ts create mode 100644 src/app/api/setup/__tests__/setupRoutes.test.ts create mode 100644 src/app/api/setup/callback/route.ts create mode 100644 src/app/api/setup/configure/route.ts create mode 100644 src/app/api/setup/installed/route.ts create mode 100644 src/app/api/setup/manifest/route.ts create mode 100644 src/app/api/setup/setupAccess.ts create mode 100644 src/app/api/setup/status/route.ts create mode 100644 src/components/SetupTokenField.tsx delete mode 100644 src/pages/api/v1/admin/ttl/cleanup.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/deploy.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/graph.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/index.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/services/[name]/build.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs/[jobName].ts delete mode 100644 src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs/[jobName].ts delete mode 100644 src/pages/api/v1/builds/[uuid]/services/[name]/deployment.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/services/[name]/logs.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/services/[name]/logs/[jobName].ts delete mode 100644 src/pages/api/v1/builds/[uuid]/torndown.ts delete mode 100644 src/pages/api/v1/builds/[uuid]/webhooks.ts delete mode 100644 src/pages/api/v1/builds/index.ts delete mode 100644 src/pages/api/v1/config/cache.ts delete mode 100644 src/pages/api/v1/deploy-summary.ts delete mode 100644 src/pages/api/v1/deployables.ts delete mode 100644 src/pages/api/v1/deploys.ts delete mode 100644 src/pages/api/v1/pull-requests/[id]/builds.ts delete mode 100644 src/pages/api/v1/pull-requests/[id]/index.ts delete mode 100644 src/pages/api/v1/pull-requests/index.ts delete mode 100644 src/pages/api/v1/repos/index.ts delete mode 100644 src/pages/api/v1/schema/validate.ts delete mode 100644 src/pages/api/v1/setup/callback.ts delete mode 100644 src/pages/api/v1/setup/configure.ts delete mode 100644 src/pages/api/v1/setup/index.ts delete mode 100644 src/pages/api/v1/setup/installed.ts delete mode 100644 src/pages/api/v1/setup/status.ts delete mode 100644 src/pages/api/v1/users/index.ts diff --git a/docs/upgrades/api-v1-retirement.md b/docs/upgrades/api-v1-retirement.md new file mode 100644 index 00000000..ddcdcc4a --- /dev/null +++ b/docs/upgrades/api-v1-retirement.md @@ -0,0 +1,35 @@ +# Retire the v1 API + +This release removes every `/api/v1/**` handler. Old API calls return 404. The v2 API keeps its existing session and API-key policies. This change does not redeploy environment services, move Jobs or change Kubernetes permissions. + +Before the upgrade: + +- Inventory ingress and internal callers, including scheduled automation. Move v1 calls to supported v2 operations. A quiet ingress sample does not prove that internal callers are absent. +- Deploy the ticket-capable Lifecycle UI and publish a distinguishable ticket-capable CLI release. Validate live pod, build and deploy logs and archived logs against the new API image. +- Coordinate the API and WebSocket replica cutover. Mixed versions can interrupt logs. Drain every old replica and anonymous socket before declaring the endpoint closed. Users must reopen disconnected views or rerun CLI commands. +- Confirm job-only workers have no custom HTTP consumers beyond GET `/api/health` and GET `/api/jobs`. +- Update existing GitHub App settings before a future setup or installation flow: setup URL `/api/setup/installed`, redirect URL `/api/setup/callback`, and the matching OAuth callback entry. Keep the existing Keycloak/user authorization callback and `/api/webhooks/github` webhook URL. Existing configured Apps are managed through installation settings, not the fresh setup API. + +The environment banner now opens the authenticated environment page. It uses the existing `LFC_DASHBOARD_URL` metadata, so newly loaded banner scripts work in existing environments without a service redeploy. Already loaded or cached old scripts may still call the retired API until users refresh. The in-page anonymous log terminal is removed. + +The old `/schema/validate` page now explains CLI and authenticated v2 validation. Use `lfc schema validate lifecycle.yaml` for a local file or sign in and use `lfc schema validate --repo owner/repository --branch branch-name`. `/v1/docs` redirects to `/v2/docs`. + +## Fresh GitHub App setup + +Fresh setup uses `/setup` and `/api/setup/**`. It requires an operator token and reads the live bootstrap Secret before changing credentials. Any existing credentials lock new App creation. A valid, matching fresh setup flow can finish installation and configuration. There is no HTTP path to replace an existing configured App. + +Provide the same `LIFECYCLE_SETUP_TOKEN` to all web replicas, or use the token derived from their shared `ENCRYPTION_KEY`. The server does not log the token and does not generate per-process credentials. Operators with access to a web pod can retrieve the token without printing the encryption key: + +```sh +kubectl exec -n -- node -e 'const c=require("crypto"); const token=process.env.LIFECYCLE_SETUP_TOKEN?.trim(); if(token) process.stdout.write(token+"\n"); else { const key=process.env.ENCRYPTION_KEY?.trim(); if(!key || ["changeme","not_setup","replace_me","your_value_here"].includes(key.toLowerCase())) throw new Error("Configure LIFECYCLE_SETUP_TOKEN or ENCRYPTION_KEY"); process.stdout.write(c.createHmac("sha256",key).update("lifecycle-setup-token").digest("hex").slice(0,32)+"\n"); }' +``` + +Treat this output as a credential. Enter it in the setup form, keep it out of URLs and shared logs, and remove an explicit setup token after completion when it is no longer needed. Existing in-flight v1 setup links are rejected. Start a fresh setup flow using the new page. + +Setup callback state expires after one hour and is consumed when its phase advances. Mutations serialize on the existing `app_setup` database row. Kubernetes Secret writes use the observed resource version. Nonsecret flow annotations bind retry recovery to the exact App credentials written by that flow. This permits retry after a Secret write succeeds but the database commit fails. A conflicting operator credential update locks the flow instead of adopting the changed credentials. + +The installation callback verifies that the installation belongs to the App created by setup. Setup initializes only missing or known seed domain and registry defaults and preserves other configured values. It requests a rollout restart only after fresh installation is recorded. If a restart fails, retry the configure action or use the deployment tools. A restart accepted before a lost response may be repeated on retry. + +## Verification still required for an installation + +Run a fresh install rehearsal and verify callback state, Secret writes, configured-instance lockout, and queue bootstrap with the actual chart and authentication provider. Verify the banner in an existing environment with main-era injected metadata. Verify that all old v1 API paths return 404 on the built image, including each log alias and archived-log route. Unit tests do not establish private chart compatibility, actual client versions, production routing, or the absence of old listeners. diff --git a/public/utils/0-banner.js b/public/utils/0-banner.js index e0baa3b8..36695c4f 100644 --- a/public/utils/0-banner.js +++ b/public/utils/0-banner.js @@ -15,9 +15,6 @@ */ (function () { - const SERVICE_NAME = window.LFC_SERVICE_NAME || 'service name'; - const UUID = window.LFC_UUID || 'not found'; - const BADGE_STYLES = { position: 'fixed', bottom: '20px', @@ -35,28 +32,13 @@ pointerEvents: 'auto', }; - const TERMINAL_COLORS = { - bg: '#1E1E1E', - text: '#D4D4D4', - error: '#FF5252', - warning: '#FFA726', - info: '#4CAF50', - prompt: '#64B5F6', - }; - const LINK_STYLES = 'color: #4CAF50; text-decoration: none; border-bottom: 1px dotted #4CAF50;'; const BADGE_ID = 'versionBadge'; - const LOG_MODAL_ID = 'logsModal'; - const LOG_MODAL_CONTENT_ID = 'logsModalContent'; - const LOG_BUTTON_ID = 'showLogsButton'; - const SEARCH_INPUT_ID = 'logSearchInput'; + const LOGS_BUTTON_ID = 'openLogsButton'; let shadowRoot = null; - let logsModal = null; - let evtSource = null; const state = { isHidden: true, - terminalWasOpen: false, shouldShowBadge: false, isCollapsed: false, badgePosition: { @@ -134,7 +116,6 @@ state.isHidden = hiddenState === null ? true : hiddenState === 'true'; state.shouldShowBadge = badgeVisibleState === null ? false : badgeVisibleState === 'true'; - state.terminalWasOpen = localStorage.getItem('terminalWasOpen') === 'true'; state.isCollapsed = localStorage.getItem('bannerCollapsed') === 'true'; const savedPosition = localStorage.getItem('badgePosition'); @@ -155,11 +136,6 @@ state.shouldShowBadge = true; toggleBadge(); saveState(); - - if (state.terminalWasOpen) { - if (!logsModal) logsModal = createLogsModal(); - showLogsModal(); - } } function toggleBadge(forceHide = false) { @@ -179,14 +155,13 @@ } badge = createBadge(content); - addShowLogsButton(badge); + addLogsButton(badge); shadowRoot.appendChild(badge); saveState(); } function saveState() { localStorage.setItem('componentsHidden', state.isHidden); - localStorage.setItem('terminalWasOpen', state.terminalWasOpen); localStorage.setItem('badgeVisible', state.shouldShowBadge); localStorage.setItem('badgePosition', JSON.stringify(state.badgePosition)); localStorage.setItem('bannerCollapsed', state.isCollapsed); @@ -355,7 +330,7 @@ function updateBadgeCollapsedState(badge, contentWrapper) { const collapseBtn = badge.querySelector('#collapse-btn'); - const buttonContainer = shadowRoot.getElementById(`${LOG_BUTTON_ID}-container`); + const buttonContainer = shadowRoot.getElementById(`${LOGS_BUTTON_ID}-container`); const dragHandle = badge.querySelector('#drag-handle'); if (state.isCollapsed) { @@ -378,7 +353,7 @@ collapseBtn.style.borderRadius = '50%'; } else { contentWrapper.style.display = 'block'; - if (buttonContainer && !state.terminalWasOpen) buttonContainer.style.display = 'block'; + if (buttonContainer) buttonContainer.style.display = 'block'; if (dragHandle) dragHandle.style.display = 'block'; badge.style.width = ''; badge.style.height = ''; @@ -425,19 +400,27 @@ return badge; } - function addShowLogsButton(badge) { + function addLogsButton(badge) { + let environmentUrl; + try { + environmentUrl = new URL(window.LFC_ENVIRONMENT_URL || window.LFC_DASHBOARD_URL || ''); + if (!['https:', 'http:'].includes(environmentUrl.protocol)) return; + } catch { + return; + } + const buttonContainer = document.createElement('div'); - buttonContainer.id = `${LOG_BUTTON_ID}-container`; + buttonContainer.id = `${LOGS_BUTTON_ID}-container`; Object.assign(buttonContainer.style, { padding: '8px 15px 15px 15px', borderTop: '1px solid rgba(255, 255, 255, 0.1)', marginTop: '10px', - display: state.terminalWasOpen || state.isCollapsed ? 'none' : 'block', + display: state.isCollapsed ? 'none' : 'block', }); const btn = document.createElement('button'); - btn.id = LOG_BUTTON_ID; - btn.textContent = 'Show logs'; + btn.id = LOGS_BUTTON_ID; + btn.textContent = 'View logs'; Object.assign(btn.style, { padding: '8px 12px', fontSize: '14px', @@ -454,273 +437,16 @@ btn.addEventListener('mouseout', () => (btn.style.backgroundColor = 'rgba(255, 255, 255, 0.1)')); btn.addEventListener('click', (e) => { e.stopPropagation(); - showLogsModal(); - buttonContainer.style.display = 'none'; + window.open(environmentUrl.toString(), '_blank', 'noopener,noreferrer'); }); buttonContainer.appendChild(btn); badge.appendChild(buttonContainer); } - function createLogsModal() { - const modal = document.createElement('div'); - modal.id = LOG_MODAL_ID; - Object.assign(modal.style, { - position: 'fixed', - left: '0', - right: '0', - bottom: '0', - height: '400px', - backgroundColor: TERMINAL_COLORS.bg, - borderTop: `2px solid ${TERMINAL_COLORS.prompt}`, - zIndex: '2147483647', - overflow: 'hidden', - display: 'none', - fontFamily: '"Fira Code", "Source Code Pro", monospace', - pointerEvents: 'auto', - }); - - const header = document.createElement('div'); - Object.assign(header.style, { - height: '40px', - backgroundColor: '#2D2D2D', - borderBottom: '1px solid #3D3D3D', - display: 'flex', - alignItems: 'center', - padding: '0 10px', - cursor: 'ns-resize', - position: 'relative', - }); - - const controls = document.createElement('div'); - controls.innerHTML = ` - - - - `; - header.appendChild(controls); - - const searchContainer = document.createElement('div'); - Object.assign(searchContainer.style, { - position: 'absolute', - right: '40px', - top: '50%', - transform: 'translateY(-50%)', - display: 'flex', - alignItems: 'center', - zIndex: '1', - }); - - const searchInput = document.createElement('input'); - searchInput.id = SEARCH_INPUT_ID; - Object.assign(searchInput.style, { - backgroundColor: '#3D3D3D', - border: 'none', - color: TERMINAL_COLORS.text, - padding: '5px 10px', - borderRadius: '4px', - width: '200px', - fontSize: '14px', - height: '28px', - }); - searchInput.placeholder = 'Filter logs...'; - searchContainer.appendChild(searchInput); - - const copyBtn = document.createElement('button'); - copyBtn.textContent = 'Copy'; - Object.assign(copyBtn.style, { - backgroundColor: '#3D3D3D', - border: 'none', - color: TERMINAL_COLORS.text, - padding: '5px 10px', - borderRadius: '4px', - marginLeft: '8px', - cursor: 'pointer', - fontSize: '14px', - height: '28px', - }); - - copyBtn.addEventListener('click', async () => { - const contentDiv = shadowRoot.getElementById(LOG_MODAL_CONTENT_ID); - const logLines = Array.from(contentDiv.getElementsByClassName('log-line')) - .filter((line) => line.style.display !== 'none') - .map((line) => line.textContent) - .join('\n'); - - try { - await navigator.clipboard.writeText(logLines); - copyBtn.textContent = 'Copied!'; - setTimeout(() => { - copyBtn.textContent = 'Copy'; - }, 2000); - } catch (err) { - console.error('Failed to copy logs:', err); - copyBtn.textContent = 'Failed'; - setTimeout(() => { - copyBtn.textContent = 'Copy'; - }, 2000); - } - }); - - copyBtn.addEventListener('mouseover', () => (copyBtn.style.backgroundColor = '#4D4D4D')); - copyBtn.addEventListener('mouseout', () => (copyBtn.style.backgroundColor = '#3D3D3D')); - - searchContainer.appendChild(copyBtn); - - const closeBtn = document.createElement('button'); - closeBtn.textContent = '\u2715'; - Object.assign(closeBtn.style, { - position: 'absolute', - right: '10px', - top: '50%', - transform: 'translateY(-50%)', - backgroundColor: 'transparent', - border: 'none', - color: TERMINAL_COLORS.text, - fontSize: '20px', - cursor: 'pointer', - padding: '5px', - lineHeight: '1', - zIndex: '1', - }); - closeBtn.addEventListener('click', hideLogsModal); - - header.appendChild(searchContainer); - header.appendChild(closeBtn); - modal.appendChild(header); - - const contentDiv = document.createElement('div'); - contentDiv.id = LOG_MODAL_CONTENT_ID; - Object.assign(contentDiv.style, { - position: 'absolute', - top: '40px', - bottom: '0', - left: '0', - right: '0', - overflowY: 'auto', - padding: '10px', - backgroundColor: TERMINAL_COLORS.bg, - color: TERMINAL_COLORS.text, - fontSize: '14px', - lineHeight: '1.5', - userSelect: 'text', - }); - modal.appendChild(contentDiv); - - let isResizing = false; - let startY = 0; - let startHeight = 0; - - header.addEventListener('mousedown', (e) => { - if (e.target === searchInput || e.target.closest('button')) return; - isResizing = true; - startY = e.clientY; - startHeight = parseInt(window.getComputedStyle(modal).height, 10); - document.body.style.cursor = 'ns-resize'; - e.preventDefault(); - }); - - document.addEventListener('mousemove', (e) => { - if (!isResizing) return; - const dy = startY - e.clientY; - const newHeight = startHeight + dy; - if (newHeight > 200) modal.style.height = `${newHeight}px`; - }); - - document.addEventListener('mouseup', () => { - if (isResizing) { - isResizing = false; - document.body.style.cursor = ''; - } - }); - - searchInput.addEventListener('input', (e) => { - const searchTerm = e.target.value.toLowerCase(); - Array.from(contentDiv.getElementsByClassName('log-line')).forEach((line) => { - line.style.display = line.textContent.toLowerCase().includes(searchTerm) ? 'block' : 'none'; - }); - }); - - shadowRoot.appendChild(modal); - return modal; - } - - function colorizeLog(logText) { - if (logText.includes('Error:') || logText.includes('[ERROR]')) { - return `${logText}`; - } - if (logText.includes('Warning:') || logText.includes('[WARN]')) { - return `${logText}`; - } - if (logText.includes('Info:') || logText.includes('[INFO]')) { - return `${logText}`; - } - return logText; - } - - function getBaseUrl() { - return window.LFC_BASE_URL || 'PLEASE_UPDATE'; - } - - function showLogsModal() { - if (!logsModal) logsModal = createLogsModal(); - logsModal.style.display = 'block'; - state.terminalWasOpen = true; - saveState(); - - const contentDiv = shadowRoot.getElementById(LOG_MODAL_CONTENT_ID); - contentDiv.innerHTML = ''; - - const url = `${getBaseUrl()}/api/v1/builds/${encodeURIComponent(UUID)}/services/${encodeURIComponent( - SERVICE_NAME - )}/logs`; - evtSource = new EventSource(url); - - evtSource.onmessage = (event) => { - const logLine = document.createElement('div'); - logLine.className = 'log-line'; - logLine.innerHTML = colorizeLog(event.data); - contentDiv.appendChild(logLine); - - const searchTerm = shadowRoot.getElementById(SEARCH_INPUT_ID).value.toLowerCase(); - if (searchTerm) { - logLine.style.display = event.data.toLowerCase().includes(searchTerm) ? 'block' : 'none'; - } - contentDiv.scrollTop = contentDiv.scrollHeight; - }; - - evtSource.onerror = (error) => console.error('SSE error:', error); - } - - function hideLogsModal() { - if (logsModal) { - logsModal.style.display = 'none'; - state.terminalWasOpen = false; - saveState(); - - const buttonContainer = shadowRoot.getElementById(`${LOG_BUTTON_ID}-container`); - if (buttonContainer && !state.isCollapsed) buttonContainer.style.display = 'block'; - } - if (evtSource) { - evtSource.close(); - evtSource = null; - } - } - function hideAll() { - const wasTerminalOpen = logsModal?.style.display === 'block'; - - if (logsModal) { - logsModal.style.display = 'none'; - if (evtSource) { - evtSource.close(); - evtSource = null; - } - } - toggleBadge(true); state.isHidden = true; - state.terminalWasOpen = wasTerminalOpen; saveState(); } @@ -738,14 +464,8 @@ loadState(); initShortcut(); - if (!state.isHidden) { - if (state.shouldShowBadge) { - toggleBadge(); - } - if (state.terminalWasOpen) { - if (!logsModal) logsModal = createLogsModal(); - showLogsModal(); - } + if (!state.isHidden && state.shouldShowBadge) { + toggleBadge(); } setInterval(() => { diff --git a/src/__tests__/pages-api/buildRoutes.test.ts b/src/__tests__/pages-api/buildRoutes.test.ts deleted file mode 100644 index e4092b56..00000000 --- a/src/__tests__/pages-api/buildRoutes.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -type Query = Record & PromiseLike; - -function queryResult(value: T): Query { - const query = {} as Query; - for (const method of [ - 'select', - 'where', - 'whereNotIn', - 'whereIn', - 'findById', - 'findOne', - 'withGraphFetched', - 'offset', - 'limit', - 'orderBy', - 'patch', - ]) { - query[method] = jest.fn(() => query); - } - query.resultSize = jest.fn().mockResolvedValue(Array.isArray(value) ? value.length : 0); - query.first = jest.fn().mockResolvedValue(value); - query.then = jest.fn((resolve, reject) => Promise.resolve(value).then(resolve, reject)); - return query; -} - -const mockBuildQuery = jest.fn(); -const mockDeployQuery = jest.fn(); -const mockGithubBuildQuery = jest.fn(); -const mockWebhookBuildQuery = jest.fn(); -const mockInvocationQuery = jest.fn(); -const mockEnqueueBuild = jest.fn(); -const mockValidateUuid = jest.fn(); -const mockUpdateBuildUuid = jest.fn(); -const mockValidateUuidFormat = jest.fn(); -const mockGenerateGraph = jest.fn(); -const mockWebhookQueueAdd = jest.fn(); -const mockNanoid = jest.fn(() => 'fixed-id'); -const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); -const mockLogger = { - debug: jest.fn(), - info: jest.fn(), - error: jest.fn(), -}; - -const mockBuildService = { - db: { - models: { - Build: { query: (...args: unknown[]) => mockBuildQuery(...args) }, - Deploy: { query: (...args: unknown[]) => mockDeployQuery(...args) }, - }, - }, - enqueueResolveAndDeployBuild: (...args: unknown[]) => mockEnqueueBuild(...args), -}; - -const mockOverrideService = { - db: { models: { Build: { query: (...args: unknown[]) => mockBuildQuery(...args) } } }, - validateUuid: (...args: unknown[]) => mockValidateUuid(...args), - updateBuildUuid: (...args: unknown[]) => mockUpdateBuildUuid(...args), -}; - -const mockGithubService = { - db: { models: { Build: { query: (...args: unknown[]) => mockGithubBuildQuery(...args) } } }, -}; - -const mockWebhookService = { - db: { - models: { - Build: { query: (...args: unknown[]) => mockWebhookBuildQuery(...args) }, - WebhookInvocations: { query: (...args: unknown[]) => mockInvocationQuery(...args) }, - }, - }, - webhookQueue: { add: (...args: unknown[]) => mockWebhookQueueAdd(...args) }, -}; - -jest.mock('server/services/build', () => ({ - __esModule: true, - default: jest.fn(() => mockBuildService), -})); - -jest.mock('server/services/override', () => ({ - __esModule: true, - default: jest.fn(() => mockOverrideService), - BuildUuidValidationError: class BuildUuidValidationError extends Error {}, -})); - -jest.mock('server/services/github', () => ({ - __esModule: true, - default: jest.fn(() => mockGithubService), -})); - -jest.mock('server/services/webhook', () => ({ - __esModule: true, - default: jest.fn(() => mockWebhookService), -})); - -jest.mock('server/lib/validation/buildUuidValidator', () => ({ - validateBuildUuidFormat: (...args: unknown[]) => mockValidateUuidFormat(...args), -})); - -jest.mock('server/lib/dependencyGraph', () => ({ - generateGraph: (...args: unknown[]) => mockGenerateGraph(...args), -})); - -jest.mock('server/lib/logger', () => ({ - withLogContext: (...args: unknown[]) => mockWithLogContext(...(args as [unknown, () => unknown])), - getLogger: () => mockLogger, - LogStage: { - BUILD_QUEUED: 'build-queued', - BUILD_FAILED: 'build-failed', - WEBHOOK_PROCESSING: 'webhook-processing', - }, -})); - -jest.mock('nanoid', () => ({ nanoid: () => mockNanoid() })); - -import buildsHandler from 'src/pages/api/v1/builds'; -import deployHandler from 'src/pages/api/v1/builds/[uuid]/deploy'; -import graphHandler from 'src/pages/api/v1/builds/[uuid]/graph'; -import buildHandler from 'src/pages/api/v1/builds/[uuid]'; -import serviceBuildHandler from 'src/pages/api/v1/builds/[uuid]/services/[name]/build'; -import torndownHandler from 'src/pages/api/v1/builds/[uuid]/torndown'; -import webhooksHandler from 'src/pages/api/v1/builds/[uuid]/webhooks'; -import { BuildUuidValidationError } from 'server/services/override'; - -describe('legacy build action routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockWithLogContext.mockImplementation((_context: unknown, callback: () => unknown) => callback()); - mockValidateUuidFormat.mockReturnValue(undefined); - mockValidateUuid.mockResolvedValue({ valid: true }); - mockUpdateBuildUuid.mockResolvedValue({ build: { id: 1, uuid: 'new-uuid' } }); - mockEnqueueBuild.mockResolvedValue(undefined); - mockGenerateGraph.mockResolvedValue({ nodes: ['api'] }); - mockWebhookQueueAdd.mockResolvedValue({ id: 'job-1' }); - }); - - describe('GET /builds', () => { - it('rejects unsupported methods before querying', async () => { - const res = response(); - await buildsHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{}, ['torn_down', 'pending']], - [{ exclude: '["failed",7]' }, ['failed']], - [{ exclude: 'ready' }, ['ready']], - [{ exclude: '"ready"' }, ['torn_down', 'pending']], - ])('normalizes exclusions and returns unpaginated results: %j', async (params, excluded) => { - const builds = [{ uuid: 'build-1', status: 'ready' }]; - const query = queryResult(builds); - mockBuildQuery.mockReturnValue(query); - const res = response(); - await buildsHandler(request({ query: params }), res); - expect(query.whereNotIn).toHaveBeenCalledWith('status', excluded); - expect(res.body).toEqual({ - builds, - metadata: { currentPage: 1, totalPages: 1, total: 1, limit: 1 }, - }); - }); - - it.each([ - [{ page: '2', limit: '5' }, { currentPage: 2, totalPages: 3, total: 12, limit: 5 }, 5], - [{ page: 'bad', limit: '0' }, { currentPage: 1, totalPages: 1, total: 12, limit: 20 }, 0], - ])('paginates using current defaults and lower bounds: %j', async (params, metadata, offset) => { - const builds = [{ uuid: 'build-1', status: 'ready' }]; - const query = queryResult(builds); - query.resultSize.mockResolvedValue(12); - mockBuildQuery.mockReturnValue(query); - const res = response(); - await buildsHandler(request({ query: params }), res); - expect(query.offset).toHaveBeenCalledWith(offset); - expect(query.limit).toHaveBeenCalledWith(metadata.limit); - expect(res.body).toEqual({ builds, metadata }); - }); - - it('returns the stable 500 response on query failure', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await buildsHandler(request(), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('/builds/:uuid', () => { - it.each([{}, { uuid: ['build-1'] }])('rejects an invalid path UUID: %j', async (query) => { - const res = response(); - await buildHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it('rejects unsupported methods for a valid UUID', async () => { - const res = response(); - await buildHandler(request({ method: 'DELETE', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(405); - }); - - it('returns a selected build or 404 when absent', async () => { - const build = { id: 1, uuid: 'build-1', status: 'ready' }; - const foundQuery = queryResult(build); - mockBuildQuery.mockReturnValueOnce(foundQuery).mockReturnValueOnce(queryResult(undefined)); - - const found = response(); - await buildHandler(request({ query: { uuid: 'build-1' } }), found); - expect(foundQuery.findOne).toHaveBeenCalledWith({ uuid: 'build-1' }); - expect(found.body).toEqual(build); - - const missing = response(); - await buildHandler(request({ query: { uuid: 'missing' } }), missing); - expect(missing.statusCode).toBe(404); - }); - - it('maps GET query failures to the stable 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await buildHandler(request({ query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - }); - - it.each([ - [{}, { error: 'uuid is required' }], - [{ uuid: null }, { error: 'uuid is required' }], - [{ uuid: 7 }, { error: 'uuid must be a string' }], - ])('validates PATCH body before creating OverrideService: %j', async (body, expectedBody) => { - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'build-1' }, body }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual(expectedBody); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it('returns the UUID format validator message', async () => { - mockValidateUuidFormat.mockReturnValueOnce('UUID format is invalid'); - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'build-1' }, body: { uuid: 'INVALID' } }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'UUID format is invalid' }); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it('returns 404 when the build to update is absent', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'build-1' }, body: { uuid: 'new-uuid' } }), res); - expect(res.statusCode).toBe(404); - expect(mockValidateUuid).not.toHaveBeenCalled(); - }); - - it('rejects the existing UUID and service-level validation failures', async () => { - mockBuildQuery - .mockReturnValueOnce(queryResult({ id: 1, uuid: 'same-uuid' })) - .mockReturnValueOnce(queryResult({ id: 1, uuid: 'old-uuid' })); - const same = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'same-uuid' }, body: { uuid: 'same-uuid' } }), same); - expect(same.body).toEqual({ error: 'UUID must be different' }); - - mockValidateUuid.mockResolvedValueOnce({ valid: false, error: 'UUID is unavailable' }); - const invalid = response(); - await buildHandler( - request({ method: 'PATCH', query: { uuid: 'old-uuid' }, body: { uuid: 'new-uuid' } }), - invalid - ); - expect(invalid.body).toEqual({ error: 'UUID is unavailable' }); - expect(mockUpdateBuildUuid).not.toHaveBeenCalled(); - }); - - it.each([ - [{ id: 1, uuid: 'old-uuid', pullRequest: undefined }, false], - [{ id: 1, uuid: 'old-uuid', pullRequest: { deployOnUpdate: false } }, false], - [{ id: 1, uuid: 'old-uuid', pullRequest: { deployOnUpdate: true } }, true], - ])('updates UUID and conditionally redeploys: %j', async (build, redeploys) => { - mockBuildQuery.mockReturnValue(queryResult(build)); - mockUpdateBuildUuid.mockResolvedValueOnce({ build: { id: 1, uuid: 'new-uuid', status: 'ready' } }); - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'old-uuid' }, body: { uuid: 'new-uuid' } }), res); - expect(mockValidateUuid).toHaveBeenCalledWith('new-uuid', 1); - expect(mockUpdateBuildUuid).toHaveBeenCalledWith(build, 'new-uuid'); - if (redeploys) expect(mockEnqueueBuild).toHaveBeenCalledWith({ buildId: 1, runUUID: 'fixed-id' }); - else expect(mockEnqueueBuild).not.toHaveBeenCalled(); - expect(res.body).toEqual({ data: { id: 1, uuid: 'new-uuid', status: 'ready' } }); - }); - - it.each([ - [new BuildUuidValidationError('conflicting UUID'), 400, { error: 'conflicting UUID' }], - [new Error('db unavailable'), 500, { error: 'An unexpected error occurred' }], - ])('maps update errors by domain: %s', async (error, status, body) => { - mockBuildQuery.mockReturnValue(queryResult({ id: 1, uuid: 'old-uuid' })); - mockValidateUuid.mockRejectedValueOnce(error); - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'old-uuid' }, body: { uuid: 'new-uuid' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - }); - - describe('POST /builds/:uuid/deploy', () => { - it('rejects unsupported methods', async () => { - const res = response(); - await deployHandler(request({ query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(405); - }); - - it('returns 404 for a missing build without enqueuing', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await deployHandler(request({ method: 'POST', query: { uuid: 'missing' } }), res); - expect(res.statusCode).toBe(404); - expect(mockEnqueueBuild).not.toHaveBeenCalled(); - }); - - it('queues a full build redeploy', async () => { - mockBuildQuery.mockReturnValue(queryResult({ id: 42, uuid: 'build-1' })); - const res = response(); - await deployHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(mockEnqueueBuild).toHaveBeenCalledWith({ buildId: 42, runUUID: 'fixed-id' }); - expect(res.body).toEqual({ - status: 'success', - message: 'Redeploy for build build-1 has been queued', - }); - }); - - it('maps lookup/enqueue failures to the redeploy-specific 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await deployHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to proceed with redeploy for build build-1.' }); - }); - }); - - describe('GET /builds/:uuid/graph', () => { - it('rejects unsupported methods', async () => { - const res = response(); - await graphHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(405); - }); - - it.each([ - [{ nodes: ['existing'] }, false], - [{}, true], - ])('returns the stored graph and only generates an empty graph: %j', async (dependencyGraph, generates) => { - const patchAndFetch = jest.fn().mockImplementation(async (patch) => { - build.dependencyGraph = patch.dependencyGraph; - return build; - }); - const build = { - id: 1, - dependencyGraph, - $query: () => ({ patchAndFetch }), - }; - mockBuildQuery.mockReturnValue(queryResult(build)); - const res = response(); - await graphHandler(request({ query: { uuid: 'build-1' } }), res); - if (generates) { - expect(mockGenerateGraph).toHaveBeenCalledWith(build, 'TB'); - expect(patchAndFetch).toHaveBeenCalledWith({ dependencyGraph: { nodes: ['api'] } }); - expect(res.body).toEqual(expect.objectContaining({ dependencyGraph: { nodes: ['api'] } })); - } else { - expect(mockGenerateGraph).not.toHaveBeenCalled(); - expect(patchAndFetch).not.toHaveBeenCalled(); - expect(res.body).toEqual(expect.objectContaining({ dependencyGraph })); - } - }); - - it('maps lookup failures to the stable 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await graphHandler(request({ query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('POST /builds/:uuid/services/:name/build', () => { - it('rejects unsupported methods', async () => { - const res = response(); - await serviceBuildHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(405); - }); - - it('returns 404 for a missing build or service and does not enqueue', async () => { - mockBuildQuery - .mockReturnValueOnce(queryResult(undefined)) - .mockReturnValueOnce(queryResult({ id: 1, deploys: undefined })) - .mockReturnValueOnce(queryResult({ id: 1, deploys: [{ deployable: undefined }] })); - for (const uuid of ['missing', 'no-deploys', 'no-deployable']) { - const res = response(); - await serviceBuildHandler(request({ method: 'POST', query: { uuid, name: 'api' } }), res); - expect(res.statusCode).toBe(404); - } - expect(mockEnqueueBuild).not.toHaveBeenCalled(); - }); - - it.each([ - [{ resolvedFromRepositoryId: 11, repositoryId: 12 }, 11], - [{ resolvedFromRepositoryId: null, repositoryId: 12 }, 12], - [{ resolvedFromRepositoryId: null, repositoryId: null }, 13], - ])('queues a service redeploy using repository precedence: %j', async (deployable, repositoryId) => { - const build = { - id: 42, - deploys: [{ deployable: { name: 'api', ...deployable }, githubRepositoryId: 13 }], - }; - mockBuildQuery.mockReturnValue(queryResult(build)); - const res = response(); - await serviceBuildHandler(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), res); - expect(mockEnqueueBuild).toHaveBeenCalledWith({ - buildId: 42, - githubRepositoryId: repositoryId, - runUUID: 'fixed-id', - }); - expect(res.statusCode).toBe(200); - }); - - it('returns 500 when repository identity is absent or enqueueing fails', async () => { - const deployable = { name: 'api', resolvedFromRepositoryId: null, repositoryId: null }; - mockBuildQuery - .mockReturnValueOnce(queryResult({ id: 1, deploys: [{ deployable, githubRepositoryId: null }] })) - .mockReturnValueOnce(queryResult({ id: 1, deploys: [{ deployable: { name: 'api', repositoryId: 1 } }] })); - const missingRepo = response(); - await serviceBuildHandler(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), missingRepo); - expect(missingRepo.statusCode).toBe(500); - - mockEnqueueBuild.mockRejectedValueOnce(new Error('queue unavailable')); - const queueFailure = response(); - await serviceBuildHandler(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), queueFailure); - expect(queueFailure.statusCode).toBe(500); - }); - }); - - describe('PATCH /builds/:uuid/torndown', () => { - it('validates method and UUID before entering log context', async () => { - const method = response(); - await torndownHandler(request({ query: { uuid: 'build-1' } }), method); - expect(method.statusCode).toBe(405); - - const missing = response(); - await torndownHandler(request({ method: 'PATCH', query: {} }), missing); - expect(missing.statusCode).toBe(500); - expect(mockWithLogContext).not.toHaveBeenCalled(); - }); - - it('rejects static builds without mutating deploy status', async () => { - mockBuildQuery.mockReturnValue(queryResult({ id: 1, isStatic: true, deploys: [] })); - const res = response(); - await torndownHandler(request({ method: 'PATCH', query: { uuid: 'static' } }), res); - expect(res.statusCode).toBe(404); - expect(mockDeployQuery).not.toHaveBeenCalled(); - }); - - it('locks the current missing-build behavior: dereference is caught as a 500', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await torndownHandler(request({ method: 'PATCH', query: { uuid: 'missing' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred.' }); - }); - - it('marks the build and its deploys torn down, then returns updated deploys', async () => { - const build = { id: 1, isStatic: false, deploys: [{ id: 7 }, { id: 8 }] }; - const initialBuildQuery = queryResult(build); - const patchBuildQuery = queryResult(undefined); - const deployPatchQuery = queryResult(undefined); - const updatedDeploys = [{ id: 7, uuid: 'api-build-1', status: 'torn_down' }]; - const deploySelectQuery = queryResult(updatedDeploys); - mockBuildQuery.mockReturnValueOnce(initialBuildQuery).mockReturnValueOnce(patchBuildQuery); - mockDeployQuery.mockReturnValueOnce(deployPatchQuery).mockReturnValueOnce(deploySelectQuery); - const res = response(); - await torndownHandler(request({ method: 'PATCH', query: { uuid: 'build-1' } }), res); - expect(patchBuildQuery.findById).toHaveBeenCalledWith(1); - expect(patchBuildQuery.patch).toHaveBeenCalledWith({ - status: 'torn_down', - statusMessage: 'Namespace was deleted successfully', - }); - expect(deployPatchQuery.whereIn).toHaveBeenCalledWith('id', [7, 8]); - expect(res.body).toEqual({ - status: 'The namespace env-build-1 it was delete sucessfuly', - namespacesUpdated: updatedDeploys, - }); - }); - - it('maps database failures to a stable 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await torndownHandler(request({ method: 'PATCH', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('/builds/:uuid/webhooks', () => { - it.each([{}, { uuid: ['build-1'] }])('rejects invalid UUIDs: %j', async (query) => { - const res = response(); - await webhooksHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - }); - - it('advertises allowed methods', async () => { - const res = response(); - await webhooksHandler(request({ method: 'DELETE', query: { uuid: 'build-1' } }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET', 'POST']); - expect(res.statusCode).toBe(405); - }); - - it('maps synchronous routing failures to the outer stable error', async () => { - const res = response(); - (res.setHeader as jest.Mock).mockImplementationOnce(() => { - throw new Error('response unavailable'); - }); - await webhooksHandler(request({ method: 'DELETE', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred.' }); - }); - - it.each([ - [{}, { currentPage: 1, totalPages: 1, total: 12, limit: 100 }, 0], - [{ page: '2', limit: '5' }, { currentPage: 2, totalPages: 3, total: 12, limit: 5 }, 5], - [{ page: 'bad', limit: '0' }, { currentPage: 1, totalPages: 2, total: 12, limit: 10 }, 0], - ])('retrieves a page using the current query-builder build identifier: %j', async (params, metadata, offset) => { - const buildIdQuery = queryResult({ id: 42 }); - const countQuery = queryResult(undefined); - countQuery.resultSize.mockResolvedValue(12); - const webhooks = [{ id: 1, buildId: 42 }]; - const listQuery = queryResult(webhooks); - mockWebhookBuildQuery.mockReturnValue(buildIdQuery); - mockInvocationQuery.mockReturnValueOnce(countQuery).mockReturnValueOnce(listQuery); - const res = response(); - await webhooksHandler(request({ query: { uuid: 'build-1', ...params } }), res); - const buildIdPromise = buildIdQuery.first.mock.results[0].value; - expect(countQuery.where).toHaveBeenCalledWith('buildId', buildIdPromise); - expect(listQuery.offset).toHaveBeenCalledWith(offset); - expect(res.body).toEqual({ webhooks, metadata }); - }); - - it('maps webhook history query failures to the route-specific 500 response', async () => { - mockWebhookBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await webhooksHandler(request({ query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to retrieve webhooks for build build-1.' }); - }); - - it('locks the current missing-build POST behavior: id dereference produces a 500', async () => { - mockGithubBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await webhooksHandler(request({ method: 'POST', query: { uuid: 'missing' } }), res); - expect(res.statusCode).toBe(500); - expect(mockWebhookQueueAdd).not.toHaveBeenCalled(); - }); - - it('returns 204 when the build has no webhook configuration', async () => { - mockGithubBuildQuery.mockReturnValue(queryResult({ id: 42, webhooksYaml: null })); - const res = response(); - await webhooksHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(204); - expect(mockWebhookQueueAdd).not.toHaveBeenCalled(); - }); - - it('queues configured webhooks with the generated correlation ID', async () => { - mockGithubBuildQuery.mockReturnValue(queryResult({ id: 42, webhooksYaml: { webhooks: [] } })); - const res = response(); - await webhooksHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(mockWebhookQueueAdd).toHaveBeenCalledWith('webhook', { - buildId: 42, - correlationId: expect.stringMatching(/^api-webhook-invoke-\d+-fixed-id$/), - }); - expect(res.body).toEqual({ - status: 'success', - message: 'Webhook for build build-1 has been queued', - }); - }); - - it('maps queue failures to the invoke-specific 500 response', async () => { - mockGithubBuildQuery.mockReturnValue(queryResult({ id: 42, webhooksYaml: {} })); - mockWebhookQueueAdd.mockRejectedValueOnce(new Error('queue unavailable')); - const res = response(); - await webhooksHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ - error: 'Unable to proceed with triggering webhook for build build-1.', - }); - }); - }); -}); diff --git a/src/__tests__/pages-api/kubernetesRoutes.test.ts b/src/__tests__/pages-api/kubernetesRoutes.test.ts deleted file mode 100644 index 0ad02531..00000000 --- a/src/__tests__/pages-api/kubernetesRoutes.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { gzipSync } from 'zlib'; -import { request, response } from 'src/test-utils/pagesApi'; - -type Query = Record & PromiseLike; - -function queryResult(value: T): Query { - const query = {} as Query; - for (const method of ['where', 'withGraphFetched']) query[method] = jest.fn(() => query); - query.first = jest.fn().mockResolvedValue(value); - query.then = jest.fn((resolve, reject) => Promise.resolve(value).then(resolve, reject)); - return query; -} - -const mockLoadKubeConfig = jest.fn(); -const mockMakeApiClient = jest.fn(); -const mockListEvents = jest.fn(); -const mockReadSecret = jest.fn(); -const mockListConfigMaps = jest.fn(); -const mockGetNativeBuildJobs = jest.fn(); -const mockGetDeploymentJobs = jest.fn(); -const mockDeployQuery = jest.fn(); -const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); -const mockLogger = { - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; - -const mockCoreApi = { - listNamespacedEvent: (...args: unknown[]) => mockListEvents(...args), - readNamespacedSecret: (...args: unknown[]) => mockReadSecret(...args), - listNamespacedConfigMap: (...args: unknown[]) => mockListConfigMaps(...args), -}; - -jest.mock('@kubernetes/client-node', () => ({ - KubeConfig: jest.fn(() => ({ - loadFromDefault: (...args: unknown[]) => mockLoadKubeConfig(...args), - makeApiClient: (...args: unknown[]) => mockMakeApiClient(...args), - })), - CoreV1Api: class CoreV1Api {}, - HttpError: class HttpError extends Error { - response: { statusCode: number }; - - constructor(response: { statusCode: number }, _body: unknown, statusCode = response.statusCode) { - super(`Kubernetes ${statusCode}`); - this.response = { statusCode }; - } - }, -})); - -jest.mock('server/lib/kubernetes/getNativeBuildJobs', () => ({ - getNativeBuildJobs: (...args: unknown[]) => mockGetNativeBuildJobs(...args), -})); - -jest.mock('server/lib/kubernetes/getDeploymentJobs', () => ({ - getDeploymentJobs: (...args: unknown[]) => mockGetDeploymentJobs(...args), -})); - -jest.mock('server/models', () => ({ - Deploy: { query: (...args: unknown[]) => mockDeployQuery(...args) }, -})); - -jest.mock('server/lib/logger', () => ({ - withLogContext: (...args: unknown[]) => mockWithLogContext(...(args as [unknown, () => unknown])), - getLogger: () => mockLogger, -})); - -let eventsHandler: typeof import('src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events').default; -let buildLogsHandler: typeof import('src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs').default; -let deployLogsHandler: typeof import('src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs').default; -let deploymentHandler: typeof import('src/pages/api/v1/builds/[uuid]/services/[name]/deployment').default; -let HttpError: typeof import('@kubernetes/client-node').HttpError; - -const kubernetesError = (statusCode: number) => - new HttpError({ statusCode } as import('http').IncomingMessage, undefined, statusCode); - -beforeAll(() => { - mockMakeApiClient.mockReturnValue(mockCoreApi); - HttpError = require('@kubernetes/client-node').HttpError; - eventsHandler = require('src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events').default; - buildLogsHandler = require('src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs').default; - deployLogsHandler = require('src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs').default; - deploymentHandler = require('src/pages/api/v1/builds/[uuid]/services/[name]/deployment').default; -}); - -describe('legacy Kubernetes-backed build routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockMakeApiClient.mockReturnValue(mockCoreApi); - mockWithLogContext.mockImplementation((_context: unknown, callback: () => unknown) => callback()); - mockListEvents.mockResolvedValue({ body: { items: [] } }); - mockGetNativeBuildJobs.mockResolvedValue([]); - mockGetDeploymentJobs.mockResolvedValue([]); - mockReadSecret.mockResolvedValue({ body: { data: {} } }); - mockListConfigMaps.mockResolvedValue({ body: { items: [] } }); - mockDeployQuery.mockReturnValue(queryResult(undefined)); - }); - - describe('GET /builds/:uuid/jobs/:jobName/events', () => { - it('rejects unsupported methods and advertises GET', async () => { - const res = response(); - await eventsHandler(request({ method: 'POST', query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET']); - expect(res.statusCode).toBe(405); - expect(mockListEvents).not.toHaveBeenCalled(); - }); - - it.each([ - [{ jobName: 'job-1' }], - [{ uuid: 'build-1' }], - [{ uuid: ['build-1'], jobName: 'job-1' }], - [{ uuid: 'build-1', jobName: ['job-1'] }], - ])('requires string UUID and job name: %j', async (query) => { - const res = response(); - await eventsHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(mockListEvents).not.toHaveBeenCalled(); - }); - - it('filters job/pod events, normalizes missing fields, and sorts newest first', async () => { - mockListEvents.mockResolvedValueOnce({ - body: { - items: [ - { involvedObject: undefined }, - { involvedObject: { kind: 'Service', name: 'job-1' } }, - { involvedObject: { kind: 'Job', name: 'other-job' } }, - { involvedObject: { kind: 'Pod', name: undefined } }, - { - involvedObject: { kind: 'Job', name: 'job-1' }, - metadata: { name: 'created', namespace: 'env-build-1' }, - reason: 'Created', - message: 'Job created', - type: 'Normal', - count: 2, - lastTimestamp: '2026-01-01T00:00:00.000Z', - source: { component: 'job-controller', host: 'node-1' }, - }, - { - involvedObject: { kind: 'Pod', name: 'job-1-abc' }, - metadata: {}, - eventTime: '2026-01-02T00:00:00.000Z', - }, - { - involvedObject: { kind: 'Pod', name: 'job-1-old' }, - }, - ], - }, - }); - const res = response(); - await eventsHandler(request({ query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect(mockListEvents).toHaveBeenCalledWith('env-build-1'); - expect(res.body).toEqual({ - events: [ - expect.objectContaining({ name: '', type: 'Normal', count: 1, eventTime: '2026-01-02T00:00:00.000Z' }), - expect.objectContaining({ name: 'created', reason: 'Created', count: 2 }), - expect.objectContaining({ name: '', namespace: '', reason: '', message: '', type: 'Normal', count: 1 }), - ], - }); - expect((res.body as { events: Array<{ source?: unknown }> }).events[0].source).toBeUndefined(); - expect((res.body as { events: Array<{ source?: unknown }> }).events[1].source).toEqual({ - component: 'job-controller', - host: 'node-1', - }); - }); - - it('treats an absent Kubernetes item list as empty', async () => { - mockListEvents.mockResolvedValueOnce({ body: { items: undefined } }); - const res = response(); - await eventsHandler(request({ query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect(res.body).toEqual({ events: [] }); - }); - - it.each([ - [ - [ - { involvedObject: { kind: 'Pod', name: 'job-1-event' }, eventTime: '2026-01-02T00:00:00.000Z' }, - { involvedObject: { kind: 'Pod', name: 'job-1-last' }, lastTimestamp: '2026-01-01T00:00:00.000Z' }, - ], - ], - [ - [ - { involvedObject: { kind: 'Pod', name: 'job-1-undated' } }, - { involvedObject: { kind: 'Pod', name: 'job-1-last' }, lastTimestamp: '2026-01-01T00:00:00.000Z' }, - ], - ], - ])('sorts correctly when the comparator right side uses fallback timestamps', async (items) => { - mockListEvents.mockResolvedValueOnce({ body: { items } }); - const res = response(); - await eventsHandler(request({ query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect((res.body as { events: unknown[] }).events).toHaveLength(2); - }); - - it.each([ - [() => kubernetesError(404), 404, { error: 'Environment or job not found.' }], - [() => kubernetesError(403), 502, { error: 'Failed to communicate with Kubernetes.' }], - [ - () => { - const error = kubernetesError(403); - delete (error as unknown as { response?: unknown }).response; - return error; - }, - 502, - { error: 'Failed to communicate with Kubernetes.' }, - ], - [() => new Error('unexpected'), 500, { error: 'Internal server error occurred.' }], - ])('maps Kubernetes and internal failures', async (makeError, status, body) => { - mockListEvents.mockRejectedValueOnce(makeError()); - const res = response(); - await eventsHandler(request({ query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - }); - - describe.each([ - ['build logs', () => buildLogsHandler, mockGetNativeBuildJobs, 'builds'], - ['deploy logs', () => deployLogsHandler, mockGetDeploymentJobs, 'deployments'], - ])('GET service %s list', (_name, getHandler, fetchJobs, responseKey) => { - it('rejects unsupported methods and advertises GET', async () => { - const res = response(); - await getHandler()(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET']); - expect(res.statusCode).toBe(405); - expect(fetchJobs).not.toHaveBeenCalled(); - }); - - it.each([ - [{ name: 'api' }], - [{ uuid: 'build-1' }], - [{ uuid: ['build-1'], name: 'api' }], - [{ uuid: 'build-1', name: ['api'] }], - ])('requires string UUID and service name: %j', async (query) => { - const res = response(); - await getHandler()(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(fetchJobs).not.toHaveBeenCalled(); - }); - - it('returns jobs for the service namespace', async () => { - const jobs = [{ name: 'job-1', status: 'Complete' }]; - fetchJobs.mockResolvedValueOnce(jobs); - const res = response(); - await getHandler()(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(fetchJobs).toHaveBeenCalledWith('api', 'env-build-1'); - expect(res.body).toEqual({ [responseKey]: jobs }); - }); - - it.each([ - [() => kubernetesError(404), 404, { error: 'Environment or service not found.' }], - [() => kubernetesError(403), 502, { error: 'Failed to communicate with Kubernetes.' }], - [ - () => { - const error = kubernetesError(403); - delete (error as unknown as { response?: unknown }).response; - return error; - }, - 502, - { error: 'Failed to communicate with Kubernetes.' }, - ], - [() => new Error('unexpected'), 500, { error: 'Internal server error occurred.' }], - ])('maps Kubernetes and internal failures', async (makeError, status, body) => { - fetchJobs.mockRejectedValueOnce(makeError()); - const res = response(); - await getHandler()(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - }); - - describe('GET /builds/:uuid/services/:name/deployment', () => { - const jsonRelease = { - name: 'api-build-1', - chart: { metadata: { name: 'api-chart', version: '1.2.3' } }, - config: { replicas: 2 }, - manifest: 'kind: Deployment', - }; - - it('rejects methods and malformed params before querying Kubernetes', async () => { - const method = response(); - await deploymentHandler(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), method); - expect(method.statusCode).toBe(405); - - for (const query of [{ name: 'api' }, { uuid: 'build-1' }, { uuid: ['build-1'], name: 'api' }]) { - const res = response(); - await deploymentHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - } - expect(mockReadSecret).not.toHaveBeenCalled(); - }); - - it('returns a double-base64 gzip Helm release', async () => { - const encoded = Buffer.from(gzipSync(Buffer.from(JSON.stringify(jsonRelease))).toString('base64')).toString( - 'base64' - ); - mockReadSecret.mockResolvedValueOnce({ body: { data: { release: encoded } } }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(mockReadSecret).toHaveBeenCalledWith('sh.helm.release.v1.api-build-1.v1', 'env-build-1'); - expect(res.body).toEqual({ - type: 'helm', - releaseName: 'api-build-1', - chart: 'api-chart', - version: '1.2.3', - values: { replicas: 2 }, - manifest: 'kind: Deployment', - }); - expect(mockListConfigMaps).not.toHaveBeenCalled(); - }); - - it('falls back to plain JSON Helm data and applies output defaults', async () => { - const release = { name: 'api-build-1', chart: {}, config: null }; - mockReadSecret.mockResolvedValueOnce({ - body: { data: { release: Buffer.from(JSON.stringify(release)).toString('base64') } }, - }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.body).toEqual({ - type: 'helm', - releaseName: 'api-build-1', - chart: 'unknown', - version: undefined, - values: {}, - manifest: undefined, - }); - }); - - it('returns a manifest ConfigMap when Helm data is absent', async () => { - mockListConfigMaps.mockResolvedValueOnce({ - body: { - items: [ - { metadata: undefined }, - { metadata: { name: 'other' }, data: { 'manifest.yaml': 'ignored' } }, - { metadata: { name: 'api-manifest' }, data: { 'manifest.yaml': 'kind: Service' } }, - ], - }, - }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(mockListConfigMaps).toHaveBeenCalledWith( - 'env-build-1', - undefined, - undefined, - undefined, - undefined, - 'deploy_uuid=api-build-1,app=lifecycle-deploy' - ); - expect(res.body).toEqual({ - type: 'github', - manifestConfigMap: 'api-manifest', - manifest: 'kind: Service', - }); - expect(mockDeployQuery).not.toHaveBeenCalled(); - }); - - it('treats a Helm secret body without data as absent', async () => { - mockReadSecret.mockResolvedValueOnce({ body: {} }); - mockListConfigMaps.mockResolvedValueOnce({ - body: { items: [{ metadata: { name: 'api-manifest' }, data: { 'manifest.yaml': 'kind: Service' } }] }, - }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.body).toEqual(expect.objectContaining({ type: 'github' })); - }); - - it.each([ - [[{ metadata: { name: 'api-manifest' }, data: undefined }], { manifest: 'kind: Deployment' }], - [[], { manifest: 'kind: Deployment' }], - ])('falls back to the database manifest when ConfigMap data is unavailable', async (items, deploy) => { - mockListConfigMaps.mockResolvedValueOnce({ body: { items } }); - const deployQuery = queryResult(deploy); - mockDeployQuery.mockReturnValueOnce(deployQuery); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(deployQuery.where).toHaveBeenCalledWith('uuid', 'api-build-1'); - expect(res.body).toEqual({ - type: 'github', - manifestConfigMap: 'stored-in-database', - manifest: 'kind: Deployment', - }); - }); - - it.each([undefined, {}, { manifest: '' }])('returns 404 when no manifest exists: %j', async (deploy) => { - mockDeployQuery.mockReturnValueOnce(queryResult(deploy)); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(404); - expect(res.body).toEqual({ error: 'Deployment not found' }); - }); - - it('treats Helm and ConfigMap 404 responses as not found', async () => { - mockReadSecret.mockRejectedValueOnce(kubernetesError(404)); - mockListConfigMaps.mockRejectedValueOnce(kubernetesError(404)); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(404); - }); - - it.each([ - [() => kubernetesError(403), 502, { error: 'Failed to communicate with Kubernetes' }], - [() => new Error('unexpected'), 500, { error: 'Internal server error' }], - ])('maps upstream and internal failures', async (makeError, status, body) => { - mockReadSecret.mockRejectedValueOnce(makeError()); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - - it('maps a non-404 ConfigMap failure after Helm lookup to 502', async () => { - mockListConfigMaps.mockRejectedValueOnce(kubernetesError(403)); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(502); - expect(res.body).toEqual({ error: 'Failed to communicate with Kubernetes' }); - }); - - it.each(['secret', 'config-map'])( - 'maps a Kubernetes error without response metadata from %s lookup', - async (source) => { - const error = kubernetesError(403); - delete (error as unknown as { response?: unknown }).response; - if (source === 'secret') mockReadSecret.mockRejectedValueOnce(error); - else mockListConfigMaps.mockRejectedValueOnce(error); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(502); - } - ); - - it('continues to GitHub lookup after malformed Helm release data', async () => { - mockReadSecret.mockResolvedValueOnce({ - body: { data: { release: Buffer.from('not-json').toString('base64') } }, - }); - mockListConfigMaps.mockResolvedValueOnce({ - body: { items: [{ metadata: { name: 'api-manifest' }, data: { 'manifest.yaml': 'kind: Pod' } }] }, - }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.body).toEqual(expect.objectContaining({ type: 'github', manifest: 'kind: Pod' })); - expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to parse Helm release data')); - }); - }); -}); diff --git a/src/__tests__/pages-api/logRoutes.test.ts b/src/__tests__/pages-api/logRoutes.test.ts deleted file mode 100644 index ab053efa..00000000 --- a/src/__tests__/pages-api/logRoutes.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { EventEmitter } from 'events'; -import { request, response } from 'src/test-utils/pagesApi'; - -type Query = Record & PromiseLike; - -function queryResult(value: T): Query { - const query = {} as Query; - query.findOne = jest.fn(() => query); - query.then = jest.fn((resolve, reject) => Promise.resolve(value).then(resolve, reject)); - return query; -} - -function childProcess() { - const process = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - kill: jest.Mock; - }; - process.stdout = new EventEmitter(); - process.stderr = new EventEmitter(); - process.kill = jest.fn(); - return process; -} - -const mockGetLogStreamInfo = jest.fn(); -const mockGithubBuildQuery = jest.fn(); -const mockExec = jest.fn(); -const mockSpawn = jest.fn(); -const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; - -jest.mock('server/services/logStreaming', () => ({ - LogStreamingService: jest.fn(() => ({ - getLogStreamInfo: (...args: unknown[]) => mockGetLogStreamInfo(...args), - })), -})); - -jest.mock('server/services/github', () => ({ - __esModule: true, - default: jest.fn(() => ({ - db: { models: { Build: { query: (...args: unknown[]) => mockGithubBuildQuery(...args) } } }, - })), -})); - -jest.mock('child_process', () => ({ - exec: (...args: unknown[]) => mockExec(...args), - spawn: (...args: unknown[]) => mockSpawn(...args), -})); - -jest.mock('@kubernetes/client-node', () => ({ - HttpError: class HttpError extends Error { - response: { statusCode: number }; - - constructor(response: { statusCode: number }, _body: unknown, statusCode = response.statusCode) { - super(`Kubernetes ${statusCode}`); - this.response = { statusCode }; - } - }, -})); - -jest.mock('server/lib/logger', () => ({ - withLogContext: (...args: unknown[]) => mockWithLogContext(...(args as [unknown, () => unknown])), - getLogger: () => mockLogger, -})); - -import unifiedLogHandler from 'src/pages/api/v1/builds/[uuid]/services/[name]/logs/[jobName]'; -import buildLogProxy from 'src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs/[jobName]'; -import deployLogProxy from 'src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs/[jobName]'; -import webhookLogProxy from 'src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs'; -import serviceLogsHandler, { config as serviceLogsConfig } from 'src/pages/api/v1/builds/[uuid]/services/[name]/logs'; -import { HttpError } from '@kubernetes/client-node'; - -const kubernetesError = (statusCode: number) => - new HttpError({ statusCode } as import('http').IncomingMessage, undefined, statusCode); - -describe('legacy build log routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockWithLogContext.mockImplementation((_context: unknown, callback: () => unknown) => callback()); - mockGetLogStreamInfo.mockResolvedValue({ source: 'kubernetes', streamUrl: '/logs' }); - mockGithubBuildQuery.mockReturnValue(queryResult({ namespace: 'env-custom' })); - mockExec.mockImplementation((_command: string, callback: (error: unknown, result?: unknown) => void) => - callback(null, { stdout: 'pod-a\n', stderr: '' }) - ); - mockSpawn.mockImplementation(() => childProcess()); - }); - - describe('unified job log metadata handler', () => { - it('rejects unsupported methods and advertises GET', async () => { - const res = response(); - await unifiedLogHandler( - request({ method: 'POST', query: { uuid: 'build-1', name: 'api', jobName: 'job-1' } }), - res - ); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET']); - expect(res.statusCode).toBe(405); - expect(mockGetLogStreamInfo).not.toHaveBeenCalled(); - }); - - it.each([ - [{ name: 'api', jobName: 'job-1' }], - [{ uuid: 'build-1', name: 'api' }], - [{ uuid: ['build-1'], name: 'api', jobName: 'job-1' }], - [{ uuid: 'build-1', name: ['api'], jobName: 'job-1' }], - [{ uuid: 'build-1', jobName: 'job-1' }], - ])('requires valid route params for non-webhook streams: %j', async (query) => { - const res = response(); - await unifiedLogHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(mockGetLogStreamInfo).not.toHaveBeenCalled(); - }); - - it.each([ - [{ uuid: 'build-1', name: 'api', jobName: 'job-1', type: ['build'] }], - [{ uuid: 'build-1', name: 'api', jobName: 'job-1', type: 'unknown' }], - ])('rejects invalid log types: %j', async (query) => { - const res = response(); - await unifiedLogHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'Invalid type parameter. Must be "build", "deploy", or "webhook"' }); - }); - - it.each([ - [{ uuid: 'build-1', name: 'api', jobName: 'job-1' }, ['build-1', 'job-1', 'api', undefined]], - [{ uuid: 'build-1', name: 'api', jobName: 'job-1', type: 'build' }, ['build-1', 'job-1', 'api', 'build']], - [{ uuid: 'build-1', name: 'api', jobName: 'job-1', type: 'deploy' }, ['build-1', 'job-1', 'api', 'deploy']], - [{ uuid: 'build-1', jobName: 'job-1', type: 'webhook' }, ['build-1', 'job-1', undefined, 'webhook']], - ])('returns stream metadata for normalized inputs: %j', async (query, expectedArgs) => { - const res = response(); - await unifiedLogHandler(request({ query }), res); - expect(mockGetLogStreamInfo).toHaveBeenCalledWith(...expectedArgs); - expect(res.body).toEqual({ source: 'kubernetes', streamUrl: '/logs' }); - }); - - it.each([ - [new Error('Build not found'), 404, { error: 'Build not found' }], - [kubernetesError(403), 502, { error: 'Failed to communicate with Kubernetes.' }], - [new Error('Kubernetes request failed'), 502, { error: 'Failed to communicate with Kubernetes.' }], - [ - Object.assign(new Error('gateway'), { statusCode: 502 }), - 502, - { error: 'Failed to communicate with Kubernetes.' }, - ], - [{ statusCode: 502 }, 502, { error: 'Failed to communicate with Kubernetes.' }], - [new Error('unexpected'), 500, { error: 'Internal server error occurred.' }], - ])('maps stream metadata failures: %s', async (error, status, body) => { - mockGetLogStreamInfo.mockRejectedValueOnce(error); - const res = response(); - await unifiedLogHandler( - request({ query: { uuid: 'build-1', name: 'api', jobName: 'job-1', type: 'build' } }), - res - ); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - }); - - describe('typed log proxy routes', () => { - it.each([ - ['build', buildLogProxy, { uuid: 'build-1', name: 'api', jobName: 'build-job' }, 'build', 'api'], - ['deploy', deployLogProxy, { uuid: 'build-1', name: 'api', jobName: 'deploy-job' }, 'deploy', 'api'], - ['webhook', webhookLogProxy, { uuid: 'build-1', name: 'ignored', jobName: 'webhook-job' }, 'webhook', undefined], - ])('sets %s type before delegating to the unified behavior', async (_label, handler, query, type, name) => { - const req = request({ query }); - const res = response(); - await handler(req, res); - expect(req.query.type).toBe(type); - expect(req.query.name).toBe(name); - expect(mockGetLogStreamInfo).toHaveBeenCalledWith('build-1', query.jobName, name, type); - expect(res.statusCode).toBe(200); - }); - }); - - describe('service pod SSE logs', () => { - beforeEach(() => jest.useFakeTimers()); - afterEach(() => jest.useRealTimers()); - - it('disables Next.js body parsing for SSE requests', () => { - expect(serviceLogsConfig).toEqual({ api: { bodyParser: false } }); - }); - - it('answers OPTIONS without querying build or pods', async () => { - const res = response(); - await serviceLogsHandler(request({ method: 'OPTIONS' }), res); - expect(res.statusCode).toBe(200); - expect(res.end).toHaveBeenCalled(); - expect(mockGithubBuildQuery).not.toHaveBeenCalled(); - }); - - it('rejects unsupported methods and malformed path params', async () => { - const method = response(); - await serviceLogsHandler(request({ method: 'POST' }), method); - expect(method.statusCode).toBe(405); - - for (const query of [ - {}, - { uuid: 'build-1' }, - { name: 'api' }, - { uuid: ['build-1'], name: 'api' }, - { uuid: 'build-1', name: ['api'] }, - ]) { - const res = response(); - await serviceLogsHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - } - expect(mockGithubBuildQuery).not.toHaveBeenCalled(); - }); - - it.each(['sidecar', ['app']])('rejects unsupported container type: %j', async (containerType) => { - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api', containerType } }), res); - expect(res.statusCode).toBe(400); - expect(mockGithubBuildQuery).not.toHaveBeenCalled(); - }); - - it('opens SSE headers, uses the build namespace, and reports no matching pods', async () => { - mockExec.mockImplementationOnce((_command, callback) => callback(null, { stdout: '\n', stderr: '' })); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.writeHead).toHaveBeenCalledWith(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - 'Access-Control-Allow-Origin': '*', - }); - expect(mockExec).toHaveBeenCalledWith( - 'kubectl get pods --namespace env-custom -l app.kubernetes.io/instance=api-build-1 -o jsonpath="{range .items[*]}{.metadata.name}{\'\\n\'}{end}"', - expect.any(Function) - ); - expect(res.chunks).toContain('data: No pods found for deployment "api-build-1" in namespace "env-custom"\n\n'); - expect(res.end).toHaveBeenCalled(); - }); - - it.each([ - [undefined, "Cannot read properties of undefined (reading 'namespace')"], - [{}, 'Deployment namespace not configured'], - ])('reports a missing deployment namespace as SSE error: %j', async (build, message) => { - mockGithubBuildQuery.mockReturnValueOnce(queryResult(build)); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.chunks).toContain(`data: Error: ${message}\n\n`); - expect(mockExec).not.toHaveBeenCalled(); - expect(res.end).toHaveBeenCalled(); - }); - - it('reports kubectl stderr without spawning log followers', async () => { - mockExec.mockImplementationOnce((_command, callback) => callback(null, { stdout: '', stderr: 'forbidden' })); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.chunks).toContain('data: Error: Failed to retrieve pods: forbidden\n\n'); - expect(mockSpawn).not.toHaveBeenCalled(); - expect(res.end).toHaveBeenCalled(); - }); - - it('streams at most five app pod processes and forwards process events', async () => { - mockExec.mockImplementationOnce((_command, callback) => - callback(null, { stdout: 'pod-a\npod-b\npod-c\npod-d\npod-e\npod-f\n', stderr: '' }) - ); - const processes = Array.from({ length: 5 }, () => childProcess()); - processes.forEach((process) => mockSpawn.mockReturnValueOnce(process)); - const req = request({ query: { uuid: 'build-1', name: 'api', containerType: 'app' } }); - const res = response(); - await serviceLogsHandler(req, res); - - expect(mockSpawn).toHaveBeenCalledTimes(5); - expect(mockSpawn).toHaveBeenNthCalledWith(1, 'kubectl', [ - 'logs', - 'pod-a', - '--namespace', - 'env-custom', - '-f', - '--tail=100', - '--all-containers=true', - '--prefix=true', - ]); - - processes[0].stdout.emit('data', Buffer.from('line one\npartial')); - processes[0].stdout.emit('data', Buffer.from(' line\n\n')); - processes[0].stderr.emit('data', Buffer.from('stderr text')); - processes[0].emit('error', new Error('spawn failed')); - processes[0].stdout.emit('data', Buffer.from('tail without newline')); - processes[0].emit('close', 0); - expect(res.chunks).toEqual( - expect.arrayContaining([ - 'data: line one\n\n', - 'data: partial line\n\n', - 'data: Error: stderr text\n\n', - 'data: Process error: spawn failed\n\n', - 'data: tail without newline\n\n', - 'data: Log streaming ended with code 0\n\n', - ]) - ); - - const closeCallback = req.on.mock.calls.find(([event]) => event === 'close')?.[1]; - closeCallback(); - processes.forEach((process) => expect(process.kill).toHaveBeenCalled()); - }); - - it('targets the init container when requested', async () => { - const process = childProcess(); - mockSpawn.mockReturnValueOnce(process); - const req = request({ query: { uuid: 'build-1', name: 'api', containerType: 'init' } }); - await serviceLogsHandler(req, response()); - expect(mockSpawn).toHaveBeenCalledWith('kubectl', [ - 'logs', - 'pod-a', - '--namespace', - 'env-custom', - '-f', - '--tail=100', - '--container=init-container', - ]); - const closeCallback = req.on.mock.calls.find(([event]) => event === 'close')?.[1]; - closeCallback(); - }); - - it('kills active processes and ends the response after the one-hour timeout', async () => { - const process = childProcess(); - mockSpawn.mockReturnValueOnce(process); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - jest.advanceTimersByTime(3_600_000); - expect(process.kill).toHaveBeenCalled(); - expect(res.end).toHaveBeenCalled(); - }); - - it('stringifies a non-Error kubectl rejection in the SSE error', async () => { - mockExec.mockImplementationOnce((_command, callback) => callback('plain failure')); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.chunks).toContain('data: Error: plain failure\n\n'); - expect(res.end).toHaveBeenCalled(); - }); - }); -}); diff --git a/src/__tests__/pages-api/operationalRoutes.test.ts b/src/__tests__/pages-api/operationalRoutes.test.ts deleted file mode 100644 index 605520b6..00000000 --- a/src/__tests__/pages-api/operationalRoutes.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -const mockGetAllConfigs = jest.fn(); -const mockVerifyBearerToken = jest.fn(); -jest.mock('server/lib/auth', () => ({ verifyBearerToken: (...args: unknown[]) => mockVerifyBearerToken(...args) })); -const mockTTLQueueAdd = jest.fn(); -const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); -const mockNanoid = jest.fn(() => 'fixed-id'); -const mockLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; - -jest.mock('server/services/globalConfig', () => { - const instance = { getAllConfigs: (...args: unknown[]) => mockGetAllConfigs(...args) }; - const Service = jest.fn(() => instance) as jest.Mock & { getInstance: jest.Mock }; - Service.getInstance = jest.fn(() => instance); - return { __esModule: true, default: Service }; -}); - -jest.mock('server/services/ttlCleanup', () => ({ - __esModule: true, - default: jest.fn(() => ({ ttlCleanupQueue: { add: (...args: unknown[]) => mockTTLQueueAdd(...args) } })), -})); - -jest.mock('server/lib/logger', () => ({ - getLogger: () => mockLogger, - withLogContext: (...args: unknown[]) => mockWithLogContext(...(args as [unknown, () => unknown])), - LogStage: { CLEANUP_STARTING: 'cleanup-starting', CLEANUP_FAILED: 'cleanup-failed' }, -})); - -jest.mock('nanoid', () => ({ nanoid: () => mockNanoid() })); - -import ttlHandler from 'src/pages/api/v1/admin/ttl/cleanup'; -import cacheHandler from 'src/pages/api/v1/config/cache'; - -describe('legacy operational API routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockWithLogContext.mockImplementation((_context: unknown, callback: () => unknown) => callback()); - mockGetAllConfigs.mockResolvedValue({ ttl_cleanup: { enabled: true, maxAgeDays: 7 } }); - mockTTLQueueAdd.mockResolvedValue({ id: 'job-1' }); - }); - - describe('/admin/ttl/cleanup', () => { - it('advertises allowed methods for unsupported requests', async () => { - const res = response(); - await ttlHandler(request({ method: 'DELETE' }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET', 'POST']); - expect(res.statusCode).toBe(405); - }); - - it('returns TTL configuration and distinguishes missing configuration', async () => { - const success = response(); - await ttlHandler(request(), success); - expect(success.body).toEqual({ config: { enabled: true, maxAgeDays: 7 } }); - - mockGetAllConfigs.mockResolvedValueOnce({}); - const missing = response(); - await ttlHandler(request(), missing); - expect(missing.statusCode).toBe(404); - expect(missing.body).toEqual({ error: 'TTL cleanup configuration not found' }); - }); - - it('maps config-service failures to the route-specific error', async () => { - mockGetAllConfigs.mockRejectedValueOnce(new Error('config unavailable')); - const res = response(); - await ttlHandler(request(), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to retrieve TTL cleanup configuration' }); - }); - - it.each([ - [undefined, false], - [{}, false], - [{ dryRun: true }, true], - ])('queues manual cleanup with normalized dryRun: %j', async (body, dryRun) => { - const res = response(); - await ttlHandler(request({ method: 'POST', body }), res); - expect(mockTTLQueueAdd).toHaveBeenCalledWith('manual-ttl-cleanup', { - dryRun, - correlationId: expect.stringMatching(/^api-ttl-cleanup-\d+-fixed-id$/), - }); - expect(res.body).toEqual({ - message: 'TTL cleanup job triggered successfully', - jobId: 'job-1', - dryRun, - }); - }); - - it('rejects non-boolean dryRun before creating a queue job', async () => { - const res = response(); - await ttlHandler(request({ method: 'POST', body: { dryRun: 'true' } }), res); - expect(res.statusCode).toBe(400); - expect(mockTTLQueueAdd).not.toHaveBeenCalled(); - }); - - it('maps queue failures to the trigger-specific error', async () => { - mockTTLQueueAdd.mockRejectedValueOnce(new Error('queue unavailable')); - const res = response(); - await ttlHandler(request({ method: 'POST', body: { dryRun: false } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to trigger TTL cleanup job' }); - }); - - it('currently propagates failures entering log context because the inner promise is returned', async () => { - mockWithLogContext.mockImplementationOnce(() => { - throw new Error('context unavailable'); - }); - const res = response(); - await expect(ttlHandler(request({ method: 'POST' }), res)).rejects.toThrow('context unavailable'); - expect(res.status).not.toHaveBeenCalled(); - }); - - it('maps synchronous routing failures to the outer stable error', async () => { - const res = response(); - (res.setHeader as jest.Mock).mockImplementationOnce(() => { - throw new Error('response unavailable'); - }); - await ttlHandler(request({ method: 'DELETE' }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred.' }); - }); - }); - - describe('/config/cache', () => { - const priorAuth = process.env.ENABLE_AUTH; - const priorIssuer = process.env.KEYCLOAK_ISSUER; - const adminRequest = (overrides: Parameters[0] = {}) => - request({ ...overrides, headers: { authorization: 'Bearer verified-test-token' } }); - beforeEach(() => { - process.env.ENABLE_AUTH = 'true'; - process.env.KEYCLOAK_ISSUER = 'https://idp.test/realms/lifecycle'; - mockVerifyBearerToken.mockResolvedValue({ - success: true, - payload: { sub: 'admin-1', iss: 'https://idp.test/realms/lifecycle', realm_access: { roles: ['admin'] } }, - }); - }); - afterAll(() => { - if (priorAuth === undefined) delete process.env.ENABLE_AUTH; - else process.env.ENABLE_AUTH = priorAuth; - if (priorIssuer === undefined) delete process.env.KEYCLOAK_ISSUER; - else process.env.KEYCLOAK_ISSUER = priorIssuer; - }); - it('denies ordinary/missing identity before loading configuration', async () => { - const res = response(); - await cacheHandler(request(), res); - expect(res.statusCode).toBe(403); - expect(mockGetAllConfigs).not.toHaveBeenCalled(); - }); - it('denies auth-off even with cached admin claims', async () => { - process.env.ENABLE_AUTH = 'false'; - const res = response(); - await cacheHandler(adminRequest(), res); - expect(res.statusCode).toBe(403); - expect(mockGetAllConfigs).not.toHaveBeenCalled(); - }); - it.each([ - ['GET', false], - ['PUT', true], - ])('%s returns cached configuration with the expected refresh flag', async (method, refresh) => { - mockGetAllConfigs.mockResolvedValueOnce({ feature: 'value' }); - const res = response(); - await cacheHandler(adminRequest({ method }), res); - expect(mockGetAllConfigs).toHaveBeenCalledWith(refresh); - expect(res.body).toEqual({ configs: { feature: 'value' } }); - }); - - it('advertises allowed methods', async () => { - const res = response(); - await cacheHandler(adminRequest({ method: 'POST' }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET', 'PUT']); - expect(res.statusCode).toBe(405); - }); - - it('maps config retrieval failures to the route-specific error', async () => { - mockGetAllConfigs.mockRejectedValueOnce(new Error('config unavailable')); - const res = response(); - await cacheHandler(adminRequest(), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to retrieve global config values' }); - }); - - it('ignores forged x-user admin claims even with an invalid bearer', async () => { - mockVerifyBearerToken.mockResolvedValue({ success: false }); - const res = response(); - await cacheHandler( - request({ - headers: { - authorization: 'Bearer forged', - 'x-user': Buffer.from( - JSON.stringify({ - sub: 'admin-1', - iss: 'https://idp.test/realms/lifecycle', - realm_access: { roles: ['admin'] }, - }) - ).toString('base64'), - }, - }), - res - ); - expect(res.statusCode).toBe(403); - expect(mockGetAllConfigs).not.toHaveBeenCalled(); - expect(mockVerifyBearerToken).toHaveBeenCalledWith('forged'); - }); - it('denies malformed claims before configuration reads', async () => { - const res = response(); - await cacheHandler(request({ headers: { 'x-user': 'malformed' } }), res); - expect(res.statusCode).toBe(403); - expect(mockGetAllConfigs).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/__tests__/pages-api/resourceRoutes.test.ts b/src/__tests__/pages-api/resourceRoutes.test.ts deleted file mode 100644 index d30565fb..00000000 --- a/src/__tests__/pages-api/resourceRoutes.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -type Query = Record & PromiseLike; - -function queryResult(value: T): Query { - const query = {} as Query; - for (const method of [ - 'select', - 'where', - 'whereNotIn', - 'whereNotNull', - 'whereIn', - 'orderBy', - 'distinct', - 'findById', - 'findOne', - 'withGraphFetched', - 'offset', - 'limit', - 'countDistinct', - ]) { - query[method] = jest.fn(() => query); - } - query.resultSize = jest.fn().mockResolvedValue(Array.isArray(value) ? value.length : 0); - query.first = jest.fn().mockResolvedValue(value); - query.then = jest.fn((resolve, reject) => Promise.resolve(value).then(resolve, reject)); - return query; -} - -const mockBuildQuery = jest.fn(); -const mockDeployQuery = jest.fn(); -const mockDeployableQuery = jest.fn(); -const mockPullRequestQuery = jest.fn(); -const mockRaw = jest.fn(); -const mockLogger = { - debug: jest.fn(), - error: jest.fn(), -}; - -const mockBuildService = { - db: { - models: { - Build: { query: (...args: unknown[]) => mockBuildQuery(...args) }, - Deploy: { query: (...args: unknown[]) => mockDeployQuery(...args) }, - Deployable: { query: (...args: unknown[]) => mockDeployableQuery(...args) }, - }, - knex: { raw: (...args: unknown[]) => mockRaw(...args) }, - }, -}; - -const mockPullRequestService = { - db: { - models: { - PullRequest: { query: (...args: unknown[]) => mockPullRequestQuery(...args) }, - }, - }, -}; - -jest.mock('server/services/build', () => ({ - __esModule: true, - default: jest.fn(() => mockBuildService), -})); - -jest.mock('server/services/pullRequest', () => ({ - __esModule: true, - default: jest.fn(() => mockPullRequestService), -})); - -jest.mock('server/lib/logger', () => ({ - getLogger: () => mockLogger, -})); - -import buildsHandler from 'src/pages/api/v1/builds'; -import deploySummaryHandler from 'src/pages/api/v1/deploy-summary'; -import deployablesHandler from 'src/pages/api/v1/deployables'; -import deploysHandler from 'src/pages/api/v1/deploys'; -import pullRequestBuildsHandler from 'src/pages/api/v1/pull-requests/[id]/builds'; -import pullRequestHandler from 'src/pages/api/v1/pull-requests/[id]'; -import pullRequestsHandler from 'src/pages/api/v1/pull-requests'; -import reposHandler from 'src/pages/api/v1/repos'; -import usersHandler from 'src/pages/api/v1/users'; - -describe('legacy read-only resource routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe.each([ - ['deploy summary', deploySummaryHandler], - ['deployables', deployablesHandler], - ['deploys', deploysHandler], - ])('GET /%s', (_name, handler) => { - it('rejects unsupported methods before creating a service', async () => { - const res = response(); - await handler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{}, 'Invalid build ID'], - [{ buildId: ['1'] }, 'Invalid build ID'], - [{ buildId: 'not-a-number' }, 'Invalid build ID'], - ])('validates buildId: %j', async (query, error) => { - const res = response(); - await handler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error }); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - }); - - describe('GET /deploy-summary', () => { - it('returns 404 without issuing the summary SQL when the build is absent', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await deploySummaryHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(404); - expect(mockRaw).not.toHaveBeenCalled(); - }); - - it('queries the summary view with the parsed build ID', async () => { - mockBuildQuery.mockReturnValue(queryResult({ id: 42 })); - mockRaw.mockResolvedValue({ rows: [{ name: 'api', status: 'ready' }] }); - const res = response(); - await deploySummaryHandler(request({ query: { buildId: '42' } }), res); - expect(mockRaw).toHaveBeenCalledWith(expect.stringContaining('FROM "deploySummary"'), [42]); - expect(res.body).toEqual([{ name: 'api', status: 'ready' }]); - }); - - it('returns a stable 500 response on query failure', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await deploySummaryHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred' }); - }); - }); - - describe('GET /deployables', () => { - it('returns 404 before querying deployables when the build is absent', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await deployablesHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(404); - expect(mockDeployableQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{ buildId: '42', name: 'api' }, true], - [{ buildId: '42' }, false], - [{ buildId: '42', name: ['api'] }, false], - ])('returns deployables and conditionally filters a string name: %j', async (params, filtered) => { - const buildQuery = queryResult({ id: 42 }); - const deployableQuery = queryResult([{ id: 7, name: 'api' }]); - mockBuildQuery.mockReturnValue(buildQuery); - mockDeployableQuery.mockReturnValue(deployableQuery); - const res = response(); - await deployablesHandler(request({ query: params }), res); - expect(deployableQuery.where).toHaveBeenCalledWith('buildId', 42); - if (filtered) expect(deployableQuery.where).toHaveBeenCalledWith('name', 'api'); - else expect(deployableQuery.where).not.toHaveBeenCalledWith('name', expect.anything()); - expect(res.body).toEqual([{ id: 7, name: 'api' }]); - }); - - it('returns a stable 500 response on query failure', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await deployablesHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('GET /deploys', () => { - it.each([ - { buildId: '42', deployableId: ['7'] }, - { buildId: '42', deployableId: 'invalid' }, - ])('rejects an invalid deployableId: %j', async (query) => { - const res = response(); - await deploysHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'Invalid deployable ID' }); - }); - - it('returns 404 before querying deploys when the build is absent', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await deploysHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(404); - expect(mockDeployQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{ buildId: '42', deployableId: '7' }, true], - [{ buildId: '42' }, false], - [{ buildId: '42', deployableId: '0' }, false], - ])('returns deploys and follows the current truthy parsed-ID filter: %j', async (params, filtered) => { - mockBuildQuery.mockReturnValue(queryResult({ id: 42 })); - const deployQuery = queryResult([{ id: 8, deployableId: 7 }]); - mockDeployQuery.mockReturnValue(deployQuery); - const res = response(); - await deploysHandler(request({ query: params }), res); - expect(deployQuery.where).toHaveBeenCalledWith('buildId', 42); - if (filtered) expect(deployQuery.where).toHaveBeenCalledWith('deployableId', 7); - else expect(deployQuery.where).not.toHaveBeenCalledWith('deployableId', expect.anything()); - expect(res.body).toEqual([{ id: 8, deployableId: 7 }]); - }); - - it('returns a stable 500 response on query failure', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await deploysHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe.each([ - ['pull request', pullRequestHandler], - ['pull request builds', pullRequestBuildsHandler], - ])('GET /%s/:id', (_name, handler) => { - it('rejects methods and malformed IDs before querying', async () => { - const methodRes = response(); - await handler(request({ method: 'POST' }), methodRes); - expect(methodRes.statusCode).toBe(405); - - for (const id of [undefined, ['1'], 'invalid']) { - const res = response(); - await handler(request({ query: id === undefined ? {} : { id } }), res); - expect(res.statusCode).toBe(400); - } - expect(mockPullRequestQuery).not.toHaveBeenCalled(); - }); - - it('returns 404 when the pull request does not exist', async () => { - mockPullRequestQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await handler(request({ query: { id: '17' } }), res); - expect(res.statusCode).toBe(404); - expect(res.body).toEqual({ error: 'Pull request not found' }); - }); - - it('maps lookup errors to the stable 500 response', async () => { - mockPullRequestQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await handler(request({ query: { id: '17' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - it('returns a pull request by parsed database ID', async () => { - const pullRequest = { id: 17, fullName: 'goodrx/lifecycle' }; - const query = queryResult(pullRequest); - mockPullRequestQuery.mockReturnValue(query); - const res = response(); - await pullRequestHandler(request({ query: { id: '17' } }), res); - expect(query.findById).toHaveBeenCalledWith(17); - expect(res.body).toEqual(pullRequest); - }); - - it('returns builds for an existing pull request', async () => { - mockPullRequestQuery.mockReturnValue(queryResult({ id: 17 })); - const builds = [{ id: 1, pullRequestId: 17 }]; - const buildQuery = queryResult(builds); - mockBuildQuery.mockReturnValue(buildQuery); - const res = response(); - await pullRequestBuildsHandler(request({ query: { id: '17' } }), res); - expect(buildQuery.where).toHaveBeenCalledWith('pullRequestId', 17); - expect(res.body).toEqual(builds); - }); - - describe('GET /builds', () => { - it('rejects unsupported methods', async () => { - const res = response(); - await buildsHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - }); - - it.each([ - [{}, ['torn_down', 'pending']], - [{ exclude: '["failed",7]' }, ['failed']], - [{ exclude: 'ready' }, ['ready']], - [{ exclude: '"ready"' }, ['torn_down', 'pending']], - ])('normalizes exclusion formats and returns all builds: %j', async (queryParams, exclusions) => { - const builds = [{ uuid: 'build-1', status: 'ready' }]; - const query = queryResult(builds); - mockBuildQuery.mockReturnValue(query); - const res = response(); - await buildsHandler(request({ query: queryParams }), res); - expect(query.whereNotIn).toHaveBeenCalledWith('status', exclusions); - expect(res.body).toEqual({ - builds, - metadata: { currentPage: 1, totalPages: 1, total: 1, limit: 1 }, - }); - }); - - it.each([ - [{ page: '2', limit: '5' }, { currentPage: 2, totalPages: 3, total: 12, limit: 5 }, 5], - [{ page: 'bad', limit: '0' }, { currentPage: 1, totalPages: 1, total: 12, limit: 20 }, 0], - ])('paginates with bounded numeric parameters: %j', async (queryParams, metadata, expectedOffset) => { - const builds = [{ uuid: 'build-1', status: 'ready' }]; - const query = queryResult(builds); - query.resultSize.mockResolvedValue(12); - mockBuildQuery.mockReturnValue(query); - const res = response(); - await buildsHandler(request({ query: queryParams }), res); - expect(query.offset).toHaveBeenCalledWith(expectedOffset); - expect(query.limit).toHaveBeenCalledWith(metadata.limit); - expect(res.body).toEqual({ builds, metadata }); - }); - - it('maps query failures to the stable 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await buildsHandler(request(), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('GET /pull-requests', () => { - it('rejects unsupported methods before querying', async () => { - const res = response(); - await pullRequestsHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - expect(mockPullRequestQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{}, undefined], - [{ exclude: '["closed",1]' }, ['closed']], - [{ exclude: 'closed' }, ['closed']], - [{ exclude: '"closed"' }, ['"closed"']], - ])('normalizes filters and paginates: %j', async (params, exclusions) => { - const rows = [{ id: 1, githubLogin: 'octocat' }]; - const query = queryResult(rows); - query.resultSize.mockResolvedValue(26); - mockPullRequestQuery.mockReturnValue(query); - const res = response(); - await pullRequestsHandler( - request({ query: { user: 'octocat', repo: 'goodrx/lifecycle', page: '2', limit: '25', ...params } }), - res - ); - expect(query.where).toHaveBeenCalledWith('githubLogin', 'octocat'); - expect(query.where).toHaveBeenCalledWith('fullName', 'goodrx/lifecycle'); - if (exclusions?.length) expect(query.whereNotIn).toHaveBeenCalledWith('status', exclusions); - else expect(query.whereNotIn).not.toHaveBeenCalled(); - expect(res.body).toEqual({ - pull_requests: rows, - metadata: { currentPage: 2, totalPages: 2, total: 26, limit: 25 }, - }); - }); - - it('ignores non-string user/repo and defaults malformed pagination', async () => { - const rows: unknown[] = []; - const query = queryResult(rows); - mockPullRequestQuery.mockReturnValue(query); - const res = response(); - await pullRequestsHandler( - request({ query: { user: ['octocat'], repo: ['repo'], page: 'bad', limit: '0' } }), - res - ); - expect(query.where).not.toHaveBeenCalled(); - expect(query.offset).toHaveBeenCalledWith(0); - expect(query.limit).toHaveBeenCalledWith(25); - }); - - it('maps query failures to the stable 500 response', async () => { - mockPullRequestQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await pullRequestsHandler(request(), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe.each([ - ['repos', reposHandler, 'fullName', 'repos'], - ['users', usersHandler, 'githubLogin', 'users'], - ])('GET /%s', (_route, handler, field, responseKey) => { - it('rejects unsupported methods', async () => { - const res = response(); - await handler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - }); - - it('returns all distinct values without a pagination query', async () => { - const base = queryResult([{ [field]: 'one' }, { [field]: 'two' }]); - mockPullRequestQuery.mockReturnValue(base); - const res = response(); - await handler(request(), res); - expect(res.body).toEqual({ - [responseKey]: ['one', 'two'], - metadata: { currentPage: 1, totalPages: 1, total: 2, limit: 2 }, - }); - expect(mockPullRequestQuery).toHaveBeenCalledTimes(1); - }); - - it.each([ - [{ page: '2', limit: '2' }, { currentPage: 2, totalPages: 3, total: 5, limit: 2 }, 2], - [{ page: 'bad', limit: '0' }, { currentPage: 1, totalPages: 0, total: 0, limit: 20 }, 0], - ])('returns a distinct page and count: %j', async (params, metadata, expectedOffset) => { - const base = queryResult([{ [field]: 'one' }]); - const count = queryResult(metadata.total ? { count: String(metadata.total) } : undefined); - mockPullRequestQuery.mockReturnValueOnce(base).mockReturnValueOnce(count); - const res = response(); - await handler(request({ query: params }), res); - expect(base.offset).toHaveBeenCalledWith(expectedOffset); - expect(base.limit).toHaveBeenCalledWith(metadata.limit); - expect(res.body).toEqual({ [responseKey]: ['one'], metadata }); - }); - - it('maps query failures to the stable 500 response', async () => { - mockPullRequestQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await handler(request(), res); - expect(res.statusCode).toBe(500); - }); - }); -}); diff --git a/src/__tests__/pages-api/schemaValidate.test.ts b/src/__tests__/pages-api/schemaValidate.test.ts deleted file mode 100644 index 76a57465..00000000 --- a/src/__tests__/pages-api/schemaValidate.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -const mockFetchYaml = jest.fn(); -const mockParse = jest.fn(); -const mockValidate = jest.fn(); -const mockLogger = { error: jest.fn() }; - -jest.mock('server/lib/github', () => ({ - getYamlFileContentFromBranch: (...args: unknown[]) => mockFetchYaml(...args), - ConfigFileNotFound: class ConfigFileNotFound extends Error {}, -})); - -jest.mock('server/lib/yamlConfigParser', () => ({ - YamlConfigParser: jest.fn(() => ({ parseYamlConfigFromString: (...args: unknown[]) => mockParse(...args) })), - ParsingError: class ParsingError extends Error {}, -})); - -jest.mock('server/lib/yamlConfigValidator', () => ({ - YamlConfigValidator: jest.fn(() => ({ validate: (...args: unknown[]) => mockValidate(...args) })), - ValidationError: class ValidationError extends Error {}, -})); - -jest.mock('server/lib/logger', () => ({ getLogger: () => mockLogger })); - -import handler from 'src/pages/api/v1/schema/validate'; -import { ConfigFileNotFound } from 'server/lib/github'; -import { ParsingError } from 'server/lib/yamlConfigParser'; -import { ValidationError } from 'server/lib/yamlConfigValidator'; - -describe('POST /api/v1/schema/validate', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockParse.mockReturnValue({ version: 'v2', services: {} }); - mockValidate.mockReturnValue(true); - mockFetchYaml.mockResolvedValue('version: v2'); - }); - - it('rejects unsupported methods before parsing a request', async () => { - const res = response(); - await handler(request({ method: 'GET' }), res); - expect(res.statusCode).toBe(405); - expect(mockParse).not.toHaveBeenCalled(); - }); - - it.each([undefined, 7, 'url'])('rejects unsupported source values: %j', async (source) => { - const res = response(); - await handler(request({ method: 'POST', body: { source } }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ valid: false, error: ['Invalid source in request body'] }); - }); - - it('validates decoded base64 content', async () => { - const content = Buffer.from('version: v2\nservices: {}').toString('base64'); - const res = response(); - await handler(request({ method: 'POST', body: { source: 'content', content } }), res); - expect(mockParse).toHaveBeenCalledWith('version: v2\nservices: {}'); - expect(mockValidate).toHaveBeenCalledWith('v2', { version: 'v2', services: {} }); - expect(res.body).toEqual({ valid: true, error: null }); - }); - - it('passes an absent parsed content version through to the validator', async () => { - mockParse.mockReturnValueOnce(undefined); - const res = response(); - await handler( - request({ method: 'POST', body: { source: 'content', content: Buffer.from('').toString('base64') } }), - res - ); - expect(mockValidate).toHaveBeenCalledWith(undefined, undefined); - }); - - it.each([undefined, 7])('requires content to be a string: %j', async (content) => { - const res = response(); - await handler(request({ method: 'POST', body: { source: 'content', content } }), res); - expect(res.statusCode).toBe(400); - expect(mockParse).not.toHaveBeenCalled(); - }); - - it.each([[{ repo: '', branch: 'main' }], [{ repo: 'goodrx/lifecycle', branch: ' ' }], [{ repo: 7, branch: 'main' }]])( - 'requires nonblank string path coordinates: %j', - async (body) => { - const res = response(); - await handler(request({ method: 'POST', body: { source: 'path', ...body } }), res); - expect(res.statusCode).toBe(400); - expect(mockFetchYaml).not.toHaveBeenCalled(); - } - ); - - it('fetches and validates repository YAML by repo and branch', async () => { - mockValidate.mockReturnValue(false); - const res = response(); - await handler( - request({ method: 'POST', body: { source: 'path', repo: 'goodrx/lifecycle', branch: 'feature' } }), - res - ); - expect(mockFetchYaml).toHaveBeenCalledWith('goodrx/lifecycle', 'feature'); - expect(mockParse).toHaveBeenCalledWith('version: v2'); - expect(res.body).toEqual({ valid: false, error: null }); - }); - - it('passes an absent parsed path version through to the validator', async () => { - mockParse.mockReturnValueOnce(undefined); - const res = response(); - await handler( - request({ method: 'POST', body: { source: 'path', repo: 'goodrx/lifecycle', branch: 'empty' } }), - res - ); - expect(mockValidate).toHaveBeenCalledWith(undefined, undefined); - }); - - it.each([ - [new ParsingError('line one\nline two'), 400, { valid: false, error: ['line one', 'line two'] }], - [new ValidationError('invalid service'), 400, { valid: false, error: ['invalid service'] }], - [new ConfigFileNotFound('missing'), 404, { valid: false, error: ['Config file not found'] }], - [new Error('unexpected'), 500, { error: 'Internal server error' }], - ])('maps validation failures without leaking internals: %s', async (error, status, body) => { - mockParse.mockImplementationOnce(() => { - throw error; - }); - const res = response(); - await handler( - request({ - method: 'POST', - body: { source: 'content', content: Buffer.from('invalid').toString('base64') }, - }), - res - ); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - if (status === 500) expect(mockLogger.error).toHaveBeenCalledWith({ error }, 'Schema: YAML validation failed'); - }); - - it('maps repository file-not-found failures', async () => { - mockFetchYaml.mockRejectedValueOnce(new ConfigFileNotFound('missing')); - const res = response(); - await handler(request({ method: 'POST', body: { source: 'path', repo: 'goodrx/lifecycle', branch: 'main' } }), res); - expect(res.statusCode).toBe(404); - }); -}); diff --git a/src/__tests__/pages-api/setupRoutes.test.ts b/src/__tests__/pages-api/setupRoutes.test.ts deleted file mode 100644 index dfd71f7f..00000000 --- a/src/__tests__/pages-api/setupRoutes.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -const mockGetConfig = jest.fn(); -const mockSetConfig = jest.fn(); -const mockGetAllConfigs = jest.fn(); -const mockUpdateSecret = jest.fn(); -const mockGetNamespace = jest.fn(); -const mockShellPromise = jest.fn(); -const mockRandomBytes = jest.fn(); -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; -let mockAppHost = 'https://lifecycle.example.test'; - -jest.mock('server/services/globalConfig', () => { - const instance = { - getConfig: (...args: unknown[]) => mockGetConfig(...args), - setConfig: (...args: unknown[]) => mockSetConfig(...args), - getAllConfigs: (...args: unknown[]) => mockGetAllConfigs(...args), - }; - const Service = jest.fn(() => instance) as jest.Mock & { getInstance: jest.Mock }; - Service.getInstance = jest.fn(() => instance); - return { __esModule: true, default: Service }; -}); - -jest.mock('server/lib/kubernetes', () => ({ - updateSecret: (...args: unknown[]) => mockUpdateSecret(...args), - getCurrentNamespaceFromFile: (...args: unknown[]) => mockGetNamespace(...args), -})); - -jest.mock('server/lib/shell', () => ({ - shellPromise: (...args: unknown[]) => mockShellPromise(...args), -})); - -jest.mock('server/lib/logger', () => ({ - getLogger: () => mockLogger, -})); - -jest.mock('crypto', () => ({ - randomBytes: (...args: unknown[]) => mockRandomBytes(...args), -})); - -jest.mock('shared/config', () => ({ - get APP_HOST() { - return mockAppHost; - }, - GITHUB_APP_AUTH_CALLBACK: 'https://auth.example.test/callback', - SECRET_BOOTSTRAP_NAME: 'lifecycle-bootstrap', -})); - -import callbackHandler from 'src/pages/api/v1/setup/callback'; -import configureHandler from 'src/pages/api/v1/setup/configure'; -import setupHandler from 'src/pages/api/v1/setup'; -import installedHandler from 'src/pages/api/v1/setup/installed'; -import statusHandler from 'src/pages/api/v1/setup/status'; - -describe('legacy setup API routes', () => { - const originalReleaseName = process.env.HELM_RELEASE_NAME; - - beforeEach(() => { - jest.clearAllMocks(); - mockAppHost = 'https://lifecycle.example.test'; - mockGetNamespace.mockReturnValue('lifecycle-system'); - mockRandomBytes.mockReturnValue({ toString: jest.fn(() => 'fixed-state') }); - mockGetConfig.mockImplementation(async (key: string) => { - if (key === 'app_setup') return { state: 'fixed-state', installed: false }; - if (key === 'lifecycleDefaults') return { existing: 'lifecycle' }; - if (key === 'domainDefaults') return { existing: 'domain' }; - return undefined; - }); - process.env.HELM_RELEASE_NAME = 'lifecycle'; - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - json: jest.fn().mockResolvedValue({ - id: 'app-1', - client_id: 'client-1', - client_secret: 'client-secret', - webhook_secret: 'webhook-secret', - pem: 'line1\nline2', - html_url: 'https://github.com/apps/lifecycle-test', - slug: 'lifecycle-test', - }), - }) as typeof fetch; - }); - - afterAll(() => { - if (originalReleaseName === undefined) delete process.env.HELM_RELEASE_NAME; - else process.env.HELM_RELEASE_NAME = originalReleaseName; - }); - - describe('GET /setup', () => { - it('rejects unsupported methods and installed applications before generating state', async () => { - const methodRes = response(); - await setupHandler(request({ method: 'POST' }), methodRes); - expect(methodRes.statusCode).toBe(405); - expect(mockGetConfig).not.toHaveBeenCalled(); - - mockGetConfig.mockResolvedValueOnce({ installed: true }); - const installedRes = response(); - await setupHandler(request(), installedRes); - expect(installedRes.redirect).toHaveBeenCalledWith('/setup'); - expect(mockRandomBytes).not.toHaveBeenCalled(); - }); - - it.each([ - [{}, 'App name is not valid.'], - [{ app_name: 'x'.repeat(35) }, 'App name is not valid.'], - [{ app_name: 'valid-app', org: 'bad org' }, 'Organization name is not valid.'], - [{ app_name: 'valid-app', org: 'x'.repeat(40) }, 'Organization name is not valid.'], - ])('validates manifest identity fields: %j', async (query, error) => { - const res = response(); - await setupHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error }); - expect(mockSetConfig).not.toHaveBeenCalled(); - }); - - it('validates input when app setup state has not been created yet', async () => { - mockGetConfig.mockResolvedValueOnce(undefined); - const res = response(); - await setupHandler(request({ query: {} }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'App name is not valid.' }); - }); - - it.each(['', 'not a url'])('rejects an invalid configured public URL: %j', async (appHost) => { - mockAppHost = appHost; - const res = response(); - await setupHandler(request({ query: { app_name: 'valid-app' } }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'Application public URL is not valid.' }); - }); - - it.each([ - [{ app_name: ' lifecycle-app ' }, 'https://github.com/settings/apps/new?state=fixed-state'], - [ - { app_name: 'lifecycle-app', org: ' goodrx ' }, - 'https://github.com/organizations/goodrx/settings/apps/new?state=fixed-state', - ], - ])('persists state and renders the GitHub manifest form', async (query, actionUrl) => { - const res = response(); - await setupHandler(request({ query }), res); - - expect(mockSetConfig).toHaveBeenCalledWith('app_setup', { - state: 'fixed-state', - created: false, - installed: false, - org: 'org' in query ? 'goodrx' : '', - appUrl: 'https://lifecycle.example.test', - }); - expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); - expect(res.send).toHaveBeenCalledWith(expect.stringContaining(`action="${actionUrl}"`)); - expect(res.send).toHaveBeenCalledWith(expect.stringContaining('"workflows":"write"')); - expect(res.send).toHaveBeenCalledWith(expect.stringContaining('https://auth.example.test/callback')); - }); - }); - - describe('GET /setup/callback', () => { - it.each([ - [{ state: 'fixed-state' }, { error: 'Missing authorization code' }], - [{ code: 'code-1' }, { error: 'Missing state parameter' }], - ])('requires OAuth callback parameters: %j', async (query, body) => { - const res = response(); - await callbackHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual(body); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('rejects a mismatched state and an already-installed app', async () => { - mockGetConfig.mockResolvedValueOnce({ state: 'other-state' }); - const stateRes = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), stateRes); - expect(stateRes.body).toEqual({ error: 'Invalid state parameter' }); - - mockGetConfig - .mockResolvedValueOnce({ state: 'fixed-state' }) - .mockResolvedValueOnce({ state: 'fixed-state', installed: true }); - const installedRes = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), installedRes); - expect(installedRes.body).toEqual({ error: 'App already installed' }); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('maps GitHub conversion failure and invalid credentials without mutating secrets', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 422, - json: jest.fn().mockResolvedValue({ message: 'expired' }), - }); - const githubRes = response(); - await callbackHandler(request({ query: { code: 'expired', state: 'fixed-state' } }), githubRes); - expect(githubRes.statusCode).toBe(422); - expect(githubRes.body).toEqual({ error: 'Failed to convert manifest code' }); - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ id: 'incomplete' }), - }); - const invalidRes = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), invalidRes); - expect(invalidRes.statusCode).toBe(400); - expect(invalidRes.body).toEqual({ error: 'Invalid response from GitHub' }); - expect(mockUpdateSecret).not.toHaveBeenCalled(); - }); - - it('stores converted credentials and redirects to GitHub installation', async () => { - const res = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), res); - - expect(global.fetch).toHaveBeenCalledWith('https://api.github.com/app-manifests/code-1/conversions', { - method: 'POST', - headers: { Accept: 'application/vnd.github.v3+json' }, - }); - expect(mockUpdateSecret).toHaveBeenCalledWith( - 'lifecycle-bootstrap', - { - GITHUB_APP_ID: 'app-1', - GITHUB_CLIENT_ID: 'client-1', - GITHUB_CLIENT_SECRET: 'client-secret', - GITHUB_WEBHOOK_SECRET: 'webhook-secret', - GITHUB_PRIVATE_KEY: 'line1\\nline2', - }, - 'lifecycle-system' - ); - expect(mockSetConfig).toHaveBeenCalledWith('app_setup', { - state: 'fixed-state', - installed: false, - created: true, - url: 'https://github.com/apps/lifecycle-test', - name: 'lifecycle-test', - }); - expect(res.redirect).toHaveBeenCalledWith('https://github.com/apps/lifecycle-test/installations/new'); - }); - - it('returns the stable setup error on unexpected failures', async () => { - mockGetConfig.mockRejectedValueOnce(new Error('database unavailable')); - const res = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An error occurred during GitHub app setup' }); - }); - }); - - describe('GET /setup/installed', () => { - it.each([ - [{}, 400, { error: 'Missing installation_id' }], - [{ installation_id: 'install-1', setup_action: 'update' }, 500, { error: 'Invalid setup_action' }], - ])('validates installation callback query: %j', async (query, status, body) => { - const res = response(); - await installedHandler(request({ query }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - expect(mockSetConfig).not.toHaveBeenCalled(); - }); - - it.each([ - [undefined, 404, { error: 'No app_setup found.' }], - [{ installed: true }, 400, { error: 'App already installed.' }], - ])('rejects unusable app_setup state', async (appSetup, status, body) => { - mockGetConfig.mockResolvedValueOnce(appSetup); - const res = response(); - await installedHandler(request({ query: { installation_id: 'install-1' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - expect(mockUpdateSecret).not.toHaveBeenCalled(); - }); - - it('records installation, updates the bootstrap secret, and redirects with state', async () => { - const res = response(); - await installedHandler(request({ query: { installation_id: 'install-1', setup_action: 'install' } }), res); - - const installedState = { state: 'fixed-state', installed: true }; - expect(mockSetConfig).toHaveBeenCalledWith('app_setup', installedState); - expect(mockUpdateSecret).toHaveBeenCalledWith( - 'lifecycle-bootstrap', - { GITHUB_APP_INSTALLATION_ID: 'install-1' }, - 'lifecycle-system' - ); - expect(res.redirect).toHaveBeenCalledWith( - `/setup/complete?app_setup=${encodeURIComponent(JSON.stringify(installedState))}` - ); - }); - }); - - describe('POST /setup/configure', () => { - it('fails before reading config when namespace or release name is missing', async () => { - mockGetNamespace.mockReturnValueOnce(''); - const noNamespace = response(); - await configureHandler(request({ method: 'POST' }), noNamespace); - expect(noNamespace.statusCode).toBe(500); - - mockGetNamespace.mockReturnValueOnce('lifecycle-system'); - delete process.env.HELM_RELEASE_NAME; - const noRelease = response(); - await configureHandler(request({ method: 'POST' }), noRelease); - expect(noRelease.statusCode).toBe(500); - expect(mockGetConfig).not.toHaveBeenCalled(); - }); - - it('does not request a second restart', async () => { - process.env.HELM_RELEASE_NAME = 'lifecycle'; - mockGetConfig.mockResolvedValueOnce({ restarted: true }); - const res = response(); - await configureHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(400); - expect(mockShellPromise).not.toHaveBeenCalled(); - }); - - it('persists domain defaults, refreshes config, restarts, and marks setup', async () => { - process.env.HELM_RELEASE_NAME = 'lifecycle'; - const res = response(); - await configureHandler(request({ method: 'POST' }), res); - - expect(mockSetConfig).toHaveBeenNthCalledWith(1, 'lifecycleDefaults', { - existing: 'lifecycle', - ecrDomain: 'distribution.example.test', - defaultPublicUrl: 'dev-0.example.test', - }); - expect(mockSetConfig).toHaveBeenNthCalledWith(2, 'domainDefaults', { - existing: 'domain', - http: 'example.test', - grpc: 'example.test', - publicScheme: 'https', - }); - expect(mockGetAllConfigs).toHaveBeenCalledWith(true); - expect(mockShellPromise).toHaveBeenCalledWith( - 'kubectl rollout restart deployment -l app.kubernetes.io/instance=lifecycle,app.kubernetes.io/name=lifecycle -n lifecycle-system' - ); - expect(mockSetConfig).toHaveBeenNthCalledWith(3, 'app_setup', { - state: 'fixed-state', - installed: false, - restarted: true, - }); - expect(res.statusCode).toBe(200); - }); - - it('returns a stable error when configuration or restart fails', async () => { - process.env.HELM_RELEASE_NAME = 'lifecycle'; - mockShellPromise.mockRejectedValueOnce(new Error('rollout failed')); - const res = response(); - await configureHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Restarting deployment failed' }); - }); - - it('can configure when app_setup has not been initialized', async () => { - process.env.HELM_RELEASE_NAME = 'lifecycle'; - mockGetConfig - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ existing: 'lifecycle' }) - .mockResolvedValueOnce({ existing: 'domain' }); - const res = response(); - await configureHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(200); - expect(mockSetConfig).toHaveBeenLastCalledWith('app_setup', { restarted: true }); - }); - }); - - describe('GET /setup/status', () => { - it('enforces GET and returns defaults for absent setup state', async () => { - const methodRes = response(); - await statusHandler(request({ method: 'POST' }), methodRes); - expect(methodRes.statusCode).toBe(405); - - mockGetConfig.mockResolvedValueOnce(undefined); - const emptyRes = response(); - await statusHandler(request(), emptyRes); - expect(emptyRes.body).toEqual({ installed: false, created: false, restarted: false, url: '' }); - }); - - it('returns persisted setup status fields', async () => { - mockGetConfig.mockResolvedValueOnce({ installed: true, created: true, restarted: true, url: 'app-url' }); - const res = response(); - await statusHandler(request(), res); - expect(res.body).toEqual({ installed: true, created: true, restarted: true, url: 'app-url' }); - }); - }); -}); diff --git a/src/__tests__/pages-api/v1Retirement.test.ts b/src/__tests__/pages-api/v1Retirement.test.ts new file mode 100644 index 00000000..db7789c0 --- /dev/null +++ b/src/__tests__/pages-api/v1Retirement.test.ts @@ -0,0 +1,23 @@ +import { existsSync, readdirSync, readFileSync } from 'fs'; +import path from 'path'; + +const root = path.resolve(__dirname, '../../..'); +function sourceFiles(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const name = path.join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(name) : /\.[jt]sx?$/.test(name) ? [name] : []; + }); +} + +it('does not register the retired v1 API in either Next router', () => { + expect(sourceFiles(path.join(root, 'src/pages/api/v1'))).toEqual([]); + expect(sourceFiles(path.join(root, 'src/app/api/v1'))).toEqual([]); +}); + +it('does not leave first-party pages or the environment banner calling the retired API', () => { + const files = [...sourceFiles(path.join(root, 'src/pages')), path.join(root, 'public/utils/0-banner.js')]; + const callers = files.filter((file) => readFileSync(file, 'utf8').includes('/api/v1')); + expect(callers).toEqual([]); + expect(readFileSync(path.join(root, 'public/utils/0-banner.js'), 'utf8')).not.toContain('new EventSource'); +}); diff --git a/src/app/api/setup/__tests__/setupRoutes.test.ts b/src/app/api/setup/__tests__/setupRoutes.test.ts new file mode 100644 index 00000000..443ab283 --- /dev/null +++ b/src/app/api/setup/__tests__/setupRoutes.test.ts @@ -0,0 +1,348 @@ +import { createHmac } from 'crypto'; +import { NextRequest } from 'next/server'; + +let mockSetupToken = 'operator-token'; +let mockEnvCredentials: Record = {}; +let mockRecords: Record = {}; +let mockSecret: any; +let mockCommitFailure = false; +let mockTransactionQueue = Promise.resolve(); +const mockLock = jest.fn(); +const mockInvalidate = jest.fn(); +const mockExec = jest.fn(); +const mockReadSecret = jest.fn(); +const mockWriteSecret = jest.fn(); +const mockAppAuth = jest.fn(); +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + +function mockTrx(_table: string) { + let key = ''; + return { + insert(value: { key: string; config: unknown }) { + return { + onConflict: () => ({ + ignore: async () => { + mockRecords[value.key] ??= structuredClone(value.config); + }, + }), + }; + }, + where(value: { key: string }) { + key = value.key; + return this; + }, + forUpdate() { + mockLock(key); + return this; + }, + async first() { + return { config: structuredClone(mockRecords[key]) }; + }, + async update(value: { config: unknown }) { + mockRecords[key] = structuredClone(value.config); + }, + }; +} +mockTrx.raw = jest.fn().mockResolvedValue(undefined); +const mockTransaction = jest.fn((action) => { + const result = mockTransactionQueue.then(async () => { + const snapshot = structuredClone(mockRecords); + try { + const value = await action(mockTrx); + if (mockCommitFailure) { + mockCommitFailure = false; + throw new Error('simulated commit failure'); + } + return value; + } catch (error) { + mockRecords = snapshot; + throw error; + } + }); + mockTransactionQueue = result.then( + () => undefined, + () => undefined + ); + return result; +}); + +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + db: { knex: { transaction: mockTransaction } }, + getConfig: async (key: string) => mockRecords[key], + invalidateCache: (...args: unknown[]) => mockInvalidate(...args), + }), + }, +})); +jest.mock('server/lib/kubernetes', () => ({ + getCurrentNamespaceFromFile: () => 'lifecycle', + getK8sApi: () => ({ + addInterceptor: jest.fn(), + readNamespacedSecret: mockReadSecret, + replaceNamespacedSecret: mockWriteSecret, + createNamespacedSecret: mockWriteSecret, + }), +})); +jest.mock('@octokit/auth-app', () => ({ createAppAuth: () => mockAppAuth })); +jest.mock('child_process', () => ({ execFile: (...args: unknown[]) => mockExec(...args) })); +jest.mock('server/lib/logger', () => ({ getLogger: () => mockLogger })); +jest.mock('server/lib/dependencies', () => ({})); +jest.mock('server/services/apiToken', () => ({ scopeSatisfies: () => true })); +jest.mock('server/services/authRateLimit', () => ({ checkApiKeyRateLimit: async () => ({ allowed: true }) })); +jest.mock('server/services/authAudit', () => ({ recordAuthAuditEvent: jest.fn() })); +jest.mock('server/lib/principal', () => ({ resolvePrincipal: jest.fn() })); +jest.mock('shared/config', () => ({ + APP_HOST: 'https://lifecycle.example.test', + GITHUB_APP_AUTH_CALLBACK: 'https://auth.example.test/callback', + SECRET_BOOTSTRAP_NAME: 'bootstrap', + get LIFECYCLE_SETUP_TOKEN() { + return mockSetupToken; + }, + get GITHUB_APP_ID() { + return mockEnvCredentials.GITHUB_APP_ID ?? 'YOUR_VALUE_HERE'; + }, + get GITHUB_CLIENT_ID() { + return mockEnvCredentials.GITHUB_CLIENT_ID ?? 'YOUR_VALUE_HERE'; + }, + get GITHUB_CLIENT_SECRET() { + return mockEnvCredentials.GITHUB_CLIENT_SECRET ?? 'YOUR_VALUE_HERE'; + }, + get GITHUB_PRIVATE_KEY() { + return mockEnvCredentials.GITHUB_PRIVATE_KEY ?? 'YOUR_VALUE_HERE'; + }, + get GITHUB_WEBHOOK_SECRET() { + return mockEnvCredentials.GITHUB_WEBHOOK_SECRET ?? 'YOUR_VALUE_HERE'; + }, +})); + +import { POST as manifest } from '../manifest/route'; +import { GET as callback } from '../callback/route'; +import { GET as installed } from '../installed/route'; +import { POST as configure } from '../configure/route'; +import { GET as status } from '../status/route'; +import { getSetupToken } from '../setupAccess'; + +const originalEnv = { ...process.env }; +const originalFetch = global.fetch; +function req(path: string, options: { method?: string; token?: string; body?: unknown } = {}) { + return new NextRequest(`https://lifecycle.example.test/api/setup/${path}`, { + method: options.method || 'GET', + headers: options.token === undefined ? {} : { 'x-setup-token': options.token }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); +} +const post = (path: string, body?: unknown) => req(path, { method: 'POST', token: 'operator-token', body }); +const decodeSecret = (key: string) => Buffer.from(mockSecret.data[key], 'base64').toString('utf8'); + +beforeEach(() => { + jest.clearAllMocks(); + mockSetupToken = 'operator-token'; + mockEnvCredentials = {}; + mockCommitFailure = false; + mockTransactionQueue = Promise.resolve(); + mockRecords = { + app_setup: { created: false, installed: false, restarted: false, state: '', retained: 'setting' }, + lifecycleDefaults: { + ecrDomain: 'distribution.example.com', + defaultPublicUrl: 'dev-0.127.0.0.1.nip.io', + extra: true, + }, + domainDefaults: { http: '127.0.0.1.nip.io', grpc: '127.0.0.1.nip.io' }, + }; + mockSecret = { + metadata: { name: 'bootstrap', resourceVersion: '1', annotations: { existing: 'annotation' } }, + data: { OTHER_KEY: Buffer.from('preserved').toString('base64') }, + }; + mockReadSecret.mockReset().mockImplementation(async () => ({ body: structuredClone(mockSecret) })); + mockWriteSecret.mockReset().mockImplementation(async (_name, _namespace, body) => { + const next = body || _namespace; + if (next.metadata.resourceVersion && next.metadata.resourceVersion !== mockSecret.metadata.resourceVersion) + throw new Error('resourceVersion conflict'); + mockSecret = structuredClone(next); + mockSecret.metadata.resourceVersion = String(Number(mockSecret.metadata.resourceVersion || 0) + 1); + }); + mockInvalidate.mockResolvedValue(undefined); + mockExec.mockReset().mockImplementation((_file, _args, _options, done) => done(null, '', '')); + mockAppAuth.mockResolvedValue({ token: 'app-jwt' }); + process.env.HELM_RELEASE_NAME = 'lifecycle'; + delete process.env.ENCRYPTION_KEY; + global.fetch = jest.fn().mockImplementation(async (url: string) => ({ + ok: true, + json: async () => + url.includes('/conversions') + ? { + id: 123, + client_id: 'client', + client_secret: 'client-secret', + webhook_secret: 'webhook-secret', + pem: 'private-key', + html_url: 'https://github.com/apps/fresh-app', + slug: 'fresh-app', + } + : { app_id: 123 }, + })) as any; +}); +afterAll(() => { + process.env = originalEnv; + global.fetch = originalFetch; +}); + +async function start() { + const response = await manifest(post('manifest', { appName: 'fresh-app', org: 'example' })); + expect(response.status).toBe(200); + return mockRecords.app_setup.state; +} +async function create() { + const state = await start(); + const response = await callback(req(`callback?code=code&state=${state}`)); + expect(response.status).toBe(307); + return mockRecords.app_setup.state; +} +async function install() { + const state = await create(); + const response = await installed(req(`installed?installation_id=456&setup_action=install&state=${state}`)); + expect(response.status).toBe(307); +} + +it('requires the operator token before reading settings or mutating setup', async () => { + for (const token of [undefined, 'wrong-token']) { + const response = await manifest(req('manifest', { method: 'POST', token, body: { appName: 'fresh-app' } })); + expect(response.status).toBe(401); + } + expect(mockTransaction).not.toHaveBeenCalled(); + expect(mockReadSecret).not.toHaveBeenCalled(); +}); +it('uses a shared derivation and refuses missing keys instead of generating per-pod tokens', () => { + mockSetupToken = ''; + expect(() => getSetupToken()).toThrow('shared setup token'); + process.env.ENCRYPTION_KEY = 'shared-encryption-key'; + expect(getSetupToken()).toBe( + createHmac('sha256', 'shared-encryption-key').update('lifecycle-setup-token').digest('hex').slice(0, 32) + ); + expect(mockLogger.info).not.toHaveBeenCalled(); +}); +it('completes a fresh setup, verifies the installation and retains unrelated data', async () => { + await install(); + const response = await configure(post('configure')); + expect(response.status).toBe(200); + expect(mockLock).toHaveBeenCalledWith('app_setup'); + expect(mockRecords.app_setup).toMatchObject({ phase: 'complete', retained: 'setting', state: '', restarted: true }); + expect(decodeSecret('GITHUB_APP_INSTALLATION_ID')).toBe('456'); + expect(decodeSecret('OTHER_KEY')).toBe('preserved'); + expect(mockSecret.metadata.annotations.existing).toBe('annotation'); + expect(mockRecords.domainDefaults).toEqual({ http: 'example.test', grpc: 'example.test', publicScheme: 'https' }); + expect(mockRecords.lifecycleDefaults).toEqual({ + ecrDomain: 'distribution.example.test', + defaultPublicUrl: 'dev-0.example.test', + extra: true, + }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.github.com/app/installations/456', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer app-jwt' }) }) + ); + expect(mockExec).toHaveBeenCalledWith( + 'kubectl', + [ + 'rollout', + 'restart', + 'deployment', + '-l', + 'app.kubernetes.io/instance=lifecycle,app.kubernetes.io/name=lifecycle', + '-n', + 'lifecycle', + ], + { timeout: 15000 }, + expect.any(Function) + ); + expect((await configure(post('configure'))).status).toBe(403); + expect((await manifest(post('manifest', { appName: 'replacement' }))).status).toBe(403); +}); +it.each(['env', 'secret'])('locks configured %s credentials even if app_setup flags are false', async (source) => { + if (source === 'env') mockEnvCredentials.GITHUB_APP_ID = '123'; + else mockSecret.data.GITHUB_APP_ID = Buffer.from('123').toString('base64'); + expect((await manifest(post('manifest', { appName: 'replacement' }))).status).toBe(403); + expect(mockWriteSecret).not.toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); +}); +it('fails closed when the live bootstrap Secret cannot be read', async () => { + mockReadSecret.mockRejectedValue({ response: { statusCode: 403 } }); + expect((await manifest(post('manifest', { appName: 'fresh-app' }))).status).toBe(503); + expect(mockWriteSecret).not.toHaveBeenCalled(); +}); +it('rejects legacy, expired and wrong callback state before contacting GitHub', async () => { + mockRecords.app_setup = { state: 'old-state', created: false }; + expect((await callback(req('callback?code=x&state=old-state'))).status).toBe(400); + const state = await start(); + expect((await callback(req('callback?code=x&state=wrong'))).status).toBe(400); + mockRecords.app_setup.expiresAt = Date.now() - 1; + expect((await callback(req(`callback?code=x&state=${state}`))).status).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); +}); +it('serializes concurrent callback replays and converts the manifest once', async () => { + const state = await start(); + const responses = await Promise.all([ + callback(req(`callback?code=x&state=${state}`)), + callback(req(`callback?code=x&state=${state}`)), + ]); + expect(responses.map((response) => response.status)).toEqual([307, 400]); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(mockWriteSecret).toHaveBeenCalledTimes(1); +}); +it('recovers the same created App after the Secret is saved but the DB transaction fails', async () => { + const state = await start(); + mockCommitFailure = true; + expect((await callback(req(`callback?code=x&state=${state}`))).status).toBe(500); + expect(mockRecords.app_setup.phase).toBe('manifest'); + expect(decodeSecret('GITHUB_APP_ID')).toBe('123'); + expect((await callback(req(`callback?code=x&state=${state}`))).status).toBe(307); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(mockWriteSecret).toHaveBeenCalledTimes(1); +}); +it('does not adopt externally changed credentials that retained setup annotations', async () => { + await create(); + mockSecret.data.GITHUB_CLIENT_SECRET = Buffer.from('operator-replacement').toString('base64'); + expect((await manifest(post('manifest'))).status).toBe(403); + expect((await installed(req(`installed?installation_id=456&state=${mockRecords.app_setup.state}`))).status).toBe(403); +}); +it('rejects an installation from another App without marking setup complete', async () => { + const state = await create(); + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => ({ app_id: 999 }) }); + expect((await installed(req(`installed?installation_id=456&state=${state}`))).status).toBe(403); + expect(mockRecords.app_setup.phase).toBe('installation'); + expect(mockSecret.data.GITHUB_APP_INSTALLATION_ID).toBeUndefined(); +}); +it('keeps installation callback retryable when its Secret write fails', async () => { + const state = await create(); + mockWriteSecret.mockRejectedValueOnce(new Error('unavailable')); + expect((await installed(req(`installed?installation_id=456&state=${state}`))).status).toBe(503); + expect(mockRecords.app_setup.phase).toBe('installation'); + expect((await installed(req(`installed?installation_id=456&state=${state}`))).status).toBe(307); +}); +it('preserves operator domain and registry settings and permits a failed restart to retry', async () => { + await install(); + mockRecords.domainDefaults = { http: 'custom.test', grpc: 'grpc.custom.test', publicScheme: 'http' }; + mockRecords.lifecycleDefaults = { ecrDomain: 'registry.custom.test', defaultPublicUrl: 'dev.custom.test' }; + mockExec.mockImplementationOnce((_file, _args, _options, done) => done(new Error('unavailable'))); + expect((await configure(post('configure'))).status).toBe(503); + expect(mockRecords.app_setup.phase).toBe('installed'); + expect((await configure(post('configure'))).status).toBe(200); + expect(mockRecords.domainDefaults).toEqual({ http: 'custom.test', grpc: 'grpc.custom.test', publicScheme: 'http' }); + expect(mockRecords.lifecycleDefaults).toEqual({ + ecrDomain: 'registry.custom.test', + defaultPublicUrl: 'dev.custom.test', + }); +}); +it('returns no state or token from status and prevents caching bootstrap state responses', async () => { + const response = await manifest(post('manifest', { appName: 'fresh-app' })); + const payload = await response.json(); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(payload.data.manifest.setup_url).toBe('https://lifecycle.example.test/api/setup/installed'); + expect(payload.data.manifest.redirect_url).toBe('https://lifecycle.example.test/api/setup/callback'); + const statusResponse = await status(req('status')); + const statusText = await statusResponse.text(); + expect(statusText).not.toContain(mockRecords.app_setup.state); + expect(statusText).not.toContain('operator-token'); + expect(statusResponse.headers.get('cache-control')).toBe('private, no-store'); +}); diff --git a/src/app/api/setup/callback/route.ts b/src/app/api/setup/callback/route.ts new file mode 100644 index 00000000..764308e7 --- /dev/null +++ b/src/app/api/setup/callback/route.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2026 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, NextResponse } from 'next/server'; +import { AppError, BadRequestError } from 'server/lib/appError'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { withNoStore } from 'server/lib/response'; +import { + assertSetupState, + credentialFingerprint, + hasCredentials, + isEnvConfigured, + nextFlow, + readBootstrapSecret, + recoveredApp, + setupLocked, + validGithubAppUrl, + withSetupTransaction, + writeBootstrapSecret, +} from '../setupAccess'; + +export const runtime = 'nodejs'; +export const GET = createPublicApiHandler(async (req: NextRequest) => { + const code = req.nextUrl.searchParams.get('code'); + const state = req.nextUrl.searchParams.get('state'); + if (!code || !state) + throw new BadRequestError( + 'GitHub did not return the setup code and state. Start setup again.', + 'missing_callback_params' + ); + return withSetupTransaction(async (flow, _trx, save) => { + assertSetupState(flow, state, 'manifest'); + let secret = await readBootstrapSecret(); + let app = recoveredApp(flow, secret); + if (!app) { + if (hasCredentials(secret) || isEnvConfigured()) throw setupLocked(); + const response = await fetch(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, { + method: 'POST', + headers: { Accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) + throw new AppError({ + httpStatus: 502, + code: 'github_conversion_failed', + message: 'GitHub could not create the App. Start setup again.', + }); + const { id, client_id, client_secret, webhook_secret, pem, html_url, slug } = await response.json(); + if ( + !id || + ![client_id, client_secret, webhook_secret, pem, slug].every( + (value) => typeof value === 'string' && value.length > 0 + ) || + !validGithubAppUrl(html_url) + ) { + throw new AppError({ + httpStatus: 502, + code: 'github_incomplete_credentials', + message: 'GitHub returned incomplete App settings. Start setup again.', + }); + } + await writeBootstrapSecret( + secret, + { + GITHUB_APP_ID: String(id), + GITHUB_CLIENT_ID: client_id, + GITHUB_CLIENT_SECRET: client_secret, + GITHUB_WEBHOOK_SECRET: webhook_secret, + GITHUB_PRIVATE_KEY: pem.replace(/\n/g, '\\n'), + }, + { id: flow.flowId!, url: html_url, name: slug } + ); + secret = await readBootstrapSecret(); + app = recoveredApp(flow, secret); + if (!app) throw setupLocked(); + } + const next = nextFlow( + { ...flow, created: true, url: app.url, name: app.name, credentialFingerprint: credentialFingerprint(secret) }, + 'installation' + ); + await save(next); + const response = withNoStore(NextResponse.redirect(`${app.url}/installations/new?state=${next.state}`)); + response.headers.set('Referrer-Policy', 'no-referrer'); + return response; + }); +}); diff --git a/src/app/api/setup/configure/route.ts b/src/app/api/setup/configure/route.ts new file mode 100644 index 00000000..ba1f7ab1 --- /dev/null +++ b/src/app/api/setup/configure/route.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2026 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 { execFile } from 'child_process'; +import { promisify } from 'util'; +import type { NextRequest } from 'next/server'; +import { AppError, ConflictError } from 'server/lib/appError'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { getCurrentNamespaceFromFile } from 'server/lib/kubernetes'; +import { successResponse, withNoStore } from 'server/lib/response'; +import { APP_HOST } from 'shared/config'; +import { + assertCurrentApp, + assertSetupActor, + readBootstrapSecret, + setupLocked, + withSetupTransaction, +} from '../setupAccess'; + +export const runtime = 'nodejs'; +const execFileAsync = promisify(execFile); +const DEFAULT_VALUES = new Set([ + '', + '127.0.0.1.nip.io', + 'dev-0.127.0.0.1.nip.io', + 'distribution.example.com', + '10.96.188.230:5000', +]); + +export const POST = createPublicApiHandler(async (req: NextRequest) => { + assertSetupActor(req); + return withSetupTransaction(async (flow, trx, save) => { + if (flow.phase === 'complete' || flow.restarted) throw setupLocked(); + if (flow.phase !== 'installed' || !flow.installed) + throw new ConflictError('Install the GitHub App before configuring Lifecycle.', 'app_not_installed'); + assertCurrentApp(flow, await readBootstrapSecret()); + const namespace = getCurrentNamespaceFromFile(); + const releaseName = process.env.HELM_RELEASE_NAME; + if (!namespace || !releaseName) + throw new AppError({ + httpStatus: 503, + code: 'restart_unavailable', + message: 'Configure the namespace and Helm release before restarting Lifecycle.', + }); + const publicUrl = new URL(APP_HOST); + const domain = publicUrl.hostname.split('.').slice(1).join('.') || publicUrl.hostname; + for (const [key, defaults] of [ + ['lifecycleDefaults', { ecrDomain: `distribution.${domain}`, defaultPublicUrl: `dev-0.${domain}` }], + ['domainDefaults', { http: domain, grpc: domain, publicScheme: publicUrl.protocol.replace(':', '') }], + ] as const) { + await trx('global_config').insert({ key, config: {} }).onConflict('key').ignore(); + const row = await trx('global_config').where({ key }).forUpdate().first(); + const current = typeof row.config === 'string' ? JSON.parse(row.config) : row.config || {}; + const next = { ...current }; + for (const [name, value] of Object.entries(defaults)) { + if (current[name] == null || DEFAULT_VALUES.has(current[name])) next[name] = value; + } + await trx('global_config').where({ key }).update({ config: next }); + } + try { + await execFileAsync( + 'kubectl', + [ + 'rollout', + 'restart', + 'deployment', + '-l', + `app.kubernetes.io/instance=${releaseName},app.kubernetes.io/name=lifecycle`, + '-n', + namespace, + ], + { timeout: 15_000 } + ); + } catch { + throw new AppError({ + httpStatus: 503, + code: 'restart_failed', + message: 'Lifecycle could not restart its deployments. Retry or restart them through your deployment tools.', + }); + } + await save({ ...flow, state: '', phase: 'complete', restarted: true }); + return withNoStore(successResponse({ restarted: true }, { status: 200 }, req)); + }); +}); diff --git a/src/app/api/setup/installed/route.ts b/src/app/api/setup/installed/route.ts new file mode 100644 index 00000000..3946ce72 --- /dev/null +++ b/src/app/api/setup/installed/route.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2026 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 { createAppAuth } from '@octokit/auth-app'; +import { NextRequest, NextResponse } from 'next/server'; +import { AppError, BadRequestError } from 'server/lib/appError'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { withNoStore } from 'server/lib/response'; +import { APP_HOST } from 'shared/config'; +import { + assertCurrentApp, + assertSetupState, + readBootstrapSecret, + withSetupTransaction, + writeBootstrapSecret, +} from '../setupAccess'; + +export const runtime = 'nodejs'; +export const GET = createPublicApiHandler(async (req: NextRequest) => { + const installationId = req.nextUrl.searchParams.get('installation_id'); + const setupAction = req.nextUrl.searchParams.get('setup_action'); + if (!installationId || !/^\d{1,20}$/.test(installationId)) + throw new BadRequestError( + 'GitHub did not return a valid installation ID. Install the App again.', + 'invalid_installation_id' + ); + if (setupAction && setupAction !== 'install') + throw new BadRequestError('Only a new installation can finish setup here.', 'unsupported_setup_action'); + return withSetupTransaction(async (flow, _trx, save) => { + assertSetupState(flow, req.nextUrl.searchParams.get('state'), 'installation'); + const secret = await readBootstrapSecret(); + assertCurrentApp(flow, secret); + const auth = createAppAuth({ + appId: secret.values.GITHUB_APP_ID, + privateKey: secret.values.GITHUB_PRIVATE_KEY.replace(/\\n/g, '\n'), + }); + const { token } = await auth({ type: 'app' }); + const installation = await fetch(`https://api.github.com/app/installations/${installationId}`, { + headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(15_000), + }); + if (!installation.ok || String((await installation.json()).app_id) !== secret.values.GITHUB_APP_ID) { + throw new AppError({ + httpStatus: 403, + code: 'installation_not_allowed', + message: 'This installation does not belong to the GitHub App created by setup.', + }); + } + await writeBootstrapSecret(secret, { GITHUB_APP_INSTALLATION_ID: installationId }); + await save({ ...flow, state: '', phase: 'installed', installed: true }); + const response = withNoStore(NextResponse.redirect(new URL('/setup/complete', APP_HOST))); + response.headers.set('Referrer-Policy', 'no-referrer'); + return response; + }); +}); diff --git a/src/app/api/setup/manifest/route.ts b/src/app/api/setup/manifest/route.ts new file mode 100644 index 00000000..f4ca15c2 --- /dev/null +++ b/src/app/api/setup/manifest/route.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2026 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 type { NextRequest } from 'next/server'; +import { BadRequestError } from 'server/lib/appError'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, withNoStore } from 'server/lib/response'; +import { APP_HOST, GITHUB_APP_AUTH_CALLBACK } from 'shared/config'; +import { + assertSetupActor, + hasCredentials, + isEnvConfigured, + readBootstrapSecret, + recoveredApp, + credentialFingerprint, + nextFlow, + setupLocked, + withSetupTransaction, +} from '../setupAccess'; + +export const runtime = 'nodejs'; + +export const POST = createPublicApiHandler(async (req: NextRequest) => { + assertSetupActor(req); + return withSetupTransaction(async (flow, _trx, save) => { + if (flow.installed || flow.restarted) throw setupLocked(); + const secret = await readBootstrapSecret(); + if (hasCredentials(secret) || isEnvConfigured()) { + const app = recoveredApp(flow, secret); + if (!app || flow.installed) throw setupLocked(); + const next = nextFlow( + { ...flow, created: true, url: app.url, name: app.name, credentialFingerprint: credentialFingerprint(secret) }, + 'installation' + ); + await save(next); + return withNoStore( + successResponse({ installUrl: `${app.url}/installations/new?state=${next.state}` }, { status: 200 }, req) + ); + } + const body = await req.json().catch(() => null); + if (!body || typeof body !== 'object' || Array.isArray(body)) + throw new BadRequestError('Enter the GitHub App name.', 'invalid_body'); + const appName = typeof body.appName === 'string' ? body.appName.trim() : ''; + const org = typeof body.org === 'string' ? body.org.trim() : ''; + if (!/^[a-zA-Z0-9-]{1,34}$/.test(appName)) throw new BadRequestError('App name is not valid.', 'invalid_app_name'); + if (org && !/^[a-zA-Z0-9-]{1,39}$/.test(org)) + throw new BadRequestError('Organization name is not valid.', 'invalid_organization'); + const appUrl = APP_HOST; + let publicUrl: URL; + try { + publicUrl = new URL(appUrl); + } catch { + throw new BadRequestError('Application public URL is not valid.', 'invalid_app_host'); + } + if (!['https:', 'http:'].includes(publicUrl.protocol) || publicUrl.username || publicUrl.password) + throw new BadRequestError('Application public URL is not valid.', 'invalid_app_host'); + const manifest = { + name: appName, + url: appUrl, + hook_attributes: { url: `${appUrl}/api/webhooks/github`, active: true }, + setup_url: `${appUrl}/api/setup/installed`, + redirect_url: `${appUrl}/api/setup/callback`, + callback_urls: [`${appUrl}/api/setup/callback`, `${GITHUB_APP_AUTH_CALLBACK}`], + public: false, + default_permissions: { + contents: 'write', + deployments: 'write', + issues: 'write', + members: 'read', + metadata: 'read', + pull_requests: 'write', + statuses: 'read', + // GitHub refuses .github/workflows writes under contents:write alone; the agent's update_file tool needs this. + workflows: 'write', + emails: 'read', + }, + default_events: [ + 'issues', + 'issue_comment', + 'label', + 'membership', + 'organization', + 'repository', + 'public', + 'pull_request', + 'push', + 'team', + ], + }; + const next = nextFlow( + { ...flow, flowId: undefined, created: false, installed: false, restarted: false, org, url: '' }, + 'manifest' + ); + await save(next); + const actionUrl = org + ? `https://github.com/organizations/${org}/settings/apps/new?state=${next.state}` + : `https://github.com/settings/apps/new?state=${next.state}`; + return withNoStore(successResponse({ actionUrl, manifest }, { status: 200 }, req)); + }); +}); diff --git a/src/app/api/setup/setupAccess.ts b/src/app/api/setup/setupAccess.ts new file mode 100644 index 00000000..d28a4206 --- /dev/null +++ b/src/app/api/setup/setupAccess.ts @@ -0,0 +1,253 @@ +/** + * Copyright 2026 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 at http://www.apache.org/licenses/LICENSE-2.0 + */ +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto'; +import type { V1Secret } from '@kubernetes/client-node'; +import type { Knex } from 'knex'; +import type { NextRequest } from 'next/server'; +import { AppError, BadRequestError } from 'server/lib/appError'; +import { getCurrentNamespaceFromFile, getK8sApi } from 'server/lib/kubernetes'; +import { getLogger } from 'server/lib/logger'; +import GlobalConfigService from 'server/services/globalConfig'; +import type { AppSetup } from 'server/services/types/globalConfig'; +import { + GITHUB_APP_ID, + GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET, + GITHUB_PRIVATE_KEY, + GITHUB_WEBHOOK_SECRET, + LIFECYCLE_SETUP_TOKEN, + SECRET_BOOTSTRAP_NAME, +} from 'shared/config'; + +const PLACEHOLDERS = new Set(['', 'your_value_here', 'not_setup', 'replace_me', 'changeme']); +const CREDENTIAL_KEYS = [ + 'GITHUB_APP_ID', + 'GITHUB_CLIENT_ID', + 'GITHUB_CLIENT_SECRET', + 'GITHUB_PRIVATE_KEY', + 'GITHUB_WEBHOOK_SECRET', +] as const; +const FLOW_ANNOTATION = 'lifecycle.dev/setup-flow'; +const URL_ANNOTATION = 'lifecycle.dev/setup-app-url'; +const NAME_ANNOTATION = 'lifecycle.dev/setup-app-name'; +const FINGERPRINT_ANNOTATION = 'lifecycle.dev/setup-credential-sha256'; +const FLOW_TTL_MS = 60 * 60 * 1000; + +export type SetupFlow = Partial & { + flowVersion?: 1; + flowId?: string; + phase?: 'manifest' | 'installation' | 'installed' | 'complete'; + expiresAt?: number; + credentialFingerprint?: string; +}; +export type BootstrapSecret = { body: V1Secret | null; values: Record }; + +function real(value: unknown): boolean { + return !PLACEHOLDERS.has( + String(value ?? '') + .trim() + .toLowerCase() + ); +} +function equal(expected: string, presented: string): boolean { + const digest = (value: string) => createHash('sha256').update(value).digest(); + return timingSafeEqual(digest(expected), digest(presented)); +} +export function isEnvConfigured(): boolean { + return [GITHUB_APP_ID, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET].some(real); +} +export function hasCredentials(secret: BootstrapSecret): boolean { + return CREDENTIAL_KEYS.some((key) => real(secret.values[key])); +} +export function credentialFingerprint(secret: BootstrapSecret): string { + return createHash('sha256') + .update(JSON.stringify(CREDENTIAL_KEYS.map((key) => secret.values[key] || ''))) + .digest('hex'); +} +export function getSetupToken(): string { + if (real(LIFECYCLE_SETUP_TOKEN)) return String(LIFECYCLE_SETUP_TOKEN).trim(); + const key = process.env.ENCRYPTION_KEY?.trim(); + if (real(key)) return createHmac('sha256', key!).update('lifecycle-setup-token').digest('hex').slice(0, 32); + throw new AppError({ + httpStatus: 503, + code: 'setup_token_unavailable', + message: 'Configure a shared setup token before starting setup.', + }); +} +export function assertSetupActor(req: NextRequest): void { + const presented = req.headers.get('x-setup-token'); + if (!presented) + throw new AppError({ + httpStatus: 401, + code: 'setup_token_required', + message: 'Enter the setup token to continue.', + }); + if (!equal(getSetupToken(), presented)) + throw new AppError({ httpStatus: 401, code: 'invalid_setup_token', message: 'That setup token is not valid.' }); +} +export function setupLocked(): AppError { + return new AppError({ + httpStatus: 403, + code: 'setup_locked', + message: 'GitHub App setup is locked. Manage the configured App through your installation settings.', + }); +} +export function newSetupState(): string { + return randomBytes(24).toString('base64url'); +} +export function activeFlow(flow: SetupFlow): boolean { + return ( + flow.flowVersion === 1 && + !!flow.flowId && + Number.isFinite(flow.expiresAt) && + flow.expiresAt! > Date.now() && + flow.phase !== 'complete' + ); +} +export function assertSetupState(flow: SetupFlow, presented: string | null, phase: SetupFlow['phase']): void { + if (!activeFlow(flow) || flow.phase !== phase || !flow.state || !presented || !equal(flow.state, presented)) { + throw new BadRequestError('This GitHub link has expired or was used. Start setup again.', 'invalid_setup_state'); + } +} +export function nextFlow(flow: SetupFlow, phase: SetupFlow['phase']): SetupFlow { + return { + ...flow, + flowVersion: 1, + flowId: flow.flowId || newSetupState(), + phase, + state: newSetupState(), + expiresAt: Date.now() + FLOW_TTL_MS, + }; +} +function bootstrapApi() { + const api = getK8sApi(); + api.addInterceptor((options) => { + options.timeout = 15_000; + }); + return api; +} +export async function readBootstrapSecret(): Promise { + try { + const { body } = await bootstrapApi().readNamespacedSecret(SECRET_BOOTSTRAP_NAME, getCurrentNamespaceFromFile()); + return { + body, + values: Object.fromEntries( + Object.entries(body.data || {}).map(([key, value]) => [key, Buffer.from(value, 'base64').toString('utf8')]) + ), + }; + } catch (error) { + const status = + (error as { response?: { statusCode?: number }; statusCode?: number }).response?.statusCode ?? + (error as { statusCode?: number }).statusCode; + if (status === 404) return { body: null, values: {} }; + throw new AppError({ + httpStatus: 503, + code: 'setup_state_unavailable', + message: 'Lifecycle could not read its GitHub App settings. Try again.', + }); + } +} +export async function writeBootstrapSecret( + secret: BootstrapSecret, + values: Record, + flow?: { id: string; url: string; name: string } +): Promise { + const body: V1Secret = { + ...secret.body, + apiVersion: 'v1', + kind: 'Secret', + metadata: { + ...secret.body?.metadata, + name: SECRET_BOOTSTRAP_NAME, + ...(flow + ? { + annotations: { + ...secret.body?.metadata?.annotations, + [FLOW_ANNOTATION]: flow.id, + [URL_ANNOTATION]: flow.url, + [NAME_ANNOTATION]: flow.name, + [FINGERPRINT_ANNOTATION]: credentialFingerprint({ body: null, values: { ...secret.values, ...values } }), + }, + } + : {}), + }, + data: { + ...secret.body?.data, + ...Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Buffer.from(value).toString('base64')])), + }, + }; + try { + const api = bootstrapApi(); + if (secret.body) await api.replaceNamespacedSecret(SECRET_BOOTSTRAP_NAME, getCurrentNamespaceFromFile(), body); + else await api.createNamespacedSecret(getCurrentNamespaceFromFile(), body); + } catch { + throw new AppError({ + httpStatus: 503, + code: 'setup_secret_write_failed', + message: 'Lifecycle could not save the GitHub App settings. Retry this step.', + }); + } +} +export function recoveredApp(flow: SetupFlow, secret: BootstrapSecret): { url: string; name: string } | null { + const annotations = secret.body?.metadata?.annotations; + if ( + flow.flowVersion !== 1 || + !flow.flowId || + flow.phase === 'complete' || + !hasCredentials(secret) || + annotations?.[FLOW_ANNOTATION] !== flow.flowId + ) + return null; + const fingerprint = credentialFingerprint(secret); + if ( + annotations[FINGERPRINT_ANNOTATION] !== fingerprint || + (flow.credentialFingerprint && flow.credentialFingerprint !== fingerprint) + ) + return null; + if (!CREDENTIAL_KEYS.every((key) => real(secret.values[key]))) return null; + const url = annotations[URL_ANNOTATION]; + if (!validGithubAppUrl(url)) return null; + return { url, name: annotations[NAME_ANNOTATION] || '' }; +} +export function validGithubAppUrl(value: unknown): value is string { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return ( + url.protocol === 'https:' && + url.hostname === 'github.com' && + /^\/apps\/[a-zA-Z0-9-]+$/.test(url.pathname) && + !url.username && + !url.password && + !url.search && + !url.hash + ); + } catch { + return false; + } +} +export function assertCurrentApp(flow: SetupFlow, secret: BootstrapSecret): void { + if (!recoveredApp(flow, secret) || flow.credentialFingerprint !== credentialFingerprint(secret)) throw setupLocked(); +} +export async function withSetupTransaction( + action: (flow: SetupFlow, trx: Knex.Transaction, save: (flow: SetupFlow) => Promise) => Promise +): Promise { + const service = GlobalConfigService.getInstance(); + let changed = false; + const result = await service.db.knex.transaction(async (trx) => { + await trx.raw("SET LOCAL lock_timeout = '5s'"); + await trx('global_config').insert({ key: 'app_setup', config: {} }).onConflict('key').ignore(); + const row = await trx('global_config').where({ key: 'app_setup' }).forUpdate().first(); + const flow = (typeof row.config === 'string' ? JSON.parse(row.config) : row.config) as SetupFlow; + return action(flow || {}, trx, async (next) => { + await trx('global_config').where({ key: 'app_setup' }).update({ config: next }); + changed = true; + }); + }); + if (changed) await service.invalidateCache().catch(() => getLogger().warn('Setup: config cache invalidation failed')); + return result; +} diff --git a/src/app/api/setup/status/route.ts b/src/app/api/setup/status/route.ts new file mode 100644 index 00000000..1a40b3cf --- /dev/null +++ b/src/app/api/setup/status/route.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2026 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 type { NextRequest } from 'next/server'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, withNoStore } from 'server/lib/response'; +import GlobalConfigService from 'server/services/globalConfig'; +import { hasCredentials, isEnvConfigured, readBootstrapSecret, recoveredApp, type SetupFlow } from '../setupAccess'; + +export const runtime = 'nodejs'; +export const GET = createPublicApiHandler(async (req: NextRequest) => { + const flow = ((await GlobalConfigService.getInstance().getConfig('app_setup')) || {}) as SetupFlow; + const secret = await readBootstrapSecret(); + const locked = + (hasCredentials(secret) || isEnvConfigured()) && (!recoveredApp(flow, secret) || flow.phase === 'complete'); + return withNoStore( + successResponse( + { + installed: !!flow.installed, + created: !!flow.created, + restarted: !!flow.restarted, + url: flow.url || '', + locked, + }, + { status: 200 }, + req + ) + ); +}); diff --git a/src/components/SetupTokenField.tsx b/src/components/SetupTokenField.tsx new file mode 100644 index 00000000..6ba4cfe7 --- /dev/null +++ b/src/components/SetupTokenField.tsx @@ -0,0 +1,52 @@ +/** + * Copyright 2026 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 React from 'react'; + +const INPUT_STYLE: React.CSSProperties = { + width: '100%', + marginTop: 4, + padding: '0.7em', + borderRadius: 8, + border: '1px solid #ddd', + fontSize: '1rem', + marginBottom: 2, +}; + +type SetupTokenFieldProps = { + value: string; + onChange: (value: string) => void; +}; + +export function SetupTokenField({ value, onChange }: SetupTokenFieldProps) { + return ( +
+ +
+ Use the operator token from LIFECYCLE_SETUP_TOKEN or the setup instructions. +
+
+ ); +} diff --git a/src/middleware.test.ts b/src/middleware.test.ts index fa8054a0..3fb2d9f0 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -41,6 +41,6 @@ describe('API middleware composition', () => { expect(mockChain).toHaveBeenCalledWith([mockCorsMiddleware, mockRequestIdMiddleware, mockAuthMiddleware]); expect(loaded.middleware).toBe(mockCompiledMiddleware); - expect(loaded.config.matcher).toEqual(['/api/v1/:path*', '/api/v2/:path*']); + expect(loaded.config.matcher).toEqual(['/api/v2/:path*']); }); }); diff --git a/src/middleware.ts b/src/middleware.ts index a91dec77..6a9c6e05 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -23,5 +23,5 @@ const middlewares = [corsMiddleware, requestIdMiddleware, authMiddleware]; export const middleware = chain(middlewares); export const config = { - matcher: ['/api/v1/:path*', '/api/v2/:path*'], + matcher: ['/api/v2/:path*'], }; diff --git a/src/pages/api/v1/admin/ttl/cleanup.ts b/src/pages/api/v1/admin/ttl/cleanup.ts deleted file mode 100644 index 39452bb4..00000000 --- a/src/pages/api/v1/admin/ttl/cleanup.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next'; -import { nanoid } from 'nanoid'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import TTLCleanupService from 'server/services/ttlCleanup'; - -/** - * @openapi - * /api/v1/admin/ttl/cleanup: - * get: - * summary: Get TTL cleanup configuration - * description: Retrieves the current TTL cleanup configuration from global config - * tags: - * - Admin - * - TTL Cleanup - * responses: - * 200: - * description: Successfully retrieved TTL cleanup configuration - * content: - * application/json: - * schema: - * type: object - * properties: - * config: - * type: object - * properties: - * enabled: - * type: boolean - * description: Whether TTL cleanup is enabled - * dryRun: - * type: boolean - * description: Whether cleanup runs in dry-run mode - * inactivityDays: - * type: number - * description: Number of days of inactivity before cleanup - * checkIntervalMinutes: - * type: number - * description: How often cleanup job runs (in minutes) - * commentTemplate: - * type: string - * description: Template for PR comments - * excludedRepositories: - * type: array - * items: - * type: string - * description: List of repositories excluded from cleanup - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: DELETE is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to retrieve TTL cleanup configuration - * post: - * summary: Manually trigger TTL cleanup - * description: Manually triggers a TTL cleanup job with optional configuration override - * tags: - * - Admin - * - TTL Cleanup - * requestBody: - * required: false - * content: - * application/json: - * schema: - * type: object - * properties: - * dryRun: - * type: boolean - * description: Override dry-run mode for this execution (optional) - * responses: - * 200: - * description: Successfully triggered TTL cleanup job - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: TTL cleanup job triggered successfully - * jobId: - * type: string - * description: The ID of the queued job - * dryRun: - * type: boolean - * description: Whether this job will run in dry-run mode - * 400: - * description: Bad request - invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: dryRun must be a boolean value - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: DELETE is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to trigger TTL cleanup job - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - try { - switch (req.method) { - case 'GET': - return getTTLConfig(res); - case 'POST': - return triggerTTLCleanup(req, res); - default: - res.setHeader('Allow', ['GET', 'POST']); - return res.status(405).json({ error: `${req.method} is not allowed.` }); - } - } catch (error) { - getLogger().error({ error }, 'TTL: cleanup operation failed'); - res.status(500).json({ error: 'An unexpected error occurred.' }); - } -}; - -async function getTTLConfig(res: NextApiResponse) { - try { - const configService = GlobalConfigService.getInstance(); - const globalConfig = await configService.getAllConfigs(); - const ttlConfig = globalConfig.ttl_cleanup; - - if (!ttlConfig) { - getLogger().warn('TTL: config not found'); - return res.status(404).json({ error: 'TTL cleanup configuration not found' }); - } - - return res.status(200).json({ config: ttlConfig }); - } catch (error) { - getLogger().error({ error }, 'TTL: config retrieval failed'); - return res.status(500).json({ error: 'Unable to retrieve TTL cleanup configuration' }); - } -} - -async function triggerTTLCleanup(req: NextApiRequest, res: NextApiResponse) { - const correlationId = `api-ttl-cleanup-${Date.now()}-${nanoid(8)}`; - - return withLogContext({ correlationId }, async () => { - try { - const { dryRun = false } = req.body || {}; - - // Validate dryRun parameter type - if (typeof dryRun !== 'boolean') { - return res.status(400).json({ error: 'dryRun must be a boolean value' }); - } - - // Create new service instance and add job to queue - const ttlCleanupService = new TTLCleanupService(); - const job = await ttlCleanupService.ttlCleanupQueue.add('manual-ttl-cleanup', { dryRun, correlationId }); - - getLogger({ stage: LogStage.CLEANUP_STARTING }).info( - `TTL: cleanup job triggered manually jobId=${job.id} dryRun=${dryRun}` - ); - - return res.status(200).json({ - message: 'TTL cleanup job triggered successfully', - jobId: job.id, - dryRun, - }); - } catch (error) { - getLogger({ stage: LogStage.CLEANUP_FAILED }).error({ error }, 'TTL: cleanup trigger failed'); - return res.status(500).json({ error: 'Unable to trigger TTL cleanup job' }); - } - }); -} diff --git a/src/pages/api/v1/builds/[uuid]/deploy.ts b/src/pages/api/v1/builds/[uuid]/deploy.ts deleted file mode 100644 index 8e4c3204..00000000 --- a/src/pages/api/v1/builds/[uuid]/deploy.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import { Build } from 'server/models'; -import { nanoid } from 'nanoid'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds/{uuid}/deploy: - * post: - * summary: Redeploy an entire build - * description: | - * Triggers a redeployment of all services within a 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: - * type: object - * properties: - * status: - * type: string - * example: success - * message: - * type: string - * example: Redeploy for build abc-123 has been queued - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found for abc-123 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to proceed with redeploy for build abc-123. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - const correlationId = `api-redeploy-${Date.now()}-${nanoid(8)}`; - const { uuid } = req.query; - - return withLogContext({ correlationId, buildUuid: uuid as string }, async () => { - if (req.method !== 'POST') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - try { - getLogger({ stage: LogStage.BUILD_QUEUED }).info('Build: redeploy requested'); - - const buildService = new BuildService(); - const build: Build = await buildService.db.models.Build.query() - .findOne({ uuid }) - .withGraphFetched('deploys.deployable'); - - if (!build) { - getLogger().debug('Build not found'); - return res.status(404).json({ error: `Build not found for ${uuid}` }); - } - - const buildId = build.id; - const runUUID = nanoid(); - await buildService.enqueueResolveAndDeployBuild({ - buildId, - runUUID, - }); - - getLogger({ stage: LogStage.BUILD_QUEUED }).info('Build: redeploy queued'); - - return res.status(200).json({ - status: 'success', - message: `Redeploy for build ${uuid} has been queued`, - }); - } catch (error) { - getLogger({ stage: LogStage.BUILD_FAILED }).error({ error }, 'Build: redeploy failed'); - return res.status(500).json({ error: `Unable to proceed with redeploy for build ${uuid}.` }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/graph.ts b/src/pages/api/v1/builds/[uuid]/graph.ts deleted file mode 100644 index 7493abcf..00000000 --- a/src/pages/api/v1/builds/[uuid]/graph.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { generateGraph } from 'server/lib/dependencyGraph'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { Build } from 'server/models'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds/{uuid}/graph: - * get: - * summary: Get build dependency graph - * description: | - * Retrieves the dependency graph for a specific build. If the graph doesn't exist, - * it will be generated and stored before being returned. The graph represents the - * relationships between different deployables in the build. - * tags: - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * responses: - * 200: - * description: Successfully retrieved dependency graph - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: success - * message: - * type: string - * example: Dependency graph for abc-123 returned. - * dependencyGraph: - * type: object - * description: The dependency graph structure - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed - * 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; - - return withLogContext({ buildUuid: uuid as string }, async () => { - try { - const buildService = new BuildService(); - - const build: Build = await buildService.db.models.Build.query() - .findOne({ - uuid, - }) - .withGraphFetched('[deploys.deployable, deployables]'); - - if (Object.keys(build.dependencyGraph).length === 0) { - const dependencyGraph = await generateGraph(build, 'TB'); - await build.$query().patchAndFetch({ - dependencyGraph, - }); - } - - return res.status(200).json({ - status: 'success', - message: `Dependency graph for ${uuid} returned.`, - dependencyGraph: build.dependencyGraph, - }); - } catch (error) { - getLogger().error({ error }, 'Build: dependency graph fetch failed'); - res.status(500).json({ error: 'An unexpected error occurred.' }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/index.ts b/src/pages/api/v1/builds/[uuid]/index.ts deleted file mode 100644 index 974c0ebe..00000000 --- a/src/pages/api/v1/builds/[uuid]/index.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * 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 { nanoid } from 'nanoid'; -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import { validateBuildUuidFormat } from 'server/lib/validation/buildUuidValidator'; -import BuildService from 'server/services/build'; -import OverrideService, { BuildUuidValidationError } from 'server/services/override'; - -async function retrieveBuild(req: NextApiRequest, res: NextApiResponse) { - try { - const { uuid } = req.query; - const buildService = new BuildService(); - - const build = await buildService.db.models.Build.query() - .findOne({ uuid }) - .select( - 'id', - 'uuid', - 'status', - 'statusMessage', - 'sha', - 'createdAt', - 'updatedAt', - 'deletedAt', - 'pullRequestId', - 'manifest', - 'webhooksYaml', - 'isStatic', - 'namespace' - ); - - if (!build) { - getLogger().debug('Build not found'); - return res.status(404).json({ error: 'Build not found' }); - } - - return res.status(200).json(build); - } catch (error) { - getLogger({ error }).error('API: build fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -} - -async function updateBuild(req: NextApiRequest, res: NextApiResponse, correlationId: string) { - const { uuid } = req.query; - const { uuid: newUuid } = req.body; - - if (newUuid == null) { - getLogger().debug('Missing or invalid uuid in request body'); - return res.status(400).json({ error: 'uuid is required' }); - } - - if (typeof newUuid !== 'string') { - getLogger().debug('Invalid uuid in request body'); - return res.status(400).json({ error: 'uuid must be a string' }); - } - - const uuidFormatError = validateBuildUuidFormat(newUuid); - if (uuidFormatError) { - getLogger().debug(`UUID validation failed: error=${uuidFormatError}`); - return res.status(400).json({ error: uuidFormatError }); - } - - try { - const override = new OverrideService(); - - const build = await override.db.models.Build.query().findOne({ uuid }).withGraphFetched('pullRequest'); - - if (!build) { - getLogger().debug('Build not found, cannot patch uuid'); - return res.status(404).json({ error: 'Build not found' }); - } - - if (newUuid === build.uuid) { - getLogger().debug(`Attempted to update UUID to same value: newUuid=${newUuid}`); - return res.status(400).json({ error: 'UUID must be different' }); - } - - const validation = await override.validateUuid(newUuid, build.id); - if (!validation.valid) { - getLogger().debug(`UUID validation failed: error=${validation.error}`); - return res.status(400).json({ error: validation.error }); - } - - const result = await override.updateBuildUuid(build, newUuid); - - if (build.pullRequest?.deployOnUpdate) { - getLogger({ stage: LogStage.BUILD_QUEUED }).info(`Triggering redeploy after UUID update`); - await new BuildService().enqueueResolveAndDeployBuild({ - buildId: build.id, - runUUID: nanoid(), - }); - } - - return res.status(200).json({ - data: { - ...result.build, - }, - }); - } catch (error) { - if (error instanceof BuildUuidValidationError) { - getLogger().debug(`UUID validation failed: error=${error.message}`); - return res.status(400).json({ error: error.message }); - } - - getLogger({ error }).error(`API: UUID update failed newUuid=${newUuid}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -} - -/** - * @openapi - * /api/v1/builds/{uuid}: - * get: - * summary: Get build by UUID - * description: | - * Retrieves detailed information about a specific build by its UUID. - * tags: - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * responses: - * 200: - * description: Successfully retrieved build details - * content: - * application/json: - * schema: - * type: object - * properties: - * id: - * type: integer - * uuid: - * type: string - * status: - * type: string - * statusMessage: - * type: string - * sha: - * type: string - * createdAt: - * type: string - * format: date-time - * updatedAt: - * type: string - * format: date-time - * deletedAt: - * type: string - * format: date-time - * nullable: true - * pullRequestId: - * type: integer - * manifest: - * type: object - * webhooksYaml: - * type: object - * isStatic: - * type: boolean - * namespace: - * type: string - * 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 - * 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 - * minLength: 3 - * maxLength: 50 - * pattern: '^[a-z0-9-]+$' - * description: The new UUID (3-50 characters, lowercase letters, numbers, and 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 - * 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: {} - * 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 lowercase 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) => { - const { uuid } = req.query; - - if (!uuid || typeof uuid !== 'string') { - return res.status(400).json({ error: 'Invalid UUID' }); - } - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method === 'PATCH') { - const correlationId = `api-build-update-${Date.now()}-${nanoid(8)}`; - return withLogContext({ correlationId }, async () => { - return updateBuild(req, res, correlationId); - }); - } - - switch (req.method) { - case 'GET': - return retrieveBuild(req, res); - default: - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events.ts b/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events.ts deleted file mode 100644 index 2a1e8cf6..00000000 --- a/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * 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. - */ - -/** - * @openapi - * /api/v1/builds/{uuid}/jobs/{jobName}/events: - * get: - * summary: Get Kubernetes events for a specific job - * description: | - * Retrieves all Kubernetes events related to a specific job and its pods. - * Events are sorted by timestamp with the most recent events first. - * tags: - * - Jobs - * - Events - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build environment - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the Kubernetes job - * responses: - * 200: - * description: Successful response with events list - * content: - * application/json: - * schema: - * type: object - * properties: - * events: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * description: Event name - * namespace: - * type: string - * description: Kubernetes namespace - * reason: - * type: string - * description: Event reason (e.g., Created, Started, Failed) - * message: - * type: string - * description: Detailed event message - * type: - * type: string - * description: Event type (Normal or Warning) - * count: - * type: number - * description: Number of times this event has occurred - * firstTimestamp: - * type: string - * format: date-time - * description: When this event first occurred - * lastTimestamp: - * type: string - * format: date-time - * description: When this event last occurred - * eventTime: - * type: string - * format: date-time - * description: Event time (newer API field) - * source: - * type: object - * properties: - * component: - * type: string - * description: Component that reported the event - * host: - * type: string - * description: Host where the event was reported - * 400: - * description: Bad request - missing or invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Missing or invalid uuid or jobName parameters - * 404: - * description: Environment or job not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Environment or job not found. - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Internal server error occurred. - * 502: - * description: Bad gateway - failed to communicate with Kubernetes - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Failed to communicate with Kubernetes. - */ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import * as k8s from '@kubernetes/client-node'; -import { HttpError } from '@kubernetes/client-node'; - -interface K8sEvent { - name: string; - namespace: string; - reason: string; - message: string; - type: string; - count: number; - firstTimestamp?: string; - lastTimestamp?: string; - eventTime?: string; - source?: { - component?: string; - host?: string; - }; -} - -interface EventsResponse { - events: K8sEvent[]; -} - -async function getJobEvents(jobName: string, namespace: string): Promise { - const kc = new k8s.KubeConfig(); - kc.loadFromDefault(); - const coreV1Api = kc.makeApiClient(k8s.CoreV1Api); - - try { - const eventsResponse = await coreV1Api.listNamespacedEvent(namespace); - const allEvents = eventsResponse.body.items || []; - - const jobEvents = allEvents.filter((event) => { - const involvedObject = event.involvedObject; - if (!involvedObject) return false; - - if (involvedObject.kind === 'Job' && involvedObject.name === jobName) { - return true; - } - - if (involvedObject.kind === 'Pod' && involvedObject.name?.startsWith(jobName)) { - return true; - } - - return false; - }); - - const events: K8sEvent[] = jobEvents.map((event) => ({ - name: event.metadata?.name || '', - namespace: event.metadata?.namespace || '', - reason: event.reason || '', - message: event.message || '', - type: event.type || 'Normal', - count: event.count || 1, - firstTimestamp: event.firstTimestamp, - lastTimestamp: event.lastTimestamp, - eventTime: event.eventTime, - source: event.source - ? { - component: event.source.component, - host: event.source.host, - } - : undefined, - })); - - events.sort((a, b) => { - const aTime = new Date(a.lastTimestamp || a.eventTime || 0).getTime(); - const bTime = new Date(b.lastTimestamp || b.eventTime || 0).getTime(); - return bTime - aTime; - }); - - return events; - } catch (error) { - getLogger().error({ error }, `API: events fetch failed jobName=${jobName}`); - throw error; - } -} - -const eventsHandler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, jobName } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - if (typeof uuid !== 'string' || typeof jobName !== 'string') { - getLogger().warn(`API: invalid params uuid=${uuid} jobName=${jobName}`); - return res.status(400).json({ error: 'Missing or invalid uuid or jobName parameters' }); - } - - try { - const namespace = `env-${uuid}`; - - const events = await getJobEvents(jobName, namespace); - - const response: EventsResponse = { - events, - }; - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, `API: events fetch failed jobName=${jobName}`); - - if (error instanceof HttpError) { - if (error.response?.statusCode === 404) { - return res.status(404).json({ error: 'Environment or job not found.' }); - } - return res.status(502).json({ error: 'Failed to communicate with Kubernetes.' }); - } - - return res.status(500).json({ error: 'Internal server error occurred.' }); - } - }); -}; - -export default eventsHandler; diff --git a/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs.ts b/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs.ts deleted file mode 100644 index 99d08ed4..00000000 --- a/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 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 type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import unifiedLogStreamHandler from '../../services/[name]/logs/[jobName]'; - -/** - * @openapi - * /api/v1/builds/{uuid}/jobs/{jobName}/logs: - * get: - * summary: Get job log streaming information for webhooks - * description: | - * Returns WebSocket endpoint and parameters for streaming job logs from Kubernetes. - * This endpoint provides information needed to establish a WebSocket connection - * for real-time log streaming from webhook jobs. - * tags: - * - Webhooks - * - Jobs - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the job - * responses: - * 200: - * description: Successful response with WebSocket information - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [Active, Complete, Failed, NotFound, Pending] - * description: Current status of the job - * websocket: - * type: object - * properties: - * endpoint: - * type: string - * example: /api/logs/stream - * parameters: - * type: object - * properties: - * podName: - * type: string - * namespace: - * type: string - * follow: - * type: boolean - * timestamps: - * type: boolean - * container: - * type: string - * required: false - * containers: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * state: - * type: string - * error: - * type: string - * description: Error message if applicable - * 400: - * description: Bad request - missing or invalid parameters - * 404: - * description: Build or job not found - * 405: - * description: Method not allowed - * 500: - * description: Internal server error - */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { uuid, jobName } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - getLogger().info(`API: job logs called method=${req.method} jobName=${jobName}`); - - req.query.type = 'webhook'; - - req.query.name = undefined; - - return unifiedLogStreamHandler(req, res); - }); -} diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/build.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/build.ts deleted file mode 100644 index 85f34993..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/build.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import { nanoid } from 'nanoid'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/build: - * post: - * summary: Redeploy a service within a build - * description: | - * Triggers a redeployment of a specific service within a build. 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 build - * - 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: - * type: object - * properties: - * status: - * type: string - * example: success - * message: - * type: string - * example: Redeploy for service example-service in build abc-123 has been queued - * 404: - * description: Build or service not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found for abc-123 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to proceed with redeploy for services example-service in build abc-123. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'POST') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { uuid, name } = req.query; - const correlationId = `api-service-redeploy-${Date.now()}-${nanoid(8)}`; - - return withLogContext({ correlationId, buildUuid: uuid as string }, async () => { - try { - const buildService = new BuildService(); - const build = await buildService.db.models.Build.query() - .findOne({ - uuid, - }) - .withGraphFetched('deploys.deployable'); - - if (!build) { - getLogger().debug(`Build not found`); - return res.status(404).json({ error: `Build not found for ${uuid}` }); - } - - const deploy = build.deploys?.find((candidate) => candidate.deployable?.name === name); - - if (!deploy?.deployable) { - getLogger().debug(`Deployable not found: service=${name}`); - res.status(404).json({ error: `${name} service is not found in ${uuid} build.` }); - return; - } - - const githubRepositoryId = - deploy.deployable.resolvedFromRepositoryId ?? deploy.deployable.repositoryId ?? deploy.githubRepositoryId; - if (githubRepositoryId == null) { - throw new Error(`Cannot redeploy ${name}: source repository is unknown.`); - } - - const runUUID = nanoid(); - await buildService.enqueueResolveAndDeployBuild({ - buildId: build.id, - githubRepositoryId: Number(githubRepositoryId), - runUUID, - }); - - getLogger({ stage: LogStage.BUILD_QUEUED }).info(`Build: service redeploy queued service=${name}`); - return res.status(200).json({ - status: 'success', - message: `Redeploy for service ${name} in build ${uuid} has been queued`, - }); - } catch (error) { - getLogger({ stage: LogStage.BUILD_FAILED }).error( - { error }, - `Unable to proceed with redeploy for services ${name} in build ${uuid}` - ); - return res.status(500).json({ error: `Unable to proceed with redeploy for services ${name} in build ${uuid}.` }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs.ts deleted file mode 100644 index cda9a20b..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * 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 type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { HttpError } from '@kubernetes/client-node'; -import { BuildJobInfo, getNativeBuildJobs } from 'server/lib/kubernetes/getNativeBuildJobs'; - -interface BuildLogsListResponse { - builds: BuildJobInfo[]; -} - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/buildLogs: - * get: - * summary: List build jobs for a service - * description: | - * Returns a list of all build jobs for a specific service within a build. - * This includes both active and completed build jobs with their status, - * timing information, and the build engine used. - * tags: - * - Builds - * - Native Build - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * responses: - * '200': - * description: List of build jobs - * content: - * application/json: - * schema: - * type: object - * properties: - * builds: - * type: array - * items: - * type: object - * properties: - * jobName: - * type: string - * description: Kubernetes job name - * example: build-api-abc123-1234567890 - * buildUuid: - * type: string - * description: Deploy UUID - * example: api-abc123 - * sha: - * type: string - * description: Git commit SHA - * example: a1b2c3d4e5f6 - * status: - * type: string - * enum: [Active, Complete, Failed, Pending] - * description: Current status of the build job - * startedAt: - * type: string - * format: date-time - * description: When the job started - * completedAt: - * type: string - * format: date-time - * description: When the job completed - * duration: - * type: number - * description: Build duration in seconds - * engine: - * type: string - * enum: [buildkit, kaniko, unknown] - * description: Build engine used - * podName: - * type: string - * description: Kubernetes pod name associated with the build job - * error: - * type: string - * description: Error message if the build job failed - * source: - * type: string - * enum: [live, archived] - * description: Whether the job is from a live k8s resource or archived in object storage - * '400': - * description: Invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '404': - * description: Environment or service not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '405': - * description: Method not allowed (only GET is supported) - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed - * '502': - * description: Failed to communicate with Kubernetes - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '500': - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Internal server error occurred. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, name } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - if (typeof uuid !== 'string' || typeof name !== 'string') { - getLogger().warn(`API: invalid params uuid=${uuid} name=${name}`); - return res.status(400).json({ error: 'Missing or invalid uuid or name parameters' }); - } - - try { - const namespace = `env-${uuid}`; - - const buildJobs = await getNativeBuildJobs(name, namespace); - - const response: BuildLogsListResponse = { - builds: buildJobs, - }; - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, `API: build logs fetch failed service=${name}`); - - if (error instanceof HttpError) { - if (error.response?.statusCode === 404) { - return res.status(404).json({ error: 'Environment or service not found.' }); - } - return res.status(502).json({ error: 'Failed to communicate with Kubernetes.' }); - } - - return res.status(500).json({ error: 'Internal server error occurred.' }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs/[jobName].ts b/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs/[jobName].ts deleted file mode 100644 index c4577014..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs/[jobName].ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * 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 type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import unifiedLogStreamHandler from '../logs/[jobName]'; - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/buildLogs/{jobName}: - * get: - * summary: Get build log streaming information for a specific job - * description: | - * Returns WebSocket endpoint and parameters for streaming build logs from Kubernetes. - * This endpoint provides information needed to establish a WebSocket connection - * for real-time log streaming. - * tags: - * - Builds - * - Native Build - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the build job - * responses: - * 200: - * description: Successful response with WebSocket information - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [Active, Complete, Failed, NotFound] - * description: Current status of the build job - * websocket: - * type: object - * properties: - * endpoint: - * type: string - * example: wss://example.com/k8s/log/namespace/pod-name/container - * parameters: - * type: object - * properties: - * podName: - * type: string - * namespace: - * type: string - * follow: - * type: boolean - * timestamps: - * type: boolean - * containers: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * state: - * type: string - * 400: - * description: Bad request - * 404: - * description: Build or deploy not found - * 405: - * description: Method not allowed - * 500: - * description: Internal server error - */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { uuid, jobName } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - getLogger().info(`API: build logs endpoint called method=${req.method} jobName=${jobName}`); - - req.query.type = 'build'; - - return unifiedLogStreamHandler(req, res); - }); -} diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts deleted file mode 100644 index 3faa730a..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * 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 type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { HttpError } from '@kubernetes/client-node'; -import { DeploymentJobInfo, getDeploymentJobs } from 'server/lib/kubernetes/getDeploymentJobs'; - -interface DeployLogsListResponse { - deployments: DeploymentJobInfo[]; -} - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/deployLogs: - * get: - * summary: List deployment jobs for a service - * description: | - * Returns a list of all deployment jobs for a specific service within a build. - * This includes both Helm deployment jobs and GitHub-type deployment jobs. - * tags: - * - Deployments - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * responses: - * '200': - * description: List of deployment jobs - * content: - * application/json: - * schema: - * type: object - * properties: - * deployments: - * type: array - * items: - * type: object - * properties: - * jobName: - * type: string - * description: Kubernetes job name - * example: deploy-uuid-helm-123-abc123 - * deployUuid: - * type: string - * description: Deploy UUID - * example: deploy-uuid - * sha: - * type: string - * description: Git commit SHA - * example: abc123 - * status: - * type: string - * enum: [Active, Complete, Failed, Pending] - * description: Current status of the deployment job - * startedAt: - * type: string - * format: date-time - * description: When the job started - * completedAt: - * type: string - * format: date-time - * description: When the job completed - * duration: - * type: number - * description: Deployment duration in seconds - * error: - * type: string - * description: Error message if job failed - * podName: - * type: string - * description: Name of the pod running the job - * deploymentType: - * type: string - * enum: [helm, github] - * description: Type of deployment (helm or github) - * source: - * type: string - * enum: [live, archived] - * description: Whether the job is from a live k8s resource or archived in object storage - * '400': - * description: Invalid parameters - * '404': - * description: Environment or service not found - * '405': - * description: Method not allowed - * '502': - * description: Failed to communicate with Kubernetes - * '500': - * description: Internal server error - */ -const deployLogsHandler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, name } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - if (typeof uuid !== 'string' || typeof name !== 'string') { - getLogger().warn(`API: invalid params uuid=${uuid} name=${name}`); - return res.status(400).json({ error: 'Missing or invalid uuid or name parameters' }); - } - - try { - const namespace = `env-${uuid}`; - - const deployments = await getDeploymentJobs(name, namespace); - - const response: DeployLogsListResponse = { - deployments, - }; - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, `API: deploy logs fetch failed service=${name}`); - - if (error instanceof HttpError) { - if (error.response?.statusCode === 404) { - return res.status(404).json({ error: 'Environment or service not found.' }); - } - return res.status(502).json({ error: 'Failed to communicate with Kubernetes.' }); - } - - return res.status(500).json({ error: 'Internal server error occurred.' }); - } - }); -}; - -export default deployLogsHandler; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs/[jobName].ts b/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs/[jobName].ts deleted file mode 100644 index 02ef1bf9..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs/[jobName].ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * 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. - */ - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/deployLogs/{jobName}: - * get: - * summary: Get deployment log streaming information for a specific job - * description: | - * Returns WebSocket endpoint and parameters for streaming deployment logs from Kubernetes. - * This endpoint provides information needed to establish a WebSocket connection - * for real-time log streaming from deployment jobs. - * tags: - * - Deployments - * - Native Helm - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the deployment job - * responses: - * 200: - * description: Successful response with WebSocket information - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [Active, Complete, Failed, NotFound, Pending] - * description: Current status of the deployment job - * websocket: - * type: object - * properties: - * endpoint: - * type: string - * example: /api/logs/stream - * parameters: - * type: object - * properties: - * podName: - * type: string - * namespace: - * type: string - * follow: - * type: boolean - * timestamps: - * type: boolean - * container: - * type: string - * required: false - * containers: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * state: - * type: string - * error: - * type: string - * description: Error message if applicable - * 400: - * description: Bad request - missing or invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Missing or invalid parameters - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Internal server error occurred. - * 502: - * description: Bad gateway - failed to communicate with Kubernetes - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Failed to communicate with Kubernetes. - */ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import unifiedLogStreamHandler from '../logs/[jobName]'; - -const deployLogStreamHandler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, jobName } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - getLogger().info( - `method=${req.method} jobName=${jobName} Deploy logs endpoint called, delegating to unified handler` - ); - - req.query.type = 'deploy'; - - return unifiedLogStreamHandler(req, res); - }); -}; - -export default deployLogStreamHandler; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/deployment.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/deployment.ts deleted file mode 100644 index 3fe4bcb8..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/deployment.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * 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 type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import * as k8s from '@kubernetes/client-node'; -import { HttpError } from '@kubernetes/client-node'; -import { Deploy } from 'server/models'; - -const kc = new k8s.KubeConfig(); -kc.loadFromDefault(); - -interface HelmDeploymentDetails { - type: 'helm'; - releaseName: string; - chart: string; - version?: string; - values: Record; - manifest?: string; -} - -interface GitHubDeploymentDetails { - type: 'github'; - manifestConfigMap: string; - manifest: string; -} - -async function getHelmDeploymentDetails(namespace: string, deployUuid: string): Promise { - const coreV1Api = kc.makeApiClient(k8s.CoreV1Api); - - try { - const secretName = `sh.helm.release.v1.${deployUuid}.v1`; - getLogger({}).debug(`Checking for Helm secret: secretName=${secretName} namespace=${namespace}`); - - const secret = await coreV1Api.readNamespacedSecret(secretName, namespace); - - if (!secret.body.data?.release) { - getLogger({}).debug(`Helm secret found but no release data: secretName=${secretName}`); - return null; - } - - const firstDecode = Buffer.from(secret.body.data.release, 'base64').toString(); - - let releaseData: Buffer; - if (/^[A-Za-z0-9+/]/.test(firstDecode) && firstDecode.length % 4 <= 2) { - try { - releaseData = Buffer.from(firstDecode, 'base64'); - } catch { - releaseData = Buffer.from(firstDecode); - } - } else { - releaseData = Buffer.from(firstDecode); - } - - let release: any; - try { - const zlib = require('zlib'); - const decompressed = zlib.gunzipSync(releaseData); - release = JSON.parse(decompressed.toString()); - } catch (decompressError: any) { - try { - release = JSON.parse(releaseData.toString()); - } catch (parseError: any) { - getLogger({}).warn( - `Failed to parse Helm release data: deployUuid=${deployUuid} decompress_error=${decompressError.message} parse_error=${parseError.message}` - ); - return null; - } - } - - return { - type: 'helm', - releaseName: release.name, - chart: release.chart?.metadata?.name || 'unknown', - version: release.chart?.metadata?.version, - values: release.config || {}, - manifest: release.manifest, - }; - } catch (error) { - if (error instanceof HttpError && error.response?.statusCode === 404) { - return null; - } - throw error; - } -} - -async function getGitHubDeploymentDetails( - namespace: string, - deployUuid: string -): Promise { - const coreV1Api = kc.makeApiClient(k8s.CoreV1Api); - - try { - const labelSelector = `deploy_uuid=${deployUuid},app=lifecycle-deploy`; - const configMaps = await coreV1Api.listNamespacedConfigMap( - namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector - ); - - const manifestConfigMap = configMaps.body.items.find((cm) => cm.metadata?.name?.includes('-manifest')); - - if (!manifestConfigMap || !manifestConfigMap.data?.['manifest.yaml']) { - const deploy = await Deploy.query().where('uuid', deployUuid).withGraphFetched('[deployable]').first(); - - if (!deploy?.manifest) { - return null; - } - - return { - type: 'github', - manifestConfigMap: 'stored-in-database', - manifest: deploy.manifest, - }; - } - - return { - type: 'github', - manifestConfigMap: manifestConfigMap.metadata?.name || '', - manifest: manifestConfigMap.data['manifest.yaml'], - }; - } catch (error) { - if (error instanceof HttpError && error.response?.statusCode === 404) { - return null; - } - throw error; - } -} - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/deployment: - * get: - * summary: Get deployment details - * description: | - * Returns detailed information about a specific deployment. - * For Helm deployments, this includes the release information and values. - * For GitHub-type deployments, this includes the Kubernetes manifest. - * tags: - * - Deployments - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * responses: - * '200': - * description: Deployment details - * content: - * application/json: - * schema: - * oneOf: - * - type: object - * properties: - * type: - * type: string - * const: helm - * releaseName: - * type: string - * example: my-service - * chart: - * type: string - * example: my-chart - * version: - * type: string - * example: 1.2.3 - * values: - * type: object - * description: Helm values used for deployment - * manifest: - * type: string - * description: Rendered Kubernetes manifest - * - type: object - * properties: - * type: - * type: string - * const: github - * manifestConfigMap: - * type: string - * example: deploy-uuid-manifest - * manifest: - * type: string - * description: Kubernetes manifest YAML - * '400': - * description: Invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '404': - * description: Deployment not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '405': - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '500': - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, name } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - if (typeof uuid !== 'string' || typeof name !== 'string') { - getLogger().warn(`API: invalid query params uuid=${uuid} name=${name}`); - return res.status(400).json({ error: 'Missing or invalid parameters' }); - } - - const deployUuid = `${name}-${uuid}`; - - try { - const namespace = `env-${uuid}`; - - getLogger().debug(`Fetching deployment details: deployUuid=${deployUuid} namespace=${namespace} service=${name}`); - - const helmDetails = await getHelmDeploymentDetails(namespace, deployUuid); - if (helmDetails) { - getLogger().debug(`Found Helm deployment details: deployUuid=${deployUuid}`); - return res.status(200).json(helmDetails); - } - - const githubDetails = await getGitHubDeploymentDetails(namespace, deployUuid); - if (githubDetails) { - getLogger().debug(`Found GitHub-type deployment details: deployUuid=${deployUuid}`); - return res.status(200).json(githubDetails); - } - - getLogger().warn(`API: deployment not found deployUuid=${deployUuid}`); - return res.status(404).json({ error: 'Deployment not found' }); - } catch (error) { - getLogger().error({ error }, `API: deployment details error deployUuid=${deployUuid}`); - - if (error instanceof HttpError) { - if (error.response?.statusCode === 404) { - return res.status(404).json({ error: 'Deployment not found' }); - } - return res.status(502).json({ error: 'Failed to communicate with Kubernetes' }); - } - - return res.status(500).json({ error: 'Internal server error' }); - } - }); -}; - -export default handler; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/logs.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/logs.ts deleted file mode 100644 index 339e94c8..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/logs.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * 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 type { NextApiRequest, NextApiResponse } from 'next'; -import { spawn, exec } from 'child_process'; -import { promisify } from 'util'; -import GithubService from 'server/services/github'; -import { Build } from 'server/models'; - -// Constants -const MAX_CONCURRENT_PODS = 5; -const LOG_TIMEOUT_MS = 3600000; // 1 hour -const MAX_INITIAL_LINES = 100; - -const execAsync = promisify(exec); - -// Types -interface LogStreamOptions { - podName: string; - namespace: string; - sendSSE: (msg: string) => void; // eslint-disable-line no-unused-vars -} - -type ContainerType = 'app' | 'init'; - -// Input validation helpers -const isValidContainerType = (type: string | undefined): type is ContainerType => { - return type === undefined || type === 'app' || type === 'init'; -}; - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/logs: - * get: - * summary: Stream logs from a service's containers - * description: | - * Streams logs from either application containers or init containers of a service - * using Server-Sent Events (SSE). The stream will continue until the client disconnects - * or the timeout is reached. - * deprecated: true - * tags: - * - Logs - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * - in: query - * name: containerType - * required: false - * schema: - * type: string - * enum: [app, init] - * description: Type of containers to stream logs from. Defaults to 'app' - * responses: - * 200: - * description: Success. Returns a stream of Server-Sent Events - * content: - * text/event-stream: - * schema: - * type: string - * example: | - * data: [pod/name] Log message content - * 400: - * description: Bad request - invalid parameters - * 405: - * description: Method not allowed - */ - -export const config = { - api: { - bodyParser: false, - }, -}; - -async function getPodNames(namespace: string, deployment: string): Promise { - const getPodsCmd = `kubectl get pods --namespace ${namespace} -l app.kubernetes.io/instance=${deployment} -o jsonpath="{range .items[*]}{.metadata.name}{'\\n'}{end}"`; - const { stdout, stderr } = await execAsync(getPodsCmd); - - if (stderr) { - throw new Error(`Failed to retrieve pods: ${stderr}`); - } - - return stdout - .trim() - .split('\n') - .filter((name) => name.trim() !== ''); -} - -function createPodLogStream({ - podName, - namespace, - sendSSE, - containerType = 'app', -}: LogStreamOptions & { containerType?: ContainerType }) { - const kubectlArgs = ['logs', podName, '--namespace', namespace, '-f', `--tail=${MAX_INITIAL_LINES}`]; - - if (containerType === 'init') { - kubectlArgs.push('--container=init-container'); - } else { - kubectlArgs.push('--all-containers=true'); - kubectlArgs.push('--prefix=true'); - } - - const proc = spawn('kubectl', kubectlArgs); - - let buffer = ''; - const flush = () => { - if (buffer) { - sendSSE(buffer); - buffer = ''; - } - }; - - proc.stdout.on('data', (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\n'); - // Keep the last, possibly incomplete, line in the buffer - buffer = lines.pop() || ''; - lines.forEach((line) => { - if (line.trim()) { - sendSSE(line); - } - }); - }); - - proc.stderr.on('data', (data: Buffer) => { - sendSSE(`Error: ${data.toString()}`); - }); - - proc.on('error', (error: Error) => { - sendSSE(`Process error: ${error.message}`); - }); - - proc.on('close', (code: number) => { - flush(); - sendSSE(`Log streaming ended with code ${code}`); - }); - - return proc; -} - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === 'OPTIONS') { - res.status(200).end(); - return; - } - - if (req.method !== 'GET') { - res.status(405).json({ error: 'Method Not Allowed' }); - return; - } - - const { uuid, name } = req.query; - if (!uuid || !name || typeof uuid !== 'string' || typeof name !== 'string') { - res.status(400).json({ error: 'Invalid path parameters' }); - return; - } - - const deployment = `${name}-${uuid}`; - const containerType = req.query.containerType as string | undefined; - if (!isValidContainerType(containerType)) { - res.status(400).json({ error: 'Invalid container type. Must be "app" or "init"' }); - return; - } - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - 'Access-Control-Allow-Origin': '*', - }); - - const sendSSE = (data: string) => { - res.write(`data: ${data}\n\n`); - }; - - try { - const githubService = new GithubService(); - const build: Build = await githubService.db.models.Build.query().findOne({ - uuid, - }); - const deployNamespace = build.namespace; - if (!deployNamespace) { - throw new Error('Deployment namespace not configured'); - } - - const podNames = await getPodNames(deployNamespace, deployment); - - if (podNames.length === 0) { - sendSSE(`No pods found for deployment "${deployment}" in namespace "${deployNamespace}"`); - res.end(); - return; - } - - if (podNames.length > MAX_CONCURRENT_PODS) { - podNames.length = MAX_CONCURRENT_PODS; - } - - const logProcesses = podNames.map((podName) => { - return createPodLogStream({ - podName, - namespace: deployNamespace, - sendSSE, - containerType: (containerType as ContainerType) || 'app', - }); - }); - - const timeout = setTimeout(() => { - logProcesses.forEach((proc) => proc.kill()); - res.end(); - }, LOG_TIMEOUT_MS); - - req.on('close', () => { - clearTimeout(timeout); - logProcesses.forEach((proc) => proc.kill()); - }); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - sendSSE(`Error: ${errorMessage}`); - res.end(); - } -} diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/logs/[jobName].ts b/src/pages/api/v1/builds/[uuid]/services/[name]/logs/[jobName].ts deleted file mode 100644 index ce48cd3e..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/logs/[jobName].ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * 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. - */ - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/logs/{jobName}: - * get: - * summary: Get log streaming information for a specific job (build or deploy) - * description: | - * Returns WebSocket endpoint and parameters for streaming logs from Kubernetes. - * This unified endpoint handles both build and deployment logs, providing information - * needed to establish a WebSocket connection for real-time log streaming. - * tags: - * - Logs - * - Builds - * - Deployments - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the job (build or deploy) - * - in: query - * name: type - * required: false - * schema: - * type: string - * enum: [build, deploy, webhook] - * description: The type of logs to retrieve (defaults to auto-detection based on job name) - * responses: - * 200: - * description: Successful response with WebSocket information - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [Active, Complete, Failed, NotFound, Pending, Archived] - * description: Current status of the job - * streamingRequired: - * type: boolean - * description: Whether streaming is required for active logs - * podName: - * type: string - * nullable: true - * description: Name of the pod running the job - * websocket: - * type: object - * properties: - * endpoint: - * type: string - * example: /api/logs/stream - * parameters: - * type: object - * properties: - * podName: - * type: string - * namespace: - * type: string - * follow: - * type: boolean - * timestamps: - * type: boolean - * container: - * type: string - * required: false - * containers: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * state: - * type: string - * message: - * type: string - * description: Additional message about the job status - * error: - * type: string - * description: Error message if applicable - * archivedLogs: - * type: string - * description: Full log content from object storage (only present when status is Archived) - * 400: - * description: Bad request - missing or invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Missing or invalid parameters - * 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 - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Internal server error occurred. - * 502: - * description: Bad gateway - failed to communicate with Kubernetes - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Failed to communicate with Kubernetes. - */ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { LogStreamingService } from 'server/services/logStreaming'; -import type { LogType } from 'server/services/types/logStreaming'; -import { HttpError } from '@kubernetes/client-node'; - -const unifiedLogStreamHandler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, name, jobName, type } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const isWebhookRequest = type === 'webhook'; - - if (typeof uuid !== 'string' || typeof jobName !== 'string' || (!isWebhookRequest && typeof name !== 'string')) { - getLogger().warn(`API: invalid params uuid=${uuid} name=${name} jobName=${jobName} type=${type}`); - return res.status(400).json({ error: 'Missing or invalid parameters' }); - } - - if (type && (typeof type !== 'string' || !['build', 'deploy', 'webhook'].includes(type))) { - getLogger().warn(`API: invalid type param type=${type}`); - return res.status(400).json({ error: 'Invalid type parameter. Must be "build", "deploy", or "webhook"' }); - } - - try { - const logService = new LogStreamingService(); - - const response = await logService.getLogStreamInfo( - uuid, - jobName, - name as string | undefined, - type as LogType | undefined - ); - - return res.status(200).json(response); - } catch (error: any) { - getLogger().error({ error }, `API: log streaming info failed jobName=${jobName} service=${name}`); - - if (error.message === 'Build not found') { - return res.status(404).json({ error: 'Build not found' }); - } - - if (error instanceof HttpError || error.message?.includes('Kubernetes') || error.statusCode === 502) { - return res.status(502).json({ error: 'Failed to communicate with Kubernetes.' }); - } - - return res.status(500).json({ error: 'Internal server error occurred.' }); - } - }); -}; - -export default unifiedLogStreamHandler; diff --git a/src/pages/api/v1/builds/[uuid]/torndown.ts b/src/pages/api/v1/builds/[uuid]/torndown.ts deleted file mode 100644 index 4450dde5..00000000 --- a/src/pages/api/v1/builds/[uuid]/torndown.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { Build } from 'server/models'; - -import { BuildStatus, DeployStatus } from 'shared/constants'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds/{uuid}/torndown: - * patch: - * 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 build to tear down - * responses: - * 200: - * description: Build successfully torn down - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: The namespace env-noisy-mud-690038 it was delete sucessfuly - * namespacesUpdated: - * type: array - * items: - * type: object - * properties: - * id: - * type: number - * example: 64087 - * uuid: - * type: string - * example: noisy-mud-690038 - * status: - * type: string - * example: torn_down - * 404: - * description: Build not found or is a static environment - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: The build doesn't exist or is static environment - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 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 !== 'PATCH') { - getLogger().debug(`Method not allowed: method=${req.method}`); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const uuid = req.query?.uuid; - - if (!uuid) { - getLogger().debug('The uuid is required'); - return res.status(500).json({ error: 'The uuid is required' }); - } - - return withLogContext({ buildUuid: uuid as string }, async () => { - try { - const buildService = new BuildService(); - - const build: Build = await buildService.db.models.Build.query() - .findOne({ - uuid, - }) - .withGraphFetched('[deploys]'); - - if (build.isStatic || !build) { - getLogger().debug('Build does not exist or is static environment'); - return res.status(404).json({ error: `The build doesn't exist or is static environment` }); - } - - const deploysIds = build.deploys.map((deploy) => deploy.id); - - await buildService.db.models.Build.query().findById(build.id).patch({ - status: BuildStatus.TORN_DOWN, - statusMessage: 'Namespace was deleted successfully', - }); - - await buildService.db.models.Deploy.query() - .whereIn('id', deploysIds) - .patch({ status: DeployStatus.TORN_DOWN, statusMessage: 'Namespace was deleted successfully' }); - - const updatedDeploys = await buildService.db.models.Deploy.query() - .whereIn('id', deploysIds) - .select('id', 'uuid', 'status'); - - return res.status(200).json({ - status: `The namespace env-${uuid} it was delete sucessfuly`, - namespacesUpdated: updatedDeploys, - }); - } catch (error) { - getLogger().error({ error }, 'Build: teardown failed'); - return res.status(500).json({ error: 'An unexpected error occurred.' }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/webhooks.ts b/src/pages/api/v1/builds/[uuid]/webhooks.ts deleted file mode 100644 index 7d8c1cb6..00000000 --- a/src/pages/api/v1/builds/[uuid]/webhooks.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { nanoid } from 'nanoid'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import GithubService from 'server/services/github'; -import { Build } from 'server/models'; -import WebhookService from 'server/services/webhook'; - -/** - * @openapi - * /api/v1/builds/{uuid}/webhooks: - * get: - * summary: Retrieve webhook invocations for a build - * description: | - * Retrieves a paginated list of webhook invocations for a specific build, - * ordered by creation date in descending order. - * tags: - * - Webhooks - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * default: 1 - * description: Page number for pagination - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * maximum: 100 - * default: 100 - * description: Number of items per page - * responses: - * 200: - * description: Successfully retrieved webhook invocations - * content: - * application/json: - * schema: - * type: object - * properties: - * webhooks: - * type: array - * items: - * type: object - * description: Webhook invocation details - * metadata: - * type: object - * properties: - * currentPage: - * type: integer - * example: 1 - * totalPages: - * type: integer - * example: 5 - * total: - * type: integer - * example: 42 - * limit: - * type: integer - * example: 100 - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found for abc-123 - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to retrieve webhooks for build abc-123. - * 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: - * - Webhooks - * - 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: - * type: object - * properties: - * status: - * type: string - * example: success - * message: - * type: string - * example: Webhook for build abc-123 has been queued - * 204: - * description: No webhooks configured for the build - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: no_content - * message: - * type: string - * example: No webhooks found for build abc-123. - * 400: - * description: Invalid UUID provided - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid UUID - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found for abc-123 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: PUT is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to proceed with triggering webhook for build abc-123. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid } = req.query; - - if (!uuid || typeof uuid !== 'string') { - return res.status(400).json({ error: 'Invalid UUID' }); - } - - return withLogContext({ buildUuid: uuid }, async () => { - try { - switch (req.method) { - case 'GET': - return retrieveWebhooks(req, res); - case 'POST': - return invokeWebhooks(req, res); - default: - res.setHeader('Allow', ['GET', 'POST']); - return res.status(405).json({ error: `${req.method} is not allowed.` }); - } - } catch (error) { - getLogger({ error }).error(`API: webhook request failed method=${req.method}`); - res.status(500).json({ error: 'An unexpected error occurred.' }); - } - }); -}; - -async function invokeWebhooks(req: NextApiRequest, res: NextApiResponse) { - const { uuid } = req.query; - const correlationId = `api-webhook-invoke-${Date.now()}-${nanoid(8)}`; - - return withLogContext({ correlationId }, async () => { - try { - const githubService = new GithubService(); - const build: Build = await githubService.db.models.Build.query().findOne({ - uuid, - }); - - const buildId = build.id; - - if (!build) { - getLogger().debug('Build not found'); - return res.status(404).json({ error: `Build not found for ${uuid}` }); - } - - if (!build.webhooksYaml) { - getLogger().debug('No webhooks found for build'); - return res.status(204).json({ - status: 'no_content', - message: `No webhooks found for build ${uuid}.`, - }); - } - - const webhookService = new WebhookService(); - await webhookService.webhookQueue.add('webhook', { - buildId, - correlationId, - }); - - getLogger({ stage: LogStage.WEBHOOK_PROCESSING }).info('Webhook invocation queued via API'); - - return res.status(200).json({ - status: 'success', - message: `Webhook for build ${uuid} has been queued`, - }); - } catch (error) { - getLogger({ stage: LogStage.WEBHOOK_PROCESSING, error }).error(`Webhook: invoke failed uuid=${uuid}`); - return res.status(500).json({ error: `Unable to proceed with triggering webhook for build ${uuid}.` }); - } - }); -} - -async function retrieveWebhooks(req: NextApiRequest, res: NextApiResponse) { - const { uuid, page = 1, limit = 100 } = req.query; - if (typeof uuid !== 'string') { - return res.status(400).json({ error: 'Invalid UUID' }); - } - try { - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 10); - const offset = (pageNumber - 1) * limitNumber; - - const webhookService = new WebhookService(); - - const buildId = webhookService.db.models.Build.query().select('id').where('uuid', uuid).first(); - - if (!buildId) { - return res.status(404).json({ error: `Build not found for ${uuid}` }); - } - - const total = await webhookService.db.models.WebhookInvocations.query().where('buildId', buildId).resultSize(); - - const webhooks = await webhookService.db.models.WebhookInvocations.query() - .where('buildId', buildId) - .orderBy('createdAt', 'desc') - .offset(offset) - .limit(limitNumber); - const totalPages = Math.ceil(total / limitNumber); - res.status(200).json({ - webhooks, - metadata: { - currentPage: pageNumber, - totalPages, - total, - limit: limitNumber, - }, - }); - } catch (error) { - getLogger({ error }).error('API: webhooks fetch failed'); - return res.status(500).json({ error: `Unable to retrieve webhooks for build ${uuid}.` }); - } -} diff --git a/src/pages/api/v1/builds/index.ts b/src/pages/api/v1/builds/index.ts deleted file mode 100644 index 3405df36..00000000 --- a/src/pages/api/v1/builds/index.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds: - * get: - * summary: Get all builds with optional status filtering - * description: | - * Retrieves a list of builds with optional status exclusion filtering and pagination support. - * By default, excludes builds with status 'torn_down' and 'pending'. - * tags: - * - Builds - * parameters: - * - in: query - * name: exclude - * required: false - * schema: - * oneOf: - * - type: array - * items: - * type: string - * - type: string - * description: | - * Status values to exclude from results. Can be passed as: - * - JSON array string: exclude=["torn_down","pending"] - * - Multiple query params: exclude=torn_down&exclude=pending - * - Single value: exclude=torn_down - * Default: ["torn_down", "pending"] - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Page number for pagination (if not provided with limit, all results are returned) - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Number of items per page (if not provided with page, all results are returned) - * responses: - * 200: - * description: Successfully retrieved builds list - * content: - * application/json: - * schema: - * type: object - * required: - * - builds - * - metadata - * properties: - * builds: - * type: array - * items: - * type: object - * required: - * - uuid - * - status - * properties: - * uuid: - * type: string - * description: Unique identifier for the build - * example: "550e8400-e29b-41d4-a716-446655440000" - * status: - * type: string - * description: Current status of the build - * example: "success" - * metadata: - * type: object - * required: - * - currentPage - * - totalPages - * - total - * - limit - * properties: - * currentPage: - * type: integer - * description: Current page number - * example: 1 - * totalPages: - * type: integer - * description: Total number of pages - * example: 5 - * total: - * type: integer - * description: Total number of builds after filtering - * example: 100 - * limit: - * type: integer - * description: Number of items per page - * example: 20 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "POST is not allowed" - * 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` }); - } - - try { - const buildService = new BuildService(); - - // Extract query parameters - const { exclude, page, limit } = req.query; - - // Parse exclude parameter - default to ['torn_down', 'pending'] - let excludeStatuses: string[] = ['torn_down', 'pending']; - if (exclude) { - if (Array.isArray(exclude)) { - excludeStatuses = exclude.filter((status) => typeof status === 'string'); - } else if (typeof exclude === 'string') { - try { - const parsed = JSON.parse(exclude); - if (Array.isArray(parsed)) { - excludeStatuses = parsed.filter((status) => typeof status === 'string'); - } - } catch { - // If not valid JSON, treat as single value - excludeStatuses = [exclude]; - } - } - } - - // Build base query - let query = buildService.db.models.Build.query().select('uuid', 'status').whereNotIn('status', excludeStatuses); - - // Handle pagination - let response: any = {}; - - if (page || limit) { - // If pagination params provided, apply pagination - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 20); - const offset = (pageNumber - 1) * limitNumber; - - // Get total count - const totalCount = await query.resultSize(); - - // Apply pagination - const builds = await query.offset(offset).limit(limitNumber); - - response = { - builds, - metadata: { - currentPage: pageNumber, - totalPages: Math.ceil(totalCount / limitNumber), - total: totalCount, - limit: limitNumber, - }, - }; - } else { - // No pagination - return all results - const builds = await query; - - response = { - builds, - metadata: { - currentPage: 1, - totalPages: 1, - total: builds.length, - limit: builds.length, - }, - }; - } - - return res.status(200).json(response); - } catch (error) { - getLogger({ error }).error('API: builds fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/config/cache.ts b/src/pages/api/v1/config/cache.ts deleted file mode 100644 index 78c0e248..00000000 --- a/src/pages/api/v1/config/cache.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger } from 'server/lib/logger'; -import { verifyBearerToken } from 'server/lib/auth'; -import { getIdentityFromClaims } from 'server/lib/get-user'; -import GlobalConfigService from 'server/services/globalConfig'; - -/** - * @openapi - * /api/v1/config/cache: - * get: - * summary: Retrieve global configuration - * description: Fetches the current global configuration values from cache - * tags: - * - Configuration - * responses: - * 200: - * description: Successfully retrieved configuration - * content: - * application/json: - * schema: - * type: object - * properties: - * configs: - * type: object - * description: Global configuration values - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to retrieve global config values - * put: - * summary: Refresh and retrieve global configuration - * description: Forces a refresh of the cached configuration values and returns the updated configuration - * tags: - * - Configuration - * responses: - * 200: - * description: Successfully refreshed and retrieved configuration - * content: - * application/json: - * schema: - * type: object - * properties: - * configs: - * type: object - * description: Updated global configuration values - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to retrieve global config values - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - res.setHeader('Cache-Control', 'no-store'); - // V1 bypasses auth middleware: verify bearer cryptographically here; x-user is attacker-controlled. - if (process.env.ENABLE_AUTH !== 'true') return res.status(403).json({ error: 'Administrator session required.' }); - const authorization = req.headers.authorization; - const bearer = - typeof authorization === 'string' && authorization.length <= 16384 ? /^Bearer\s+(\S+)$/i.exec(authorization) : null; - if (!bearer) return res.status(403).json({ error: 'Administrator session required.' }); - const verified = await verifyBearerToken(bearer[1]); - const identity = verified.success ? getIdentityFromClaims(verified.payload ?? null) : null; - if (!identity?.issuer || !identity.roles.includes('admin')) { - return res.status(403).json({ error: 'Administrator session required.' }); - } - try { - switch (req.method) { - case 'GET': - return getCachedConfig(res); - case 'PUT': - return getCachedConfig(res, true); - default: - res.setHeader('Allow', ['GET', 'PUT']); - return res.status(405).json({ error: `${req.method} is not allowed.` }); - } - } catch (error) { - getLogger().error({ error }, 'Config: cache operation failed'); - res.status(500).json({ error: 'An unexpected error occurred.' }); - } -}; - -async function getCachedConfig(res: NextApiResponse, refresh: boolean = false) { - try { - const configService = new GlobalConfigService(); - const configs = await configService.getAllConfigs(refresh); - return res.status(200).json({ configs }); - } catch (error) { - getLogger().error({ error }, 'Config: cache retrieval failed'); - return res.status(500).json({ error: `Unable to retrieve global config values` }); - } -} diff --git a/src/pages/api/v1/deploy-summary.ts b/src/pages/api/v1/deploy-summary.ts deleted file mode 100644 index ea89d418..00000000 --- a/src/pages/api/v1/deploy-summary.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/deploy-summary: - * get: - * summary: Get deploy summary by build ID - * description: | - * Retrieves deploy summary information from the deploySummary view for a specific build ID. - * tags: - * - Deploys - * parameters: - * - in: query - * name: buildId - * required: true - * schema: - * type: integer - * description: The ID of the build - * responses: - * 200: - * description: Successfully retrieved deploy summary - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * deployableID: - * type: integer - * name: - * type: string - * buildId: - * type: integer - * buildUUID: - * type: string - * branchName: - * type: string - * type: - * type: string - * active: - * type: boolean - * serviceId: - * type: integer - * defaultenv: - * type: object - * status: - * type: string - * publicUrl: - * type: string - * public: - * type: boolean - * hostPortMapping: - * type: object - * grpc: - * type: object - * capacityType: - * type: string - * 400: - * description: Invalid build ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid build ID - * 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 - */ -// 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 { buildId } = req.query; - const parsedBuildId = parseInt(buildId as string, 10); - - if (!buildId || typeof buildId !== 'string' || isNaN(parsedBuildId)) { - return res.status(400).json({ error: 'Invalid build ID' }); - } - - try { - const buildService = new BuildService(); - - // First check if build exists - const build = await buildService.db.models.Build.query().findById(parsedBuildId).select('id'); - - if (!build) { - getLogger().debug(`Build not found: buildId=${parsedBuildId}`); - return res.status(404).json({ error: 'Build not found' }); - } - - // Query the deploySummary view using raw SQL - const result = await buildService.db.knex.raw( - ` - SELECT - "deployableID", - "name", - "buildId", - "buildUUID", - "branchName", - "type", - "active", - "serviceId", - "defaultenv", - "status", - "publicUrl", - "public", - "hostPortMapping", - "grpc", - "capacityType" - FROM "deploySummary" - WHERE "buildId" = ? - `, - [parsedBuildId] - ); - - return res.status(200).json(result.rows); - } catch (error) { - getLogger({ error }).error(`API: deploy summary fetch failed buildId=${parsedBuildId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/deployables.ts b/src/pages/api/v1/deployables.ts deleted file mode 100644 index afb0ce3a..00000000 --- a/src/pages/api/v1/deployables.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/deployables: - * get: - * summary: Get deployables by build ID - * description: | - * Retrieves all deployables associated with a specific build ID. - * tags: - * - Deployables - * parameters: - * - in: query - * name: buildId - * required: true - * schema: - * type: integer - * description: The ID of the build - * - in: query - * name: name - * required: false - * schema: - * type: string - * description: Filter deployables by name - * responses: - * 200: - * description: Successfully retrieved deployables - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * buildUUID: - * type: string - * buildId: - * type: integer - * name: - * type: string - * branchName: - * type: string - * type: - * type: string - * active: - * type: boolean - * grpc: - * type: object - * capacityType: - * type: string - * public: - * type: boolean - * commentBranchName: - * type: string - * deploymentDependsOn: - * type: object - * dockerBuildPipelineName: - * type: string - * builder: - * type: object - * 400: - * description: Invalid build ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid build ID - * 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 - */ -// 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 { buildId, name } = req.query; - const parsedBuildId = parseInt(buildId as string, 10); - - if (!buildId || typeof buildId !== 'string' || isNaN(parsedBuildId)) { - return res.status(400).json({ error: 'Invalid build ID' }); - } - - try { - const buildService = new BuildService(); - - // First check if build exists - const build = await buildService.db.models.Build.query().findById(parsedBuildId).select('id'); - - if (!build) { - getLogger().debug(`Build not found: buildId=${parsedBuildId}`); - return res.status(404).json({ error: 'Build not found' }); - } - - // Get deployables for this build - let query = buildService.db.models.Deployable.query().where('buildId', parsedBuildId); - - // Apply name filter if provided - if (name && typeof name === 'string') { - query = query.where('name', name); - } - - const deployables = await query.select( - 'id', - 'buildUUID', - 'buildId', - 'name', - 'branchName', - 'type', - 'active', - 'grpc', - 'capacityType', - 'public', - 'commentBranchName', - 'deploymentDependsOn', - 'dockerBuildPipelineName', - 'builder' - ); - - return res.status(200).json(deployables); - } catch (error) { - getLogger({ error }).error(`API: deployables fetch failed buildId=${parsedBuildId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/deploys.ts b/src/pages/api/v1/deploys.ts deleted file mode 100644 index a3e6e34b..00000000 --- a/src/pages/api/v1/deploys.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/deploys: - * get: - * summary: Get deploys by build ID - * description: | - * Retrieves all deploys associated with a specific build ID. - * tags: - * - Deploys - * parameters: - * - in: query - * name: buildId - * required: true - * schema: - * type: integer - * description: The ID of the build - * - in: query - * name: deployableId - * required: false - * schema: - * type: integer - * description: Filter deploys by deployable ID - * responses: - * 200: - * description: Successfully retrieved deploys - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * status: - * type: string - * statusMessage: - * type: string - * uuid: - * type: string - * dockerImage: - * type: string - * internalHostname: - * type: string - * publicUrl: - * type: string - * env: - * type: object - * serviceId: - * type: integer - * buildId: - * type: integer - * createdAt: - * type: string - * format: date-time - * updatedAt: - * type: string - * format: date-time - * deletedAt: - * type: string - * format: date-time - * nullable: true - * branchName: - * type: string - * tag: - * type: string - * githubRepositoryId: - * type: integer - * sha: - * type: string - * initDockerImage: - * type: string - * initEnv: - * type: object - * active: - * type: boolean - * cname: - * type: string - * runUUID: - * type: string - * replicaCount: - * type: integer - * deployableId: - * type: integer - * isRunningLatest: - * type: boolean - * runningImage: - * type: string - * deployPipelineId: - * type: string - * buildJobName: - * type: string - * 400: - * description: Invalid build ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid build ID - * 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 - */ -// 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 { buildId, deployableId } = req.query; - const parsedBuildId = parseInt(buildId as string, 10); - const parsedDeployableId = deployableId ? parseInt(deployableId as string, 10) : undefined; - - if (!buildId || typeof buildId !== 'string' || isNaN(parsedBuildId)) { - return res.status(400).json({ error: 'Invalid build ID' }); - } - - if (deployableId && (typeof deployableId !== 'string' || isNaN(parsedDeployableId!))) { - return res.status(400).json({ error: 'Invalid deployable ID' }); - } - - try { - const buildService = new BuildService(); - - // First check if build exists - const build = await buildService.db.models.Build.query().findById(parsedBuildId).select('id'); - - if (!build) { - getLogger().debug(`Build not found: buildId=${parsedBuildId}`); - return res.status(404).json({ error: 'Build not found' }); - } - - // Get deploys for this build - let query = buildService.db.models.Deploy.query().where('buildId', parsedBuildId); - - // Apply deployableId filter if provided - if (parsedDeployableId) { - query = query.where('deployableId', parsedDeployableId); - } - - const deploys = await query.select( - 'id', - 'status', - 'statusMessage', - 'uuid', - 'dockerImage', - 'internalHostname', - 'publicUrl', - 'env', - 'serviceId', - 'buildId', - 'createdAt', - 'updatedAt', - 'deletedAt', - 'branchName', - 'tag', - 'githubRepositoryId', - 'sha', - 'initDockerImage', - 'initEnv', - 'active', - 'cname', - 'runUUID', - 'replicaCount', - 'deployableId', - 'isRunningLatest', - 'runningImage', - 'deployPipelineId', - 'buildJobName' - ); - - return res.status(200).json(deploys); - } catch (error) { - getLogger({ error }).error(`API: deploys fetch failed buildId=${parsedBuildId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/pull-requests/[id]/builds.ts b/src/pages/api/v1/pull-requests/[id]/builds.ts deleted file mode 100644 index b26ba41d..00000000 --- a/src/pages/api/v1/pull-requests/[id]/builds.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/pull-requests/{id}/builds: - * get: - * summary: Get builds by pull request ID - * description: | - * Retrieves all builds associated with a specific pull request ID. - * tags: - * - Builds - * - Pull Requests - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * description: The ID of the pull request - * responses: - * 200: - * description: Successfully retrieved builds - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * uuid: - * type: string - * status: - * type: string - * statusMessage: - * type: string - * sha: - * type: string - * createdAt: - * type: string - * format: date-time - * updatedAt: - * type: string - * format: date-time - * deletedAt: - * type: string - * format: date-time - * nullable: true - * pullRequestId: - * type: integer - * manifest: - * type: object - * webhooksYaml: - * type: object - * isStatic: - * type: boolean - * 400: - * description: Invalid pull request ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid pull request ID - * 404: - * description: Pull request not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Pull request not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -// 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 { id } = req.query; - const parsedId = parseInt(id as string, 10); - - if (!id || typeof id !== 'string' || isNaN(parsedId)) { - return res.status(400).json({ error: 'Invalid pull request ID' }); - } - - try { - const pullRequestService = new PullRequestService(); - const buildService = new BuildService(); - - // First check if pull request exists - const pullRequest = await pullRequestService.db.models.PullRequest.query().findById(parsedId).select('id'); - - if (!pullRequest) { - getLogger().debug(`Pull request not found: id=${parsedId}`); - return res.status(404).json({ error: 'Pull request not found' }); - } - - // Get builds for this pull request - const builds = await buildService.db.models.Build.query() - .where('pullRequestId', parsedId) - .select( - 'id', - 'uuid', - 'status', - 'statusMessage', - 'sha', - 'createdAt', - 'updatedAt', - 'deletedAt', - 'pullRequestId', - 'manifest', - 'webhooksYaml', - 'isStatic' - ); - - return res.status(200).json(builds); - } catch (error) { - getLogger().error({ error }, `API: builds fetch failed pullRequestId=${parsedId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/pull-requests/[id]/index.ts b/src/pages/api/v1/pull-requests/[id]/index.ts deleted file mode 100644 index caaf7a14..00000000 --- a/src/pages/api/v1/pull-requests/[id]/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/pull-requests/{id}: - * get: - * summary: Get pull request by ID - * description: | - * Retrieves detailed information about a specific pull request by its ID. - * tags: - * - Pull Requests - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * description: The ID of the pull request - * responses: - * 200: - * description: Successfully retrieved pull request details - * content: - * application/json: - * schema: - * type: object - * properties: - * deployOnUpdate: - * type: boolean - * branchName: - * type: string - * fullName: - * type: string - * latestCommit: - * type: string - * pullRequestNumber: - * type: integer - * repositoryId: - * type: integer - * status: - * type: string - * title: - * type: string - * updatedAt: - * type: string - * format: date-time - * createdAt: - * type: string - * format: date-time - * commentId: - * type: string - * githubLogin: - * type: string - * id: - * type: integer - * labels: - * type: object - * 400: - * description: Invalid pull request ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid pull request ID - * 404: - * description: Pull request not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Pull request not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -// 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 { id } = req.query; - const parsedId = parseInt(id as string, 10); - - if (!id || typeof id !== 'string' || isNaN(parsedId)) { - return res.status(400).json({ error: 'Invalid pull request ID' }); - } - - try { - const pullRequestService = new PullRequestService(); - - const pullRequest = await pullRequestService.db.models.PullRequest.query() - .findById(parsedId) - .select( - 'deployOnUpdate', - 'branchName', - 'fullName', - 'latestCommit', - 'pullRequestNumber', - 'repositoryId', - 'status', - 'title', - 'updatedAt', - 'createdAt', - 'commentId', - 'githubLogin', - 'id', - 'labels' - ); - - if (!pullRequest) { - getLogger().debug(`Pull request not found: id=${parsedId}`); - return res.status(404).json({ error: 'Pull request not found' }); - } - - return res.status(200).json(pullRequest); - } catch (error) { - getLogger().error({ error }, `API: pull request fetch failed id=${parsedId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/pull-requests/index.ts b/src/pages/api/v1/pull-requests/index.ts deleted file mode 100644 index afb6f774..00000000 --- a/src/pages/api/v1/pull-requests/index.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/pull-requests: - * get: - * summary: Get pull requests with filtering and pagination - * description: | - * Retrieves pull requests with optional filtering by user, repository, and status exclusion. - * Results are ordered by updatedAt descending and paginated with a default limit of 25. - * tags: - * - Pull Requests - * parameters: - * - in: query - * name: user - * required: false - * schema: - * type: string - * description: Filter by GitHub username (githubLogin) - * example: "johndoe" - * - in: query - * name: repo - * required: false - * schema: - * type: string - * description: Filter by repository name (fullName) - * example: "owner/repository" - * - in: query - * name: exclude - * required: false - * schema: - * oneOf: - * - type: array - * items: - * type: string - * - type: string - * description: | - * Status values to exclude from results. Can be passed as: - * - JSON array string: exclude=["closed","merged"] - * - Multiple query params: exclude=closed&exclude=merged - * - Single value: exclude=closed - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * default: 1 - * description: Page number for pagination - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * default: 25 - * description: Number of items per page - * responses: - * 200: - * description: Successfully retrieved pull requests list - * content: - * application/json: - * schema: - * type: object - * required: - * - pull_requests - * - metadata - * properties: - * pull_requests: - * type: array - * items: - * type: object - * properties: - * deployOnUpdate: - * type: boolean - * branchName: - * type: string - * fullName: - * type: string - * latestCommit: - * type: string - * pullRequestNumber: - * type: integer - * repositoryId: - * type: integer - * status: - * type: string - * title: - * type: string - * updatedAt: - * type: string - * format: date-time - * createdAt: - * type: string - * format: date-time - * commentId: - * type: string - * githubLogin: - * type: string - * id: - * type: integer - * metadata: - * type: object - * required: - * - currentPage - * - totalPages - * - total - * - limit - * properties: - * currentPage: - * type: integer - * description: Current page number - * example: 1 - * totalPages: - * type: integer - * description: Total number of pages - * example: 5 - * total: - * type: integer - * description: Total number of pull requests after filtering - * example: 100 - * limit: - * type: integer - * description: Number of items per page - * example: 25 - * 400: - * description: Invalid request parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "Invalid parameters" - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "POST is not allowed" - * 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` }); - } - - try { - const pullRequestService = new PullRequestService(); - - // Extract query parameters - const { user, repo, exclude, page, limit } = req.query; - - // Build base query - let query = pullRequestService.db.models.PullRequest.query() - .select( - 'deployOnUpdate', - 'branchName', - 'fullName', - 'latestCommit', - 'pullRequestNumber', - 'repositoryId', - 'status', - 'title', - 'updatedAt', - 'createdAt', - 'commentId', - 'githubLogin', - 'id', - 'labels' - ) - .orderBy('updatedAt', 'desc'); - - // Apply user filter if provided - if (user && typeof user === 'string') { - query = query.where('githubLogin', user); - } - - // Apply repo filter if provided - if (repo && typeof repo === 'string') { - query = query.where('fullName', repo); - } - - // Parse exclude parameter - if (exclude) { - let excludeStatuses: string[] = []; - if (Array.isArray(exclude)) { - excludeStatuses = exclude.filter((status) => typeof status === 'string'); - } else if (typeof exclude === 'string') { - try { - const parsed = JSON.parse(exclude); - if (Array.isArray(parsed)) { - excludeStatuses = parsed.filter((status) => typeof status === 'string'); - } else { - excludeStatuses = [exclude]; - } - } catch { - // If not valid JSON, treat as single value - excludeStatuses = [exclude]; - } - } - - if (excludeStatuses.length > 0) { - query = query.whereNotIn('status', excludeStatuses); - } - } - - // Apply pagination (always paginate with default limit 25) - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 25); - const offset = (pageNumber - 1) * limitNumber; - - // Get total count using a cloned query - const totalCount = await query.resultSize(); - - // Apply pagination to the original query - const pullRequests = await query.offset(offset).limit(limitNumber); - - interface Response { - pull_requests: any[]; - metadata: { - currentPage: number; - totalPages: number; - total: number; - limit: number; - }; - } - - const response: Response = { - pull_requests: pullRequests, - metadata: { - currentPage: pageNumber, - totalPages: Math.ceil(totalCount / limitNumber), - total: totalCount, - limit: limitNumber, - }, - }; - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, 'API: pull requests fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/repos/index.ts b/src/pages/api/v1/repos/index.ts deleted file mode 100644 index 79de907f..00000000 --- a/src/pages/api/v1/repos/index.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/repos: - * get: - * summary: Get all distinct repository names from pull requests - * description: | - * Retrieves a list of distinct repository names (fullName) from the pull requests table. - * Returns all repositories by default, with optional pagination support. - * tags: - * - Repositories - * parameters: - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Page number for pagination (if not provided with limit, all results are returned) - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Number of items per page (if not provided with page, all results are returned) - * responses: - * 200: - * description: Successfully retrieved repositories list - * content: - * application/json: - * schema: - * type: object - * required: - * - repos - * - metadata - * properties: - * repos: - * type: array - * items: - * type: string - * description: Array of distinct repository names - * example: ["owner/repo1", "owner/repo2", "owner/repo3"] - * metadata: - * type: object - * required: - * - currentPage - * - totalPages - * - total - * - limit - * properties: - * currentPage: - * type: integer - * description: Current page number - * example: 1 - * totalPages: - * type: integer - * description: Total number of pages - * example: 1 - * total: - * type: integer - * description: Total number of distinct repositories - * example: 3 - * limit: - * type: integer - * description: Number of items per page - * example: 20 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "POST is not allowed" - * 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` }); - } - - try { - const pullRequestService = new PullRequestService(); - - // Extract query parameters - const { page, limit } = req.query; - - // Build base query for distinct fullName values - const baseQuery = pullRequestService.db.models.PullRequest.query() - .distinct('fullName') - .select('fullName') - .whereNotNull('fullName'); - - // Handle pagination - interface Response { - repos: string[]; - metadata: { - currentPage: number; - totalPages: number; - total: number; - limit: number; - }; - } - - let response: Response; - - if (page || limit) { - // If pagination params provided, apply pagination - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 20); - const offset = (pageNumber - 1) * limitNumber; - - const countResult = (await pullRequestService.db.models.PullRequest.query() - .countDistinct('fullName as count') - .whereNotNull('fullName') - .first()) as { count?: string | number } | undefined; - - const totalCount = parseInt(String(countResult?.count || '0'), 10); - - const reposResult = await baseQuery.offset(offset).limit(limitNumber); - - const repos = reposResult.map((row: { fullName: string }) => row.fullName); - - response = { - repos, - metadata: { - currentPage: pageNumber, - totalPages: Math.ceil(totalCount / limitNumber), - total: totalCount, - limit: limitNumber, - }, - }; - } else { - const reposResult = await baseQuery; - - const repos = reposResult.map((row: { fullName: string }) => row.fullName); - - response = { - repos, - metadata: { - currentPage: 1, - totalPages: 1, - total: repos.length, - limit: repos.length, - }, - }; - } - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, 'API: repos fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/schema/validate.ts b/src/pages/api/v1/schema/validate.ts deleted file mode 100644 index 27b4ed33..00000000 --- a/src/pages/api/v1/schema/validate.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * 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. - */ - -/** - * @openapi - * /api/v1/schema/validate: - * post: - * summary: Validate YAML configuration - * description: Validates a YAML config provided as content or fetched from a repo/branch. - * tags: - * - Schema - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * source: - * type: string - * enum: [content, path] - * content: - * type: string - * description: Base64-encoded YAML content (required if source=content) - * repo: - * type: string - * description: Repository name (required if source=path) - * branch: - * type: string - * description: Branch name (required if source=path) - * responses: - * 200: - * description: Validation result - * content: - * application/json: - * schema: - * type: object - * properties: - * valid: - * type: boolean - * error: - * type: array - * items: - * type: string - * nullable: true - * 400: - * description: Bad request or validation error - * content: - * application/json: - * schema: - * type: object - * properties: - * valid: - * type: boolean - * error: - * type: array - * items: - * type: string - * nullable: true - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ - -type ValidationResponse = { - valid: boolean; - error: string[] | null; -}; - -type ErrorResponse = { - error: string; -}; - -type Response = ValidationResponse | ErrorResponse; - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getYamlFileContentFromBranch, ConfigFileNotFound } from 'server/lib/github'; -import { getLogger } from 'server/lib/logger'; -import { YamlConfigParser, ParsingError } from 'server/lib/yamlConfigParser'; -import { YamlConfigValidator, ValidationError } from 'server/lib/yamlConfigValidator'; - -const schemaValidateHandler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'POST') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { source } = req.body; - const allowedSources = ['content', 'path']; - - if (!source || typeof source !== 'string' || !allowedSources.includes(source)) { - return res.status(400).json({ valid: false, error: ['Invalid source in request body'] }); - } - - try { - if (source === 'content') { - return await validateContent(req, res); - } else if (source === 'path') { - return await validatePath(req, res); - } - } catch (error) { - if (error instanceof ParsingError || error instanceof ValidationError) { - const errors = error.message.split('\n'); - return res.status(400).json({ valid: false, error: errors }); - } - if (error instanceof ConfigFileNotFound) { - return res.status(404).json({ valid: false, error: ['Config file not found'] }); - } - getLogger().error({ error }, 'Schema: YAML validation failed'); - return res.status(500).json({ error: 'Internal server error' }); - } -}; - -export default schemaValidateHandler; - -const validateContent = async (req: NextApiRequest, res: NextApiResponse) => { - const { content } = req.body; - if (typeof content !== 'string') { - return res.status(400).json({ valid: false, error: ['Invalid content in request body'] }); - } - const decodedContent = Buffer.from(content, 'base64').toString('utf-8'); - const parser = new YamlConfigParser(); - const config = parser.parseYamlConfigFromString(decodedContent); - const isValid = new YamlConfigValidator().validate(config?.version, config); - return res.status(200).json({ valid: isValid, error: null }); -}; - -const validatePath = async (req: NextApiRequest, res: NextApiResponse) => { - const { repo, branch } = req.body; - if (![repo, branch].every((val) => typeof val === 'string' && val.trim() !== '')) { - return res.status(400).json({ valid: false, error: ['Invalid repo or branch in request body'] }); - } - const content = (await getYamlFileContentFromBranch(repo, branch)) as string; - const parser = new YamlConfigParser(); - const config = parser.parseYamlConfigFromString(content); - const isValid = new YamlConfigValidator().validate(config?.version, config); - return res.status(200).json({ valid: isValid, error: null }); -}; diff --git a/src/pages/api/v1/setup/callback.ts b/src/pages/api/v1/setup/callback.ts deleted file mode 100644 index 0be71685..00000000 --- a/src/pages/api/v1/setup/callback.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 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 type { NextApiRequest, NextApiResponse } from 'next'; -import { updateSecret, getCurrentNamespaceFromFile } from 'server/lib/kubernetes'; -import { getLogger } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import { SECRET_BOOTSTRAP_NAME } from 'shared/config'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - const { code, state } = req.query; - if (!code) return res.status(400).send({ error: 'Missing authorization code' }); - if (!state) return res.status(400).send({ error: 'Missing state parameter' }); - - getLogger().info(`Setup: callback received state=${state}`); - - const globalConfigService = new GlobalConfigService(); - const { state: storedState } = await globalConfigService.getConfig('app_setup'); - if (storedState !== state) { - getLogger().warn(`Setup: invalid state received=${state} expected=${storedState}`); - return res.status(400).send({ error: 'Invalid state parameter' }); - } - - // check if app is already installed - const app_setup = await globalConfigService.getConfig('app_setup'); - if (app_setup.installed) { - getLogger().warn('Setup: app already installed'); - return res.status(400).send({ error: 'App already installed' }); - } - - const response = await fetch(`https://api.github.com/app-manifests/${code}/conversions`, { - method: 'POST', - headers: { - Accept: 'application/vnd.github.v3+json', - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - getLogger().error({ errorData }, 'Setup: manifest conversion failed'); - return res.status(response.status).json({ error: 'Failed to convert manifest code' }); - } - - const creds = await response.json(); - const { id, client_id, client_secret, webhook_secret, pem, html_url, slug } = creds; - - if (!id || !client_id || !client_secret || !webhook_secret || !pem || !html_url) { - return res.status(400).send({ error: 'Invalid response from GitHub' }); - } - - const namespace = getCurrentNamespaceFromFile(); - - await updateSecret( - SECRET_BOOTSTRAP_NAME, - { - GITHUB_APP_ID: id, - GITHUB_CLIENT_ID: client_id, - GITHUB_CLIENT_SECRET: client_secret, - GITHUB_WEBHOOK_SECRET: webhook_secret, - GITHUB_PRIVATE_KEY: pem.replace(/\n/g, '\\n'), - }, - namespace - ); - - getLogger().info(`Setup: secrets updated appId=${id}`); - - await globalConfigService.setConfig('app_setup', { - ...app_setup, - created: true, - url: html_url, - name: slug, - }); - - const installationUrl = `${html_url}/installations/new`; - getLogger().info(`Setup: redirecting to install url=${installationUrl}`); - res.redirect(installationUrl); - } catch (error) { - getLogger().error({ error }, 'Setup: callback failed'); - res.status(500).send({ error: 'An error occurred during GitHub app setup' }); - } -} diff --git a/src/pages/api/v1/setup/configure.ts b/src/pages/api/v1/setup/configure.ts deleted file mode 100644 index c9b93a52..00000000 --- a/src/pages/api/v1/setup/configure.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next'; -import { getCurrentNamespaceFromFile } from 'server/lib/kubernetes'; -import { shellPromise } from 'server/lib/shell'; -import { getLogger } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import { APP_HOST } from 'shared/config'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const namespace = getCurrentNamespaceFromFile(); - const releaseName = process.env.HELM_RELEASE_NAME; - - if (!namespace || !releaseName) { - return res.status(500).json({ error: 'Restarting deployment failed' }); - } - - const globalConfigService = new GlobalConfigService(); - const app_setup = await globalConfigService.getConfig('app_setup'); - - if (app_setup?.restarted) { - return res.status(400).json({ error: 'Restart already requested' }); - } - - try { - const appDomain = APP_HOST.split('.').slice(1).join('.'); - const lifecycleDefaults = await globalConfigService.getConfig('lifecycleDefaults'); - const domainDefaults = await globalConfigService.getConfig('domainDefaults'); - - await globalConfigService.setConfig('lifecycleDefaults', { - ...lifecycleDefaults, - ecrDomain: `distribution.${appDomain}`, - defaultPublicUrl: `dev-0.${appDomain}`, - }); - - await globalConfigService.setConfig('domainDefaults', { - ...domainDefaults, - http: appDomain, - grpc: appDomain, - publicScheme: 'https', - }); - - await globalConfigService.getAllConfigs(true); - await shellPromise( - `kubectl rollout restart deployment -l app.kubernetes.io/instance=${releaseName},app.kubernetes.io/name=lifecycle -n ${namespace}` - ); - const updated_app_setup = { - ...app_setup, - restarted: true, - }; - await globalConfigService.setConfig('app_setup', updated_app_setup); - res.status(200).json({ message: 'Deployment restarted' }); - } catch (error) { - getLogger().error({ error }, 'Setup: deployment restart failed'); - return res.status(500).json({ error: 'Restarting deployment failed' }); - } -} diff --git a/src/pages/api/v1/setup/index.ts b/src/pages/api/v1/setup/index.ts deleted file mode 100644 index c35f41fb..00000000 --- a/src/pages/api/v1/setup/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * 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 { randomBytes } from 'crypto'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import { APP_HOST, GITHUB_APP_AUTH_CALLBACK } from 'shared/config'; - -function isValidURL(url: string) { - try { - new URL(url); - return true; - } catch (error) { - return false; - } -} - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const gc = new GlobalConfigService(); - const appSetup = await gc.getConfig('app_setup'); - if (appSetup?.installed) return res.redirect('/setup'); - - const appName = typeof req.query.app_name === 'string' ? req.query.app_name.trim() : ''; - const appUrl = APP_HOST; - const githubAppAuthCallback = GITHUB_APP_AUTH_CALLBACK; - const org = typeof req.query.org === 'string' ? req.query.org.trim() : ''; - - if (appName.length > 34 || !/^[a-zA-Z0-9-]+$/.test(appName)) { - return res.status(400).json({ error: 'App name is not valid.' }); - } - - if (org && (org.length > 39 || !/^[a-zA-Z0-9-]+$/.test(org))) { - return res.status(400).json({ error: 'Organization name is not valid.' }); - } - - if (!appUrl || !isValidURL(appUrl)) { - return res.status(400).json({ error: 'Application public URL is not valid.' }); - } - - const state = randomBytes(16).toString('hex'); - getLogger().info(`Setup: state generated state=${state}`); - - await gc.setConfig('app_setup', { - state, - created: false, - installed: false, - org, - appUrl, - }); - - const manifest = { - name: appName, - url: appUrl, - hook_attributes: { url: `${appUrl}/api/webhooks/github`, active: true }, - setup_url: `${appUrl}/api/v1/setup/installed`, - redirect_url: `${appUrl}/api/v1/setup/callback`, - callback_urls: [`${appUrl}/api/v1/setup/callback`, `${githubAppAuthCallback}`], - public: false, - default_permissions: { - contents: 'write', - deployments: 'write', - issues: 'write', - members: 'read', - metadata: 'read', - pull_requests: 'write', - statuses: 'read', - // The update_file tool commits arbitrary paths; GitHub rejects writes to .github/workflows/* - // with contents:write alone, so the debug agent needs the dedicated workflows permission. - workflows: 'write', - emails: 'read', - }, - default_events: [ - 'issues', - 'issue_comment', - 'label', - 'membership', - 'organization', - 'repository', - 'public', - 'pull_request', - 'push', - 'team', - ], - }; - - const actionUrl = org - ? `https://github.com/organizations/${org}/settings/apps/new?state=${state}` - : `https://github.com/settings/apps/new?state=${state}`; - - const html = ` -
- -
- - `; - - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.send(html); -} diff --git a/src/pages/api/v1/setup/installed.ts b/src/pages/api/v1/setup/installed.ts deleted file mode 100644 index dccde88f..00000000 --- a/src/pages/api/v1/setup/installed.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * 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 type { NextApiRequest, NextApiResponse } from 'next'; -import { updateSecret, getCurrentNamespaceFromFile } from 'server/lib/kubernetes'; -import { getLogger } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import { SECRET_BOOTSTRAP_NAME } from 'shared/config'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { installation_id, setup_action } = req.query; - - if (!installation_id) { - return res.status(400).json({ error: 'Missing installation_id' }); - } - if (setup_action && setup_action !== 'install') { - getLogger().warn(`Setup: invalid setup_action received=${setup_action} expected=install`); - return res.status(500).json({ error: 'Invalid setup_action' }); - } - - const globalConfigService = new GlobalConfigService(); - const app_setup = await globalConfigService.getConfig('app_setup'); - if (!app_setup) { - getLogger().warn('Setup: app_setup not found'); - return res.status(404).json({ error: 'No app_setup found.' }); - } - - if (app_setup.installed) { - getLogger().warn('Setup: app already installed'); - return res.status(400).json({ error: 'App already installed.' }); - } - - const updated_app_setup = { - ...app_setup, - installed: true, - }; - await globalConfigService.setConfig('app_setup', updated_app_setup); - getLogger().info(`Setup: installation recorded installationId=${installation_id}`); - const namespace = getCurrentNamespaceFromFile(); - await updateSecret( - SECRET_BOOTSTRAP_NAME, - { - GITHUB_APP_INSTALLATION_ID: installation_id as string, - }, - namespace - ); - - const app_setup_url_encoded = encodeURIComponent(JSON.stringify(updated_app_setup)); - getLogger().info('Setup: redirecting to completion page'); - res.redirect(`/setup/complete?app_setup=${app_setup_url_encoded}`); -} diff --git a/src/pages/api/v1/setup/status.ts b/src/pages/api/v1/setup/status.ts deleted file mode 100644 index 8b43c82f..00000000 --- a/src/pages/api/v1/setup/status.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 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. - */ - -/** - * @openapi - * /api/v1/setup/status: - * get: - * summary: Get the status of the app setup - * description: Get the status of the app setup - * tags: - * - Setup - * responses: - * 200: - * description: The status of the app setup - */ -import { NextApiRequest, NextApiResponse } from 'next'; -import GlobalConfigService from 'server/services/globalConfig'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'GET') { - return res.status(405).json({ error: 'Method not allowed' }); - } - - const globalConfigService = new GlobalConfigService(); - const appSetup = await globalConfigService.getConfig('app_setup'); - res.status(200).json({ - installed: appSetup?.installed || false, - created: appSetup?.created || false, - restarted: appSetup?.restarted || false, - url: appSetup?.url || '', - }); -} diff --git a/src/pages/api/v1/users/index.ts b/src/pages/api/v1/users/index.ts deleted file mode 100644 index 1a3f793a..00000000 --- a/src/pages/api/v1/users/index.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * 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 { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/users: - * get: - * summary: Get all distinct GitHub users from pull requests - * description: | - * Retrieves a list of distinct GitHub usernames from the pull requests table. - * Returns all users by default, with optional pagination support. - * tags: - * - Users - * parameters: - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Page number for pagination (if not provided with limit, all results are returned) - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Number of items per page (if not provided with page, all results are returned) - * responses: - * 200: - * description: Successfully retrieved users list - * content: - * application/json: - * schema: - * type: object - * required: - * - users - * - metadata - * properties: - * users: - * type: array - * items: - * type: string - * description: Array of distinct GitHub usernames - * example: ["user1", "user2", "user3"] - * metadata: - * type: object - * required: - * - currentPage - * - totalPages - * - total - * - limit - * properties: - * currentPage: - * type: integer - * description: Current page number - * example: 1 - * totalPages: - * type: integer - * description: Total number of pages - * example: 1 - * total: - * type: integer - * description: Total number of distinct users - * example: 3 - * limit: - * type: integer - * description: Number of items per page - * example: 20 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "POST is not allowed" - * 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` }); - } - - try { - const pullRequestService = new PullRequestService(); - - // Extract query parameters - const { page, limit } = req.query; - - // Build base query for distinct githubLogin values - const baseQuery = pullRequestService.db.models.PullRequest.query() - .distinct('githubLogin') - .select('githubLogin') - .whereNotNull('githubLogin'); - - // Handle pagination - interface Response { - users: string[]; - metadata: { - currentPage: number; - totalPages: number; - total: number; - limit: number; - }; - } - - let response: Response; - - if (page || limit) { - // If pagination params provided, apply pagination - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 20); - const offset = (pageNumber - 1) * limitNumber; - - const countResult = (await pullRequestService.db.models.PullRequest.query() - .countDistinct('githubLogin as count') - .whereNotNull('githubLogin') - .first()) as { count?: string | number } | undefined; - - const totalCount = parseInt(String(countResult?.count || '0'), 10); - - const usersResult = await baseQuery.offset(offset).limit(limitNumber); - - const users = usersResult.map((row: { githubLogin: string }) => row.githubLogin); - - response = { - users, - metadata: { - currentPage: pageNumber, - totalPages: Math.ceil(totalCount / limitNumber), - total: totalCount, - limit: limitNumber, - }, - }; - } else { - const usersResult = await baseQuery; - - const users = usersResult.map((row: { githubLogin: string }) => row.githubLogin); - - response = { - users, - metadata: { - currentPage: 1, - totalPages: 1, - total: users.length, - limit: users.length, - }, - }; - } - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, 'API: users fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/schema/validate.tsx b/src/pages/schema/validate.tsx index 2fcb70f1..3bec0e5b 100644 --- a/src/pages/schema/validate.tsx +++ b/src/pages/schema/validate.tsx @@ -14,125 +14,34 @@ * limitations under the License. */ -import React, { useState } from 'react'; +import Head from 'next/head'; +import React from 'react'; +import type { GetServerSideProps } from 'next'; +import { LIFECYCLE_UI_URL } from 'shared/config'; -const SchemaValidatorPage = () => { - const [yaml, setYaml] = useState(''); - const [result, setResult] = useState<{ valid: boolean; error: string[] | null } | null>(null); - const [loading, setLoading] = useState(false); - const [touched, setTouched] = useState(false); - - const handleChange = async (e: React.ChangeEvent) => { - const value = e.target.value; - setYaml(value); - setTouched(true); - if (!value) { - setResult(null); - return; - } - setLoading(true); - try { - const encodeBase64 = (str: string) => { - return Buffer.from(str, 'utf-8').toString('base64'); - }; - const encodedContent = encodeBase64(value); - const res = await fetch('/api/v1/schema/validate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: encodedContent, source: 'content' }), - }); - const data = await res.json(); - setResult({ valid: data.valid, error: data.error }); - } catch (err) { - console.error(err); - setResult({ valid: false, error: ['Could not reach validation API.'] }); - } finally { - setLoading(false); - } - }; - - let borderColor = '#D1D5DB'; - if (touched && result) { - borderColor = result.valid ? '#22C55E' : '#EF4444'; // green-500 or red-500 - } +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { uiUrl: LIFECYCLE_UI_URL || null }, +}); +export default function SchemaValidatorPage({ uiUrl }: { uiUrl: string | null }) { return ( -
-
-

Schema Validation

-