diff --git a/src/server/lib/__tests__/cli.test.ts b/src/server/lib/__tests__/cli.test.ts new file mode 100644 index 0000000..f6086bd --- /dev/null +++ b/src/server/lib/__tests__/cli.test.ts @@ -0,0 +1,283 @@ +/** + * 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. + */ + +const mockShellPromise = jest.fn(); +const mockGetAllConfigs = jest.fn(); +const mockProcessSecretRefs = jest.fn(); +const mockWaitForSecretSync = jest.fn(); +const mockReadNamespacedSecret = jest.fn(); +const mockDeleteNamespacedSecret = jest.fn(); +const mockDeleteExternalSecret = jest.fn(); +const mockCreateOrUpdateNamespace = jest.fn(); +const mockLoggerDebug = jest.fn(); +const mockLoggerError = jest.fn(); + +jest.mock('server/lib/shell', () => ({ + shellPromise: (...args: any[]) => mockShellPromise(...args), +})); + +jest.mock('server/lib/logger', () => ({ + getLogger: jest.fn(() => ({ + debug: mockLoggerDebug, + error: mockLoggerError, + info: jest.fn(), + warn: jest.fn(), + })), + updateLogContext: jest.fn(), + withLogContext: jest.fn((_ctx, fn) => fn()), +})); + +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(() => ({ + getAllConfigs: (...args: any[]) => mockGetAllConfigs(...args), + })), + }, +})); + +jest.mock('server/services/secretProcessor', () => ({ + SecretProcessor: jest.fn().mockImplementation(() => ({ + processSecretRefs: (...args: any[]) => mockProcessSecretRefs(...args), + waitForSecretSync: (...args: any[]) => mockWaitForSecretSync(...args), + })), +})); + +jest.mock('@kubernetes/client-node', () => ({ + CoreV1Api: jest.fn(), + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn(() => ({ + readNamespacedSecret: (...args: any[]) => mockReadNamespacedSecret(...args), + deleteNamespacedSecret: (...args: any[]) => mockDeleteNamespacedSecret(...args), + })), + })), +})); + +jest.mock('server/lib/kubernetes/externalSecret', () => ({ + deleteExternalSecret: (...args: any[]) => mockDeleteExternalSecret(...args), +})); + +jest.mock('server/lib/kubernetes', () => ({ + createOrUpdateNamespace: (...args: any[]) => mockCreateOrUpdateNamespace(...args), +})); + +import { codefreshDeploy, codefreshDestroy } from '../cli'; + +const secretProviders = { + aws: { + enabled: true, + clusterSecretStore: 'aws-secrets', + refreshInterval: '1h', + allowedPrefixes: ['repo/example/'], + }, +}; + +function encoded(value: string) { + return Buffer.from(value, 'utf8').toString('base64'); +} + +function createDeploy(overrides: any = {}) { + const patch = jest.fn().mockResolvedValue(undefined); + return { + uuid: 'deploy-uuid', + branchName: 'feature-branch', + env: { + API_TOKEN: '{{aws:repo/example/service:API_TOKEN}}', + }, + build: { + uuid: 'build-uuid', + sha: 'build-sha', + namespace: 'env-build-uuid', + commentRuntimeEnv: {}, + enableFullYaml: true, + }, + service: { + name: 'service-name', + deployPipelineId: 'service/deploy', + deployTrigger: 'deploy-trigger', + destroyPipelineId: 'service/destroy', + destroyTrigger: 'destroy-trigger', + branchName: 'service-branch', + }, + deployable: { + name: 'example-service', + deployPipelineId: 'deployable/deploy', + deployTrigger: 'deployable-trigger', + destroyPipelineId: 'deployable/destroy', + destroyTrigger: 'deployable-destroy-trigger', + branchName: 'deployable-branch', + }, + $query: jest.fn(() => ({ patch })), + ...overrides, + } as any; +} + +describe('codefresh cli external secret resolution', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockShellPromise.mockResolvedValue('codefresh-run-id\n'); + mockGetAllConfigs.mockResolvedValue({ secretProviders }); + mockProcessSecretRefs.mockImplementation(({ secretRefs }) => ({ + secretRefs, + expectedKeysPerSecret: { 'lfc-example-service-aws': secretRefs.map((ref: any) => ref.envKey) }, + syncTokensPerSecret: { 'lfc-example-service-aws': 'sync-token' }, + warnings: [], + })); + mockWaitForSecretSync.mockResolvedValue(undefined); + mockCreateOrUpdateNamespace.mockResolvedValue(undefined); + mockDeleteExternalSecret.mockResolvedValue(undefined); + mockDeleteNamespacedSecret.mockResolvedValue(undefined); + mockReadNamespacedSecret.mockResolvedValue({ + body: { + data: { + API_TOKEN: encoded('resolved-token'), + CONFIG__credentials__token: encoded('nested-token'), + }, + }, + }); + }); + + test('resolves deploy env secret refs before invoking Codefresh and redacts debug command', async () => { + const deploy = createDeploy(); + + await expect(codefreshDeploy(deploy, deploy.build, deploy.service, deploy.deployable)).resolves.toBe( + 'codefresh-run-id' + ); + + const [command, options] = mockShellPromise.mock.calls[0]; + expect(command).toContain("-v '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).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 () => { + mockReadNamespacedSecret.mockResolvedValue({ + body: { + data: { + API_TOKEN: encoded("resolved'token $(echo bad)"), + }, + }, + }); + const deploy = createDeploy(); + + await codefreshDeploy(deploy, deploy.build, deploy.service, 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]'"); + }); + + test('resolves destroy env secret refs before invoking Codefresh and cleans up synced resources', async () => { + const deploy = createDeploy({ + env: {}, + build: { + uuid: 'build-uuid', + sha: 'build-sha', + namespace: 'env-build-uuid', + commentRuntimeEnv: { + API_TOKEN: '{{aws:repo/example/service:API_TOKEN}}', + }, + enableFullYaml: false, + }, + }); + + await expect(codefreshDestroy(deploy)).resolves.toBe('codefresh-run-id'); + + const [command, options] = mockShellPromise.mock.calls[0]; + expect(command).toContain("codefresh run 'service/destroy' -b 'service-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]'"); + expect(mockDeleteExternalSecret).toHaveBeenCalledWith('service-name-aws-secrets', 'env-build-uuid'); + expect(mockDeleteNamespacedSecret).toHaveBeenCalledWith('service-name-aws-secrets', 'env-build-uuid'); + }); + + test('cleans up synced resources when Codefresh destroy fails', async () => { + mockShellPromise.mockRejectedValueOnce(new Error('destroy failed')); + const deploy = createDeploy({ + env: {}, + build: { + uuid: 'build-uuid', + sha: 'build-sha', + namespace: 'env-build-uuid', + commentRuntimeEnv: { + API_TOKEN: '{{aws:repo/example/service:API_TOKEN}}', + }, + enableFullYaml: false, + }, + }); + + await expect(codefreshDestroy(deploy)).rejects.toThrow('destroy failed'); + + expect(mockDeleteExternalSecret).toHaveBeenCalledWith('service-name-aws-secrets', 'env-build-uuid'); + expect(mockDeleteNamespacedSecret).toHaveBeenCalledWith('service-name-aws-secrets', 'env-build-uuid'); + }); + + test('resolves nested object env refs and preserves JSON stringification for Codefresh variables', async () => { + const deploy = createDeploy({ + env: { + CONFIG: { + credentials: { + token: '{{aws:repo/example/service:API_TOKEN}}', + }, + mode: 'test', + }, + }, + }); + + await codefreshDeploy(deploy, deploy.build, deploy.service, deploy.deployable); + + const [command, options] = mockShellPromise.mock.calls[0]; + expect(command).toContain( + `-v 'CONFIG'='${JSON.stringify({ credentials: { token: 'nested-token' }, mode: 'test' })}'` + ); + expect(options.redactCommand).toContain("-v 'CONFIG'='[REDACTED]'"); + }); + + test('preserves no-secret behavior without loading secret provider config', async () => { + const deploy = createDeploy({ + env: { + API_URL: 'https://example.invalid', + }, + }); + + await codefreshDeploy(deploy, deploy.build, deploy.service, deploy.deployable); + + expect(mockGetAllConfigs).not.toHaveBeenCalled(); + expect(mockProcessSecretRefs).not.toHaveBeenCalled(); + expect(mockShellPromise.mock.calls[0][0]).toContain("-v 'API_URL'='https://example.invalid'"); + }); + + test('fails before invoking Codefresh when a secret provider is missing', async () => { + mockGetAllConfigs.mockResolvedValue({ secretProviders: undefined }); + const deploy = createDeploy(); + + await expect(codefreshDeploy(deploy, deploy.build, deploy.service, deploy.deployable)).rejects.toThrow( + 'external secret providers are not configured' + ); + + expect(mockShellPromise).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/lib/__tests__/codefreshExternalSecrets.test.ts b/src/server/lib/__tests__/codefreshExternalSecrets.test.ts new file mode 100644 index 0000000..b3e02fc --- /dev/null +++ b/src/server/lib/__tests__/codefreshExternalSecrets.test.ts @@ -0,0 +1,83 @@ +/** + * 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. + */ + +const mockProcessSecretRefs = jest.fn(); +const mockWaitForSecretSync = jest.fn(); +const mockReadNamespacedSecret = jest.fn(); +const mockCreateOrUpdateNamespace = jest.fn(); + +jest.mock('server/services/secretProcessor', () => ({ + SecretProcessor: jest.fn().mockImplementation(() => ({ + processSecretRefs: (...args: any[]) => mockProcessSecretRefs(...args), + waitForSecretSync: (...args: any[]) => mockWaitForSecretSync(...args), + })), +})); + +jest.mock('@kubernetes/client-node', () => ({ + CoreV1Api: jest.fn(), + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn(() => ({ + readNamespacedSecret: (...args: any[]) => mockReadNamespacedSecret(...args), + })), + })), +})); + +jest.mock('server/lib/kubernetes', () => ({ + createOrUpdateNamespace: (...args: any[]) => mockCreateOrUpdateNamespace(...args), +})); + +import { resolveCodefreshExternalSecrets } from '../codefreshExternalSecrets'; + +describe('resolveCodefreshExternalSecrets', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProcessSecretRefs.mockImplementation(({ secretRefs }) => ({ + secretRefs, + expectedKeysPerSecret: { 'lfc-example-service-aws': secretRefs.map((ref: any) => ref.envKey) }, + syncTokensPerSecret: { 'lfc-example-service-aws': 'sync-token' }, + warnings: [], + })); + mockWaitForSecretSync.mockResolvedValue(undefined); + mockCreateOrUpdateNamespace.mockResolvedValue(undefined); + }); + + test('fails when the synced Kubernetes secret is missing the expected key', async () => { + mockReadNamespacedSecret.mockResolvedValue({ + body: { + data: {}, + }, + }); + + await expect( + resolveCodefreshExternalSecrets({ + env: { + API_TOKEN: '{{aws:repo/example/service:API_TOKEN}}', + }, + serviceName: 'example-service', + namespace: 'env-build-uuid', + buildUuid: 'build-uuid', + secretProviders: { + aws: { + enabled: true, + clusterSecretStore: 'aws-secrets', + refreshInterval: '1h', + }, + }, + }) + ).rejects.toThrow("synced Kubernetes secret is missing key 'API_TOKEN'"); + }); +}); diff --git a/src/server/lib/cli.ts b/src/server/lib/cli.ts index b132f46..10480e8 100644 --- a/src/server/lib/cli.ts +++ b/src/server/lib/cli.ts @@ -21,6 +21,11 @@ import { shellPromise } from './shell'; import { getLogger, withLogContext, updateLogContext } from './logger'; import GlobalConfigService from 'server/services/globalConfig'; import { DatabaseSettings } from 'server/services/types/globalConfig'; +import { + cleanupCodefreshExternalSecrets, + hasCodefreshExternalSecretRefs, + resolveCodefreshExternalSecrets, +} from 'server/lib/codefreshExternalSecrets'; /** * Deploys the build @@ -69,12 +74,19 @@ export async function codefreshDeploy(deploy: Deploy, build: Build, service: Ser getLogger().debug('Invoking the codefresh CLI to deploy this deploy'); const envVariables = merge(deploy.env || {}, deploy.build.commentRuntimeEnv); - - const variables = Object.keys(envVariables).map((key) => { - return ` -v '${key}'='${ - typeof envVariables[key] === 'object' ? JSON.stringify(envVariables[key]) : envVariables[key] - }'`; + const serviceName = build?.enableFullYaml ? deployable?.name : service?.name; + const globalConfigs = hasCodefreshExternalSecretRefs(envVariables) + ? await GlobalConfigService.getInstance().getAllConfigs() + : undefined; + const resolvedEnv = await resolveCodefreshExternalSecrets({ + env: envVariables, + serviceName, + namespace: build?.namespace, + buildUuid: build?.uuid, + staticEnv: build?.isStatic, + secretProviders: globalConfigs?.secretProviders, }); + const { variables, redactedVariables } = codefreshVariables(resolvedEnv.env, resolvedEnv.secretEnvKeys); let deployTrigger: string; let serviceDeployPipelineId: string; @@ -86,11 +98,14 @@ export async function codefreshDeploy(deploy: Deploy, build: Build, service: Ser serviceDeployPipelineId = service.deployPipelineId; } - const command = `codefresh run ${serviceDeployPipelineId} -b "${deploy.branchName}" ${variables.join( - ' ' - )} ${deployTrigger} -d`; - getLogger().debug(`About to run codefresh command: command=${command}`); - const output = await shellPromise(command); + const command = `codefresh run ${shellQuote(serviceDeployPipelineId)} -b ${shellQuote( + deploy.branchName + )} ${variables.join(' ')} ${deployTrigger} -d`; + const redactedCommand = `codefresh run ${shellQuote(serviceDeployPipelineId)} -b ${shellQuote( + deploy.branchName + )} ${redactedVariables.join(' ')} ${deployTrigger} -d`; + getLogger().debug(`About to run codefresh command: command=${redactedCommand}`); + const output = await shellPromise(command, { redactCommand: redactedCommand }); getLogger().debug(`Codefresh run output: output=${output}`); const id = output.trim(); return id; @@ -120,39 +135,77 @@ export async function codefreshDestroy(deploy: Deploy) { deploy.env || {}, deploy.build.commentRuntimeEnv ); + const serviceName = deploy.build.enableFullYaml ? deploy.deployable?.name : deploy.service?.name; + const hasExternalSecretRefs = hasCodefreshExternalSecretRefs(envVariables); + const globalConfigs = hasExternalSecretRefs ? await GlobalConfigService.getInstance().getAllConfigs() : undefined; - const variables = Object.keys(envVariables).map((key) => { - return ` -v '${key}'='${ - typeof envVariables[key] === 'object' ? JSON.stringify(envVariables[key]) : envVariables[key] - }'`; - }); + try { + const resolvedEnv = await resolveCodefreshExternalSecrets({ + env: envVariables, + serviceName, + namespace: deploy.build?.namespace, + buildUuid: deploy.build?.uuid, + staticEnv: deploy.build?.isStatic, + secretProviders: globalConfigs?.secretProviders, + }); + const { variables, redactedVariables } = codefreshVariables(resolvedEnv.env, resolvedEnv.secretEnvKeys); - let destroyTrigger: string; - let destroyPipelineId: string; - let serviceBranchName: string; - if (deploy.build.enableFullYaml) { - destroyTrigger = deploy.deployable.destroyTrigger ? `--trigger ${deploy.deployable.destroyTrigger}` : ``; - destroyPipelineId = deploy.deployable.destroyPipelineId; - serviceBranchName = deploy.deployable.branchName; - } else { - destroyTrigger = deploy.service.destroyTrigger ? `--trigger ${deploy.service.destroyTrigger}` : ``; - destroyPipelineId = deploy.service.destroyPipelineId; - serviceBranchName = deploy.service.branchName; - } + let destroyTrigger: string; + let destroyPipelineId: string; + let serviceBranchName: string; + if (deploy.build.enableFullYaml) { + destroyTrigger = deploy.deployable.destroyTrigger ? `--trigger ${deploy.deployable.destroyTrigger}` : ``; + destroyPipelineId = deploy.deployable.destroyPipelineId; + serviceBranchName = deploy.deployable.branchName; + } else { + destroyTrigger = deploy.service.destroyTrigger ? `--trigger ${deploy.service.destroyTrigger}` : ``; + destroyPipelineId = deploy.service.destroyPipelineId; + serviceBranchName = deploy.service.branchName; + } - const command = `codefresh run ${destroyPipelineId} -b "${serviceBranchName}" ${variables.join( - ' ' - )} ${destroyTrigger} -d`; - getLogger().debug(`Destroy command: command=${command}`); - const output = await shellPromise(command); - const id = output?.trim(); - return id; + 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`; + getLogger().debug(`Destroy command: command=${redactedCommand}`); + const output = await shellPromise(command, { redactCommand: redactedCommand }); + const id = output?.trim(); + return id; + } finally { + if (hasExternalSecretRefs) { + await cleanupCodefreshExternalSecrets({ + env: envVariables, + serviceName, + namespace: deploy.build?.namespace, + }); + } + } } catch (error) { getLogger({ error }).error('Codefresh: pipeline destroy failed'); throw error; } } +function codefreshVariables(envVariables: Record, secretEnvKeys: Set) { + const variables = Object.keys(envVariables).map((key) => codefreshVariable(key, envVariables[key])); + const redactedVariables = Object.keys(envVariables).map((key) => + codefreshVariable(key, secretEnvKeys.has(key) ? '[REDACTED]' : envVariables[key]) + ); + + return { variables, redactedVariables }; +} + +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, "'\\''")}'`; +} + /** * Waits for codefresh to successfully complete * @param id the codefresh ID to watch diff --git a/src/server/lib/codefreshExternalSecrets.ts b/src/server/lib/codefreshExternalSecrets.ts new file mode 100644 index 0000000..e61ea1f --- /dev/null +++ b/src/server/lib/codefreshExternalSecrets.ts @@ -0,0 +1,234 @@ +/** + * 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 { CoreV1Api, KubeConfig } from '@kubernetes/client-node'; +import { createOrUpdateNamespace } from 'server/lib/kubernetes'; +import { deleteExternalSecret } from 'server/lib/kubernetes/externalSecret'; +import { generateSecretName } from 'server/lib/kubernetes/secretNames'; +import { getLogger } from 'server/lib/logger'; +import { parseSecretRef, SecretRefWithEnvKey } from 'server/lib/secretRefs'; +import { SecretProcessor } from 'server/services/secretProcessor'; +import { SecretProvidersConfig } from 'server/services/types/globalConfig'; + +type CodefreshEnvValue = string | number | boolean | null | Record | any[]; +type CodefreshEnv = Record; + +interface SecretRefLocation { + topLevelKey: string; + path: Array; + ref: SecretRefWithEnvKey; +} + +export interface ResolveCodefreshExternalSecretsOptions { + env: CodefreshEnv; + serviceName?: string; + namespace?: string; + buildUuid?: string; + staticEnv?: boolean; + secretProviders: SecretProvidersConfig | undefined; +} + +export interface ResolveCodefreshExternalSecretsResult { + env: CodefreshEnv; + secretEnvKeys: Set; +} + +export interface CleanupCodefreshExternalSecretsOptions { + env: CodefreshEnv; + serviceName?: string; + namespace?: string; +} + +function getK8sClient(): CoreV1Api { + const kc = new KubeConfig(); + kc.loadFromDefault(); + return kc.makeApiClient(CoreV1Api); +} + +function secretEnvKeyFor(topLevelKey: string, path: Array): string { + if (path.length === 0) { + return topLevelKey; + } + + const suffix = path.map((part) => String(part).replace(/[^A-Za-z0-9_.-]/g, '_')).join('__'); + return `${topLevelKey}__${suffix}`; +} + +function collectSecretRefLocations( + value: CodefreshEnvValue, + topLevelKey: string, + path: Array = [] +): SecretRefLocation[] { + if (typeof value === 'string') { + const ref = parseSecretRef(value); + return ref ? [{ topLevelKey, path, ref: { ...ref, envKey: secretEnvKeyFor(topLevelKey, path) } }] : []; + } + + if (!value || typeof value !== 'object') { + return []; + } + + if (Array.isArray(value)) { + return value.flatMap((item, index) => collectSecretRefLocations(item, topLevelKey, [...path, index])); + } + + return Object.entries(value).flatMap(([key, nestedValue]) => + collectSecretRefLocations(nestedValue, topLevelKey, [...path, key]) + ); +} + +export function hasCodefreshExternalSecretRefs(env: CodefreshEnv): boolean { + return Object.entries(env).some(([topLevelKey, value]) => collectSecretRefLocations(value, topLevelKey).length > 0); +} + +function cloneAndSet(value: CodefreshEnvValue, path: Array, secretValue: string): CodefreshEnvValue { + if (path.length === 0) { + return secretValue; + } + + if (Array.isArray(value)) { + const clone = [...value]; + const [head, ...tail] = path; + clone[head as number] = cloneAndSet(clone[head as number], tail, secretValue); + return clone; + } + + const clone = { ...(value as Record) }; + const [head, ...tail] = path; + clone[head as string] = cloneAndSet(clone[head as string], tail, secretValue); + return clone; +} + +function decodeSecretValue(encodedValue: string | undefined, envKey: string): string { + if (encodedValue === undefined) { + throw new Error(`Codefresh secret resolution failed: synced Kubernetes secret is missing key '${envKey}'.`); + } + + return Buffer.from(encodedValue, 'base64').toString('utf8'); +} + +export async function resolveCodefreshExternalSecrets( + options: ResolveCodefreshExternalSecretsOptions +): Promise { + const locations = Object.entries(options.env).flatMap(([topLevelKey, value]) => + collectSecretRefLocations(value, topLevelKey) + ); + + if (locations.length === 0) { + return { env: options.env, secretEnvKeys: new Set() }; + } + + if (!options.secretProviders) { + const keys = [...new Set(locations.map((location) => location.topLevelKey))].join(', '); + throw new Error( + `Codefresh secret resolution failed for env keys [${keys}]: external secret providers are not configured.` + ); + } + + if (!options.serviceName || !options.namespace) { + const keys = [...new Set(locations.map((location) => location.topLevelKey))].join(', '); + throw new Error( + `Codefresh secret resolution failed for env keys [${keys}]: service name and namespace are required.` + ); + } + + if (!options.buildUuid) { + const keys = [...new Set(locations.map((location) => location.topLevelKey))].join(', '); + throw new Error(`Codefresh secret resolution failed for env keys [${keys}]: build UUID is required.`); + } + + await createOrUpdateNamespace({ + name: options.namespace, + buildUUID: options.buildUuid, + staticEnv: options.staticEnv ?? false, + waitForReady: true, + }); + + const secretProcessor = new SecretProcessor(options.secretProviders); + const secretResult = await secretProcessor.processSecretRefs({ + secretRefs: locations.map((location) => location.ref), + serviceName: options.serviceName, + namespace: options.namespace, + buildUuid: options.buildUuid, + strict: true, + }); + + if (secretResult.warnings.length > 0) { + throw new Error(`Codefresh secret resolution failed: ${secretResult.warnings.join(' ')}`); + } + + const providerTimeouts = Object.values(options.secretProviders) + .map((provider) => provider.secretSyncTimeout) + .filter((timeout): timeout is number => timeout !== undefined); + const timeout = providerTimeouts.length > 0 ? Math.max(...providerTimeouts) * 1000 : 60000; + + await secretProcessor.waitForSecretSync( + secretResult.expectedKeysPerSecret, + options.namespace, + timeout, + secretResult.syncTokensPerSecret + ); + + const k8sClient = getK8sClient(); + const secretDataByEnvKey = new Map(); + + for (const provider of [...new Set(locations.map((location) => location.ref.provider))]) { + const secretName = generateSecretName(options.serviceName, provider); + const response = await k8sClient.readNamespacedSecret(secretName, options.namespace); + const data = response.body.data || {}; + + for (const location of locations.filter((item) => item.ref.provider === provider)) { + secretDataByEnvKey.set(location.ref.envKey, decodeSecretValue(data[location.ref.envKey], location.ref.envKey)); + } + } + + let resolvedEnv: CodefreshEnv = { ...options.env }; + const secretEnvKeys = new Set(); + + for (const location of locations) { + const secretValue = secretDataByEnvKey.get(location.ref.envKey); + resolvedEnv = { + ...resolvedEnv, + [location.topLevelKey]: cloneAndSet(resolvedEnv[location.topLevelKey], location.path, secretValue ?? ''), + }; + secretEnvKeys.add(location.topLevelKey); + } + + return { env: resolvedEnv, secretEnvKeys }; +} + +export async function cleanupCodefreshExternalSecrets(options: CleanupCodefreshExternalSecretsOptions): Promise { + const locations = Object.entries(options.env).flatMap(([topLevelKey, value]) => + collectSecretRefLocations(value, topLevelKey) + ); + + if (locations.length === 0 || !options.serviceName || !options.namespace) { + return; + } + + const k8sClient = getK8sClient(); + + for (const provider of [...new Set(locations.map((location) => location.ref.provider))]) { + const secretName = generateSecretName(options.serviceName, provider); + await deleteExternalSecret(secretName, options.namespace); + + try { + await k8sClient.deleteNamespacedSecret(secretName, options.namespace); + } catch (error) { + getLogger({ error }).warn(`Codefresh secret cleanup failed name=${secretName}`); + } + } +} diff --git a/src/server/lib/shell.ts b/src/server/lib/shell.ts index ec2386f..285bdb8 100644 --- a/src/server/lib/shell.ts +++ b/src/server/lib/shell.ts @@ -19,12 +19,14 @@ import shell, { ExecOptions } from 'shelljs'; interface Options extends ExecOptions { debug?: boolean; + redactCommand?: string; } export { shell }; export async function shellPromise(cmd: string, options: Options = {}): Promise { - const { debug, ...shellOpts } = options; + const { debug, redactCommand, ...shellOpts } = options; + const displayCommand = redactCommand || cmd; return new Promise((resolve, reject) => { const opts = { @@ -35,11 +37,11 @@ export async function shellPromise(cmd: string, options: Options = {}): Promise< shell.exec(cmd, opts, (code, stdout, stderr) => { if (code !== 0) { if (stderr.length > 0) { - getLogger().debug(`Shell command failed: cmd=${cmd} stderr=${stderr}`); + getLogger().debug(`Shell command failed: cmd=${displayCommand} stderr=${stderr}`); } const options = opts ? JSON.stringify(opts) : ''; reject( - `shellPromise command failed:\nExit code: ${code}\nOptions: ${options}\nCommand:\n${cmd},\n\nstderr:\n${stderr}\n\nstdout:\n${stdout}` + `shellPromise command failed:\nExit code: ${code}\nOptions: ${options}\nCommand:\n${displayCommand},\n\nstderr:\n${stderr}\n\nstdout:\n${stdout}` ); } else { resolve(stdout);