diff --git a/src/server/lib/__tests__/secretRefs.test.ts b/src/server/lib/__tests__/secretRefs.test.ts index 03c9b991..cdb82822 100644 --- a/src/server/lib/__tests__/secretRefs.test.ts +++ b/src/server/lib/__tests__/secretRefs.test.ts @@ -14,9 +14,26 @@ * limitations under the License. */ -import { parseSecretRef, parseSecretRefsFromEnv, isSecretRef, validateSecretRef, SecretRef } from '../secretRefs'; +import { + parseSecretRef, + parseSecretRefsFromEnv, + isSecretRef, + validateSecretRef, + SecretRef, + preserveSecretRefsInTemplate, +} from '../secretRefs'; describe('secretRefs', () => { + describe('preserveSecretRefsInTemplate', () => { + it('preserves secret refs while allowing other template variables to render', () => { + const preservation = preserveSecretRefsInTemplate('{{aws:myapp/db:password}} and {{service_url}}'); + + expect(preservation.restore(preservation.template.replace('{{service_url}}', 'https://example.com'))).toBe( + '{{aws:myapp/db:password}} and https://example.com' + ); + }); + }); + describe('isSecretRef', () => { it('returns true for valid AWS secret reference', () => { expect(isSecretRef('{{aws:myapp/db:password}}')).toBe(true); diff --git a/src/server/lib/envVariables.ts b/src/server/lib/envVariables.ts index bb388028..96a378f3 100644 --- a/src/server/lib/envVariables.ts +++ b/src/server/lib/envVariables.ts @@ -30,6 +30,7 @@ import { import { getLogger } from 'server/lib/logger'; import { LifecycleError } from './errors'; import GlobalConfigService from 'server/services/globalConfig'; +import { preserveSecretRefsInTemplate } from 'server/lib/secretRefs'; const ALLOWED_PROPERTIES = [ 'branchName', @@ -306,16 +307,8 @@ export abstract class EnvironmentVariables { * @returns the rendered template */ async customRender(template, data, useDefaultUUID = true, namespace: string) { - const secretPatternRegex = /\{\{(aws|gcp|barbican|vault|onepassword):([^}]+)\}\}/g; - const secretPlaceholders: Map = new Map(); - let placeholderIndex = 0; - - template = template.replace(secretPatternRegex, (match) => { - const placeholder = `__SECRET_PLACEHOLDER_${placeholderIndex}__`; - secretPlaceholders.set(placeholder, match); - placeholderIndex++; - return placeholder; - }); + const secretPreservation = preserveSecretRefsInTemplate(template); + template = secretPreservation.template; // Convert any remaining double-curly placeholders into triple-curly ones to render unescaped HTML template = template.replace(/{{{?([^{}]*?)}}}?/g, '{{{$1}}}'); @@ -400,13 +393,7 @@ export abstract class EnvironmentVariables { } } - let rendered = mustache.render(template, data); - - for (const [placeholder, original] of secretPlaceholders.entries()) { - rendered = rendered.replace(placeholder, original); - } - - return rendered; + return secretPreservation.restore(mustache.render(template, data)); } public abstract resolve( diff --git a/src/server/lib/helm/__tests__/helm.test.ts b/src/server/lib/helm/__tests__/helm.test.ts index 39ca9205..34cd94ec 100644 --- a/src/server/lib/helm/__tests__/helm.test.ts +++ b/src/server/lib/helm/__tests__/helm.test.ts @@ -358,5 +358,60 @@ describe('Helm tests', () => { expect(customValues).toContain('deployment.env.DB__URL="{{aws:myapp/rds-credentials:url}}"'); }); + + test('rejects Helm chart value secret refs for Codefresh deploys', async () => { + const mockGetAllConfigs = jest.fn().mockResolvedValue({ + lifecycleDefaults: { + deployCluster: 'test-cluster', + cfStepType: 'helm', + }, + 'lifecycle-app': { + chart: { + values: [], + }, + }, + serviceDefaults: { + defaultIPWhiteList: '[1.1.1.1/32]', + }, + domainDefaults: { + http: 'preview.lifecycle.com', + }, + }); + const mockGetOrgChartName = jest.fn().mockResolvedValue('lifecycle-app'); + + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: mockGetAllConfigs, + getOrgChartName: mockGetOrgChartName, + }); + + const deploy = { + uuid: 'test-uuid', + dockerImage: 'repo/app:tag', + deployable: { + name: 'sample-backend', + buildUUID: 'build-123', + port: 8080, + helm: { + chart: { + name: 'lifecycle-app', + values: ['auth.password={{aws:repo/example/database:POSTGRES_PASSWORD}}'], + }, + docker: { + app: {}, + }, + }, + }, + build: { + namespace: 'env-test', + commentRuntimeEnv: {}, + isStatic: false, + }, + $fetchGraph: jest.fn(), + } as unknown as Deploy; + + await expect(helmOrgAppDeployStep(deploy)).rejects.toThrow( + 'Codefresh Helm deploy path does not support helm.chart.values secret refs' + ); + }); }); }); diff --git a/src/server/lib/helm/__tests__/secretValueRefs.test.ts b/src/server/lib/helm/__tests__/secretValueRefs.test.ts new file mode 100644 index 00000000..66f2e417 --- /dev/null +++ b/src/server/lib/helm/__tests__/secretValueRefs.test.ts @@ -0,0 +1,110 @@ +/** + * 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 { + assertNoHelmSecretValueRefs, + buildHelmSecretVolumeMounts, + buildHelmSecretVolumes, + generateHelmSecretKey, + HELM_SECRET_MOUNT_ROOT, + splitHelmSecretValueRefs, +} from 'server/lib/helm/secretValueRefs'; + +describe('helm secret value refs', () => { + it('detects full-value Helm secret refs and creates stable set-file metadata', () => { + const result = splitHelmSecretValueRefs( + ['auth.password={{aws:repo/example/database:POSTGRES_PASSWORD}}'], + 'example-db' + ); + + expect(result.plainValues).toEqual([]); + expect(result.secretRefs).toEqual([ + { + envKey: generateHelmSecretKey('auth.password', { + provider: 'aws', + path: 'repo/example/database', + key: 'POSTGRES_PASSWORD', + }), + helmKey: 'auth.password', + provider: 'aws', + path: 'repo/example/database', + key: 'POSTGRES_PASSWORD', + }, + ]); + expect(result.secretSetFiles).toEqual([ + { + helmKey: 'auth.password', + secretName: 'example-db-aws-secrets', + secretKey: result.secretRefs[0].envKey, + provider: 'aws', + mountPath: `${HELM_SECRET_MOUNT_ROOT}/example-db-aws-secrets/${result.secretRefs[0].envKey}`, + }, + ]); + }); + + it('preserves plain Helm values', () => { + const result = splitHelmSecretValueRefs(['auth.database=app_db'], 'example-db'); + + expect(result.plainValues).toEqual(['auth.database=app_db']); + expect(result.secretRefs).toEqual([]); + expect(result.secretSetFiles).toEqual([]); + }); + + it('rejects partial secret interpolation', () => { + expect(() => + splitHelmSecretValueRefs( + ['auth.url=postgres://user:{{aws:repo/example/database:POSTGRES_PASSWORD}}@host/db'], + 'example-db' + ) + ).toThrow("Helm custom value 'auth.url' uses unsupported partial or malformed secret interpolation"); + }); + + it('rejects unsupported secret refs on Codefresh deploy paths', () => { + expect(() => + assertNoHelmSecretValueRefs( + ['auth.password={{aws:repo/example/database:POSTGRES_PASSWORD}}'], + 'Codefresh Helm deploy path' + ) + ).toThrow('Codefresh Helm deploy path does not support helm.chart.values secret refs'); + }); + + it('builds secret volumes and mounts for only the required Helm keys', () => { + const result = splitHelmSecretValueRefs( + [ + 'auth.username={{aws:repo/example/database:POSTGRES_USER}}', + 'auth.password={{aws:repo/example/database:POSTGRES_PASSWORD}}', + ], + 'example-db' + ); + + expect(buildHelmSecretVolumes(result.secretSetFiles)).toEqual([ + { + name: expect.stringMatching(/^helm-secret-example-db-aws-secrets-[a-f0-9]{8}$/), + secret: { + secretName: 'example-db-aws-secrets', + items: result.secretRefs.map((ref) => ({ key: ref.envKey, path: ref.envKey })), + }, + }, + ]); + expect(buildHelmSecretVolumeMounts(result.secretSetFiles)).toEqual([ + { + name: expect.stringMatching(/^helm-secret-example-db-aws-secrets-[a-f0-9]{8}$/), + mountPath: `${HELM_SECRET_MOUNT_ROOT}/example-db-aws-secrets`, + readOnly: true, + }, + ]); + }); +}); diff --git a/src/server/lib/helm/helm.ts b/src/server/lib/helm/helm.ts index 5dc27ee7..a88b4313 100644 --- a/src/server/lib/helm/helm.ts +++ b/src/server/lib/helm/helm.ts @@ -40,6 +40,7 @@ import { waitForInProgressDeploys, } from 'server/lib/codefresh/utils/generateCodefreshCmd'; import { randomAlphanumeric } from '../random'; +import { assertNoHelmSecretValueRefs } from 'server/lib/helm/secretValueRefs'; const CODEFRESH_PATH = `${TMP_PATH}/codefresh`; const escapeCodefreshEnvKey = (key: string) => key.replace(/_/g, '__'); @@ -61,6 +62,7 @@ export async function helmPublicDeployStep(deploy: Deploy): Promise { + const equalIndex = value.indexOf('='); + + if (equalIndex === -1) { + if (containsSecretRefTemplate(value)) { + throw new Error(`Helm custom value '${value}' contains a secret ref but is not a key=value entry`); + } + + plainValues.push(value); + return; + } + + const helmKey = value.substring(0, equalIndex); + const helmValue = value.substring(equalIndex + 1); + const secretRef = parseSecretRef(helmValue); + + if (!secretRef) { + if (containsSecretRefTemplate(helmValue)) { + throw new Error(`Helm custom value '${helmKey}' uses unsupported partial or malformed secret interpolation`); + } + + plainValues.push(value); + return; + } + + const secretKey = generateHelmSecretKey(helmKey, secretRef); + const secretName = generateSecretName(serviceName, secretRef.provider); + + secretRefs.push({ + envKey: secretKey, + helmKey, + ...secretRef, + }); + secretSetFiles.push({ + helmKey, + secretName, + secretKey, + provider: secretRef.provider, + mountPath: `${HELM_SECRET_MOUNT_ROOT}/${secretName}/${secretKey}`, + }); + }); + + return { plainValues, secretRefs, secretSetFiles }; +} + +export function assertNoHelmSecretValueRefs(values: string[], deployPath: string): void { + let result: HelmSecretValueRefsResult; + + try { + result = splitHelmSecretValueRefs(values, 'unsupported-helm-secret-refs'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${deployPath} does not support helm.chart.values secret refs: ${message}`); + } + + if (result.secretRefs.length > 0) { + const refs = result.secretRefs.map((ref) => `${ref.helmKey}=${formatSecretRef(ref)}`).join(', '); + throw new Error(`${deployPath} does not support helm.chart.values secret refs: ${refs}`); + } +} + +export function buildHelmSecretVolumeName(secretName: string): string { + const hash = shortHash(secretName, 8); + const sanitized = secretName + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 41); + + return `helm-secret-${sanitized || 'secret'}-${hash}`; +} + +export function buildHelmSecretVolumes(secretSetFiles: HelmSecretSetFile[]): any[] { + const filesBySecret = new Map>(); + + secretSetFiles.forEach((file) => { + if (!filesBySecret.has(file.secretName)) { + filesBySecret.set(file.secretName, new Set()); + } + + filesBySecret.get(file.secretName)!.add(file.secretKey); + }); + + return Array.from(filesBySecret.entries()).map(([secretName, secretKeys]) => ({ + name: buildHelmSecretVolumeName(secretName), + secret: { + secretName, + items: Array.from(secretKeys).map((secretKey) => ({ key: secretKey, path: secretKey })), + }, + })); +} + +export function buildHelmSecretVolumeMounts(secretSetFiles: HelmSecretSetFile[]): any[] { + const secretNames = Array.from(new Set(secretSetFiles.map((file) => file.secretName))); + + return secretNames.map((secretName) => ({ + name: buildHelmSecretVolumeName(secretName), + mountPath: `${HELM_SECRET_MOUNT_ROOT}/${secretName}`, + readOnly: true, + })); +} diff --git a/src/server/lib/helm/utils.ts b/src/server/lib/helm/utils.ts index 744461dd..4c4432f9 100644 --- a/src/server/lib/helm/utils.ts +++ b/src/server/lib/helm/utils.ts @@ -21,8 +21,8 @@ import mustache from 'mustache'; import { HYPHEN_REPLACEMENT, HYPHEN_REPLACEMENT_REGEX } from 'shared/constants'; import { NodeAffinity, Toleration } from './types'; import { LIFECYCLE_UI_URL, APP_HOST } from 'shared/config'; -import { generateSecretName } from 'server/lib/kubernetes/externalSecret'; -import { parseSecretRefsFromEnv } from 'server/lib/secretRefs'; +import { generateSecretName } from 'server/lib/kubernetes/secretNames'; +import { parseSecretRefsFromEnv, preserveSecretRefsInTemplate } from 'server/lib/secretRefs'; export const renderTemplate = async (build: Build, values: string[] = []): Promise => { const db = build.$knex(); @@ -31,7 +31,8 @@ export const renderTemplate = async (build: Build, values: string[] = []): Promi const joinedValues = values.join('%%SPLIT%%'); const processedValues = joinedValues.replace(/-/g, HYPHEN_REPLACEMENT); - const renderedString = mustache.render(processedValues, availableEnvVars); + const secretPreservation = preserveSecretRefsInTemplate(processedValues); + const renderedString = secretPreservation.restore(mustache.render(secretPreservation.template, availableEnvVars)); return renderedString.replace(HYPHEN_REPLACEMENT_REGEX, '-').split('%%SPLIT%%'); }; diff --git a/src/server/lib/kubernetes/externalSecret.ts b/src/server/lib/kubernetes/externalSecret.ts index b72ac711..da6a1dcc 100644 --- a/src/server/lib/kubernetes/externalSecret.ts +++ b/src/server/lib/kubernetes/externalSecret.ts @@ -21,6 +21,7 @@ import { getLogger } from 'server/lib/logger'; import { SecretRefWithEnvKey } from 'server/lib/secretRefs'; import { SecretProviderConfig } from 'server/services/types/globalConfig'; import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; +import { generateSecretName } from 'server/lib/kubernetes/secretNames'; export const EXTERNAL_SECRET_FORCE_SYNC_ANNOTATION = 'force-sync'; export const TARGET_SECRET_SYNC_TOKEN_ANNOTATION = 'lfc/secret-sync-token'; @@ -70,20 +71,7 @@ export interface GenerateExternalSecretOptions { forceSyncToken?: string; } -const MAX_NAME_LENGTH = 63; - -export function generateSecretName(serviceName: string, provider: string): string { - const suffix = `-${provider}-secrets`; - const maxServiceNameLength = MAX_NAME_LENGTH - suffix.length; - - let truncatedName = serviceName.substring(0, maxServiceNameLength); - - if (truncatedName.endsWith('-')) { - truncatedName = truncatedName.slice(0, -1); - } - - return `${truncatedName}${suffix}`; -} +export { generateSecretName }; export function groupSecretRefsByProvider(refs: SecretRefWithEnvKey[]): Record { const grouped: Record = {}; diff --git a/src/server/lib/kubernetes/secretNames.ts b/src/server/lib/kubernetes/secretNames.ts new file mode 100644 index 00000000..6b608353 --- /dev/null +++ b/src/server/lib/kubernetes/secretNames.ts @@ -0,0 +1,30 @@ +/** + * 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. + */ + +const MAX_NAME_LENGTH = 63; + +export function generateSecretName(serviceName: string, provider: string): string { + const suffix = `-${provider}-secrets`; + const maxServiceNameLength = MAX_NAME_LENGTH - suffix.length; + + let truncatedName = serviceName.substring(0, maxServiceNameLength); + + if (truncatedName.endsWith('-')) { + truncatedName = truncatedName.slice(0, -1); + } + + return `${truncatedName}${suffix}`; +} diff --git a/src/server/lib/nativeHelm/__tests__/helm.test.ts b/src/server/lib/nativeHelm/__tests__/helm.test.ts index 91721932..6d13dc5b 100644 --- a/src/server/lib/nativeHelm/__tests__/helm.test.ts +++ b/src/server/lib/nativeHelm/__tests__/helm.test.ts @@ -22,6 +22,7 @@ import { constructHelmCommand, ChartType, constructHelmCustomValues, + constructHelmCustomValueConfiguration, mergeHelmConfigWithGlobal, validateHelmConfiguration, } from '../utils'; @@ -297,6 +298,38 @@ describe('Native Helm', () => { expect(result).toContain('-f values2.yaml'); }); + it('should render secret-backed values with --set-file', () => { + const result = constructHelmCommand( + 'upgrade --install', + 'my-chart', + 'my-release', + 'my-namespace', + ['auth.database=app_db'], + [], + ChartType.PUBLIC, + undefined, + undefined, + undefined, + undefined, + undefined, + [ + { + helmKey: 'auth.password', + secretName: 'example-db-aws-secrets', + secretKey: 'helm.auth.password.abc123', + provider: 'aws', + mountPath: '/var/run/lifecycle/helm-secrets/example-db-aws-secrets/helm.auth.password.abc123', + }, + ] + ); + + expect(result).toContain('--set "auth.database=app_db"'); + expect(result).toContain( + '--set-file "auth.password=/var/run/lifecycle/helm-secrets/example-db-aws-secrets/helm.auth.password.abc123"' + ); + expect(result).not.toContain('{{aws:'); + }); + it('should use custom args from global_config when provided', () => { const result = constructHelmCommand( 'upgrade --install', @@ -640,6 +673,48 @@ describe('Native Helm', () => { expect(result.args[0]).toContain("--post-renderer '/opt/bin/post-renderer'"); expect(result.args[0]).toContain("--post-renderer-args '--mode=prod'"); }); + + it('should mount Helm secret set-file volumes when configured', async () => { + const result = await createHelmContainer( + 'no-repo', + 'my-chart', + 'my-release', + 'my-namespace', + '3.12.0', + [], + [], + ChartType.PUBLIC, + 'my-service', + 'my-job-name', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + [ + { + helmKey: 'auth.password', + secretName: 'example-db-aws-secrets', + secretKey: 'helm.auth.password.abc123', + provider: 'aws', + mountPath: '/var/run/lifecycle/helm-secrets/example-db-aws-secrets/helm.auth.password.abc123', + }, + ] + ); + + expect(result.volumeMounts).toEqual( + expect.arrayContaining([ + { + name: expect.stringMatching(/^helm-secret-example-db-aws-secrets-[a-f0-9]{8}$/), + mountPath: '/var/run/lifecycle/helm-secrets/example-db-aws-secrets', + readOnly: true, + }, + ]) + ); + expect(result.args[0]).toContain('--set-file "auth.password='); + }); }); describe('mergeHelmConfigWithGlobal', () => { @@ -828,6 +903,160 @@ describe('Native Helm', () => { expect(customValues).toContain('ingress.ipAllowlist[1]=2.2.2.2/32'); }); + it('extracts secret-backed chart values after final value precedence is applied', async () => { + mockGetAllConfigs.mockResolvedValue({ + postgresql: { + chart: { + values: ['auth.password=global-password', 'auth.database=app_db'], + }, + }, + }); + + const deploy = { + uuid: 'test-uuid', + deployable: { + name: 'example-db', + buildUUID: 'build-123', + helm: { + chart: { + name: 'postgresql', + values: ['auth.password={{aws:repo/example/database:POSTGRES_PASSWORD}}'], + }, + }, + }, + build: { + commentRuntimeEnv: {}, + isStatic: false, + }, + } as any; + + const result = await constructHelmCustomValueConfiguration(deploy, ChartType.PUBLIC); + + expect(result.customValues).toContain('auth.database=app_db'); + expect(result.customValues).not.toContain('auth.password=global-password'); + expect(result.customValues).not.toEqual( + expect.arrayContaining(['auth.password={{aws:repo/example/database:POSTGRES_PASSWORD}}']) + ); + expect(result.helmSecretRefs).toEqual([ + expect.objectContaining({ + helmKey: 'auth.password', + provider: 'aws', + path: 'repo/example/database', + key: 'POSTGRES_PASSWORD', + }), + ]); + expect(result.secretSetFiles).toEqual([ + expect.objectContaining({ + helmKey: 'auth.password', + secretName: 'example-db-aws-secrets', + provider: 'aws', + }), + ]); + }); + + it('lets later generated env values override earlier secret-backed chart values', async () => { + const deploy = { + uuid: 'test-uuid', + dockerImage: 'repo/app:tag', + env: { + FOO: 'plain-from-lifecycle', + }, + deployable: { + name: 'sample-backend', + buildUUID: 'build-123', + port: 8080, + helm: { + disableIngressHost: true, + chart: { + name: 'lifecycle-app', + values: ['deployment.env.FOO={{aws:repo/example/app:FOO}}'], + }, + docker: { + app: {}, + }, + }, + }, + build: { + commentRuntimeEnv: {}, + isStatic: false, + }, + } as any; + + const result = await constructHelmCustomValueConfiguration(deploy, ChartType.ORG_CHART); + + expect(result.customValues).toContain('deployment.env.FOO="plain-from-lifecycle"'); + expect(result.customValues).not.toContain('deployment.env.FOO={{aws:repo/example/app:FOO}}'); + expect(result.helmSecretRefs).toEqual([]); + expect(result.secretSetFiles).toEqual([]); + }); + + it('lets later secret-backed chart values override earlier plain values', async () => { + const deploy = { + uuid: 'test-uuid', + deployable: { + name: 'example-db', + buildUUID: 'build-123', + helm: { + chart: { + name: 'local', + values: ['auth.password=dev-password', 'auth.password={{aws:repo/example/database:POSTGRES_PASSWORD}}'], + }, + }, + }, + build: { + commentRuntimeEnv: {}, + isStatic: false, + }, + } as any; + + const result = await constructHelmCustomValueConfiguration(deploy, ChartType.LOCAL); + + expect(result.customValues).not.toContain('auth.password=dev-password'); + expect(result.helmSecretRefs).toEqual([ + expect.objectContaining({ + helmKey: 'auth.password', + provider: 'aws', + path: 'repo/example/database', + key: 'POSTGRES_PASSWORD', + }), + ]); + expect(result.secretSetFiles).toEqual([ + expect.objectContaining({ + helmKey: 'auth.password', + secretName: 'example-db-aws-secrets', + provider: 'aws', + }), + ]); + }); + + it('ignores losing partial secret interpolation when a later plain value wins', async () => { + const deploy = { + uuid: 'test-uuid', + deployable: { + name: 'example-db', + buildUUID: 'build-123', + helm: { + chart: { + name: 'local', + values: [ + 'auth.url=postgres://user:{{aws:repo/example/database:POSTGRES_PASSWORD}}@host/db', + 'auth.url=postgres://user:password@host/db', + ], + }, + }, + }, + build: { + commentRuntimeEnv: {}, + isStatic: false, + }, + } as any; + + const result = await constructHelmCustomValueConfiguration(deploy, ChartType.LOCAL); + + expect(result.customValues).toContain('auth.url=postgres://user:password@host/db'); + expect(result.secretSetFiles).toEqual([]); + }); + it('respects disableIngressHost for org charts', async () => { const deploy = { uuid: 'test-uuid', @@ -1397,6 +1626,39 @@ describe('Native Helm', () => { expect(errors).toContain('Native Helm post-renderer command is required when post-renderer is enabled'); }); + + it('rejects debug and dry-run args when Helm secret set-files are present', async () => { + mockGetAllConfigs.mockResolvedValue({ + helmDefaults: { + nativeHelm: { + image: 'registry.example.com/default-helm-runner:1.0.0', + defaultArgs: '--debug', + }, + }, + }); + + const deploy = { + uuid: 'test-uuid', + deployable: { + name: 'example-db', + buildUUID: 'build-123', + helm: { + chart: { + name: 'postgresql', + values: ['auth.password={{aws:repo/example/database:POSTGRES_PASSWORD}}'], + }, + }, + }, + build: { + commentRuntimeEnv: {}, + isStatic: false, + }, + } as any; + + const errors = await validateHelmConfiguration(deploy); + + expect(errors).toContain('Helm args --debug and --dry-run cannot be used with secret-backed Helm custom values'); + }); }); describe('nativeHelmDeploy', () => { diff --git a/src/server/lib/nativeHelm/helm.ts b/src/server/lib/nativeHelm/helm.ts index 0427dddd..0a52cfdc 100644 --- a/src/server/lib/nativeHelm/helm.ts +++ b/src/server/lib/nativeHelm/helm.ts @@ -31,6 +31,7 @@ import { ingressBannerSnippet } from 'server/lib/helm/utils'; import { constructHelmDeploysBuildMetaData } from 'server/lib/helm/helm'; import { HelmDeployOptions, + HelmConfiguration, ChartType, HelmPostRendererConfig, determineChartType, @@ -51,6 +52,13 @@ import { import { createHelmJob as createHelmJobFromFactory } from 'server/lib/kubernetes/jobFactory'; import { ensureServiceAccountForJob } from 'server/lib/kubernetes/common/serviceAccount'; import { getLogArchivalService } from 'server/services/logArchival'; +import { parseSecretRefsFromEnv, SecretRefWithEnvKey } from 'server/lib/secretRefs'; +import { SecretProcessor } from 'server/services/secretProcessor'; +import { + buildHelmSecretVolumes, + buildHelmSecretVolumeMounts, + HelmSecretSetFile, +} from 'server/lib/helm/secretValueRefs'; export interface JobResult { completed: boolean; @@ -83,7 +91,8 @@ export async function createHelmContainer( chartVersion?: string, registryAuth?: RegistryAuthConfig, helmImage?: string, - postRenderer?: HelmPostRendererConfig + postRenderer?: HelmPostRendererConfig, + secretSetFiles: HelmSecretSetFile[] = [] ): Promise { const script = generateHelmInstallScript( repoName, @@ -98,7 +107,8 @@ export async function createHelmContainer( defaultArgs, chartVersion, registryAuth, - postRenderer + postRenderer, + secretSetFiles ); return { @@ -118,6 +128,7 @@ export async function createHelmContainer( name: 'helm-workspace', mountPath: '/workspace', }, + ...buildHelmSecretVolumeMounts(secretSetFiles), ], }; } @@ -169,7 +180,8 @@ export function createWaitForPriorDeploysInitContainer(namespace: string, servic export async function generateHelmManifest( deploy: Deploy, jobName: string, - options: HelmDeployOptions + options: HelmDeployOptions, + helmConfigOverride?: HelmConfiguration ): Promise { await deploy.$fetchGraph('deployable.repository'); await deploy.$fetchGraph('build'); @@ -177,7 +189,8 @@ export async function generateHelmManifest( const deployable = requireDeployable(deploy); const { build } = deploy; const repository = deployable.repository; - const helmConfig = await getHelmConfiguration(deploy); + const helmConfig = helmConfigOverride || (await getHelmConfiguration(deploy)); + const secretSetFiles = helmConfig.secretSetFiles || []; const serviceAccountName = await ensureServiceAccountForJob(options.namespace, 'deploy'); @@ -219,7 +232,8 @@ export async function generateHelmManifest( chartVersion, registryAuth, helmImage, - postRenderer + postRenderer, + secretSetFiles ); const volumeConfig = { @@ -229,6 +243,7 @@ export async function generateHelmManifest( name: 'helm-workspace', emptyDir: {}, }, + ...buildHelmSecretVolumes(secretSetFiles), ], }; @@ -260,6 +275,51 @@ export async function generateHelmManifest( return yaml.dump(job); } +async function processNativeHelmSecrets( + deploy: Deploy, + namespace: string, + helmConfig: HelmConfiguration +): Promise { + const deployable = requireDeployable(deploy); + const globalConfig = await GlobalConfigService.getInstance().getAllConfigs(); + const secretRefs: SecretRefWithEnvKey[] = [ + ...parseSecretRefsFromEnv((deploy.env || {}) as Record), + ...parseSecretRefsFromEnv((deploy.initEnv || {}) as Record), + ...(helmConfig.helmSecretRefs || []), + ]; + + if (secretRefs.length === 0) { + return; + } + + const secretProcessor = new SecretProcessor(globalConfig.secretProviders); + const secretResult = await secretProcessor.processSecretRefs({ + secretRefs, + serviceName: deployable.name, + namespace, + buildUuid: deploy.uuid, + strict: true, + }); + const secretNames = Object.keys(secretResult.expectedKeysPerSecret); + + if (secretNames.length === 0) { + return; + } + + const providerTimeouts = Object.values(globalConfig.secretProviders || {}) + .map((provider) => provider.secretSyncTimeout) + .filter((timeout): timeout is number => timeout !== undefined); + const timeout = providerTimeouts.length > 0 ? Math.max(...providerTimeouts) * 1000 : 60000; + + getLogger().info(`Helm: waiting for secrets to sync secrets=[${secretNames.join(', ')}]`); + await secretProcessor.waitForSecretSync( + secretResult.expectedKeysPerSecret, + namespace, + timeout, + secretResult.syncTokensPerSecret + ); +} + export async function nativeHelmDeploy(deploy: Deploy, options: HelmDeployOptions): Promise { await deploy.$fetchGraph('build.pullRequest.repository'); await deploy.$fetchGraph('deployable.repository'); @@ -278,7 +338,11 @@ export async function nativeHelmDeploy(deploy: Deploy, options: HelmDeployOption jobId, shortSha, }); - const manifest = await generateHelmManifest(deploy, jobName, options); + const helmConfig = await getHelmConfiguration(deploy); + + await processNativeHelmSecrets(deploy, namespace, helmConfig); + + const manifest = await generateHelmManifest(deploy, jobName, options, helmConfig); const localPath = `${MANIFEST_PATH}/helm/${deploy.uuid}-helm-${shortSha}`; await fs.promises.mkdir(`${MANIFEST_PATH}/helm/`, { recursive: true }); diff --git a/src/server/lib/nativeHelm/utils.ts b/src/server/lib/nativeHelm/utils.ts index 4e3bf2d3..f0174edc 100644 --- a/src/server/lib/nativeHelm/utils.ts +++ b/src/server/lib/nativeHelm/utils.ts @@ -40,6 +40,7 @@ import { NativeHelmConfig as GlobalNativeHelmConfig, NativeHelmPostRendererConfig, } from 'server/services/types/globalConfig'; +import { HelmSecretSetFile, HelmValueSecretRef, splitHelmSecretValueRefs } from 'server/lib/helm/secretValueRefs'; export type HelmPostRendererConfig = NativeHelmPostRendererConfig; @@ -51,6 +52,8 @@ export interface HelmDeployOptions { export interface HelmConfiguration { chartType: ChartType; customValues: string[]; + helmSecretRefs: HelmValueSecretRef[]; + secretSetFiles: HelmSecretSetFile[]; valuesFiles: string[]; chartPath: string; releaseName: string; @@ -122,7 +125,8 @@ export function constructHelmCommand( chartRepoUrl?: string, defaultArgs?: string, chartVersion?: string, - postRenderer?: NativeHelmPostRendererConfig + postRenderer?: NativeHelmPostRendererConfig, + secretSetFiles: HelmSecretSetFile[] = [] ): string { let command = `helm ${action} ${releaseName}`; @@ -159,7 +163,7 @@ export function constructHelmCommand( command += buildPostRendererFlags(postRenderer); - customValues.forEach((value) => { + const appendCustomValue = (value: string) => { const equalIndex = value.indexOf('='); if (equalIndex > -1) { const key = value.substring(0, equalIndex); @@ -169,6 +173,14 @@ export function constructHelmCommand( } else { command += ` --set "${value}"`; } + }; + + customValues.forEach((value) => { + appendCustomValue(value); + }); + + secretSetFiles.forEach((file) => { + command += ` --set-file "${file.helmKey}=${file.mountPath}"`; }); valuesFiles.forEach((file) => { @@ -200,7 +212,8 @@ export function generateHelmInstallScript( defaultArgs?: string, chartVersion?: string, registryAuth?: RegistryAuthConfig, - postRenderer?: NativeHelmPostRendererConfig + postRenderer?: NativeHelmPostRendererConfig, + secretSetFiles: HelmSecretSetFile[] = [] ): string { const helmCommand = constructHelmCommand( 'upgrade --install', @@ -214,7 +227,8 @@ export function generateHelmInstallScript( chartRepoUrl, defaultArgs, chartVersion, - postRenderer + postRenderer, + secretSetFiles ); let script = ['set -e', `echo "Starting helm deployment for ${releaseName}"`, ''].join('\n'); @@ -272,13 +286,15 @@ export async function getHelmConfiguration(deploy: Deploy): Promise { return ingressValues; } -export async function constructHelmCustomValues(deploy: Deploy, chartType: ChartType): Promise { - let customValues: string[] = []; +export interface HelmCustomValueConfiguration { + customValues: string[]; + helmSecretRefs: HelmValueSecretRef[]; + secretSetFiles: HelmSecretSetFile[]; +} + +interface HelmCustomValueEntry { + value: string; + parseSecretRefs: boolean; +} + +export function resolveHelmCustomValuePrecedence(values: string[]): string[] { + return resolveHelmCustomValueEntryPrecedence( + values.map((value) => ({ + value, + parseSecretRefs: false, + })) + ).map((entry) => entry.value); +} + +function chartValueEntries(values: string[]): HelmCustomValueEntry[] { + return values.map((value) => ({ + value, + parseSecretRefs: true, + })); +} + +function generatedValueEntries(values: string[]): HelmCustomValueEntry[] { + return values.map((value) => ({ + value, + parseSecretRefs: false, + })); +} + +function resolveHelmCustomValueEntryPrecedence(values: HelmCustomValueEntry[]): HelmCustomValueEntry[] { + const lastIndexByKey = new Map(); + + values.forEach((entry, index) => { + const equalIndex = entry.value.indexOf('='); + + if (equalIndex === -1) { + return; + } + + lastIndexByKey.set(entry.value.substring(0, equalIndex), index); + }); + + return values.filter((entry, index) => { + const equalIndex = entry.value.indexOf('='); + + if (equalIndex === -1) { + return true; + } + + return lastIndexByKey.get(entry.value.substring(0, equalIndex)) === index; + }); +} + +export async function constructHelmCustomValueConfiguration( + deploy: Deploy, + chartType: ChartType +): Promise { + let customValues: HelmCustomValueEntry[] = []; const { deployable, build } = deploy; const helm = await mergeHelmConfigWithGlobal(deploy); const configs = await GlobalConfigService.getInstance().getAllConfigs(); const chartName = helm?.chart?.name; + const serviceName = deployable?.name || deploy.uuid || 'service'; if (chartType === ChartType.ORG_CHART) { const orgChartName = await GlobalConfigService.getInstance().getOrgChartName(); @@ -673,54 +751,64 @@ export async function constructHelmCustomValues(deploy: Deploy, chartType: Chart '=' ); const templateResolvedValues = await renderTemplate(deploy.build, partialCustomValues); - customValues = templateResolvedValues; + customValues = chartValueEntries(templateResolvedValues); if (deploy.dockerImage) { const version = constructImageVersion(deploy.dockerImage); - customValues.push(`${resourceType}.appImage=${deploy.dockerImage}`, `version=${version}`); + customValues.push( + ...generatedValueEntries([`${resourceType}.appImage=${deploy.dockerImage}`, `version=${version}`]) + ); } if (deploy.initDockerImage) { - customValues.push(`${resourceType}.initImage=${deploy.initDockerImage}`); - customValues.push(...serializeHelmEnvMap(initEnvVars, `${resourceType}.initEnv`)); + customValues.push(...generatedValueEntries([`${resourceType}.initImage=${deploy.initDockerImage}`])); + customValues.push(...generatedValueEntries(serializeHelmEnvMap(initEnvVars, `${resourceType}.initEnv`))); } else { - customValues.push(`${resourceType}.disableInit=true`); + customValues.push(...generatedValueEntries([`${resourceType}.disableInit=true`])); } - customValues.push(...serializeHelmEnvMap(appEnvVars, `${resourceType}.env`, { quoteStringValues: true })); + customValues.push( + ...generatedValueEntries(serializeHelmEnvMap(appEnvVars, `${resourceType}.env`, { quoteStringValues: true })) + ); const isDisableIngressHost: boolean | undefined = helm?.disableIngressHost; const grpc: boolean | undefined = helm?.grpc; const ingressValues = await constructHttpIngressValues(deploy); if (grpc) { - customValues.push(...(await constructGrpcMappings(deploy))); + customValues.push(...generatedValueEntries(await constructGrpcMappings(deploy))); if (isDisableIngressHost === false) { - customValues.push(...ingressValues, ...addNativeHelmCustomValues()); + customValues.push(...generatedValueEntries([...ingressValues, ...addNativeHelmCustomValues()])); } } else if (!isDisableIngressHost && resourceType === 'deployment') { - customValues.push(...ingressValues, ...addNativeHelmCustomValues()); + customValues.push(...generatedValueEntries([...ingressValues, ...addNativeHelmCustomValues()])); } customValues.push( - `env=lifecycle-${deployable.buildUUID}`, - `${resourceType}.enableServiceLinks=disabled`, - `lc__uuid=${deployable.buildUUID}` + ...generatedValueEntries([ + `env=lifecycle-${deployable.buildUUID}`, + `${resourceType}.enableServiceLinks=disabled`, + `lc__uuid=${deployable.buildUUID}`, + ]) ); if (build?.isStatic) { customValues.push( - `${resourceType}.customNodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].key=eks.amazonaws.com/capacityType`, - `${resourceType}.customNodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].operator=In`, - `${resourceType}.customNodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].values[0]=ON_DEMAND`, - ...generateTolerationsCustomValues(`${resourceType}.tolerations`, staticEnvTolerations) + ...generatedValueEntries([ + `${resourceType}.customNodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].key=eks.amazonaws.com/capacityType`, + `${resourceType}.customNodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].operator=In`, + `${resourceType}.customNodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].values[0]=ON_DEMAND`, + ...generateTolerationsCustomValues(`${resourceType}.tolerations`, staticEnvTolerations), + ]) ); } } else if (chartType === ChartType.PUBLIC) { const templateResolvedValues = await renderTemplate(deploy.build, helm?.chart?.values || []); - customValues = mergeKeyValueArrays(configs[chartName]?.chart?.values || [], templateResolvedValues, '='); + customValues = chartValueEntries( + mergeKeyValueArrays(configs[chartName]?.chart?.values || [], templateResolvedValues, '=') + ); - const customLabels = []; + const customLabels: string[] = []; if (configs[chartName]?.label) { customLabels.push( `${configs[chartName].label}.name=${deployable.buildUUID}`, @@ -729,29 +817,37 @@ export async function constructHelmCustomValues(deploy: Deploy, chartType: Chart } customValues.push( - `fullnameOverride=${deploy.uuid}`, - `commonLabels.name=${deployable.buildUUID}`, - `commonLabels.lc__uuid=${deployable.buildUUID}`, - ...customLabels + ...generatedValueEntries([ + `fullnameOverride=${deploy.uuid}`, + `commonLabels.name=${deployable.buildUUID}`, + `commonLabels.lc__uuid=${deployable.buildUUID}`, + ...customLabels, + ]) ); if (build?.isStatic) { const { tolerations, nodeSelector } = configs[chartName] || {}; if (tolerations) { - customValues = customValues.concat(generateTolerationsCustomValues(tolerations, staticEnvTolerations)); + customValues = customValues.concat( + generatedValueEntries(generateTolerationsCustomValues(tolerations, staticEnvTolerations)) + ); } if (nodeSelector) { - customValues = customValues.concat(generateNodeSelector(nodeSelector, 'lifecycle-static-env')); + customValues = customValues.concat( + generatedValueEntries([generateNodeSelector(nodeSelector, 'lifecycle-static-env')]) + ); } } } else if (chartType === ChartType.LOCAL) { const templateResolvedValues = await renderTemplate(deploy.build, helm?.chart?.values || []); - customValues = templateResolvedValues; + customValues = chartValueEntries(templateResolvedValues); customValues.push( - `fullnameOverride=${deploy.uuid}`, - `commonLabels.name=${deployable.buildUUID}`, - `commonLabels.lc__uuid=${deployable.buildUUID}` + ...generatedValueEntries([ + `fullnameOverride=${deploy.uuid}`, + `commonLabels.name=${deployable.buildUUID}`, + `commonLabels.lc__uuid=${deployable.buildUUID}`, + ]) ); // Handle environment variables for LOCAL charts with envMapping @@ -766,7 +862,7 @@ export async function constructHelmCustomValues(deploy: Deploy, chartType: Chart helm.envMapping.app.format, helm.envMapping.app.path ); - customValues.push(...appEnvCustomValues); + customValues.push(...generatedValueEntries(appEnvCustomValues)); } // Process init environment variables @@ -776,11 +872,40 @@ export async function constructHelmCustomValues(deploy: Deploy, chartType: Chart helm.envMapping.init.format, helm.envMapping.init.path ); - customValues.push(...initEnvCustomValues); + customValues.push(...generatedValueEntries(initEnvCustomValues)); } } } + const finalCustomValues = resolveHelmCustomValueEntryPrecedence(customValues); + const secretValueRefs = finalCustomValues.reduce( + (result, entry) => { + if (!entry.parseSecretRefs) { + result.plainValues.push(entry.value); + return result; + } + + const entrySecretValueRefs = splitHelmSecretValueRefs([entry.value], serviceName); + + result.plainValues.push(...entrySecretValueRefs.plainValues); + result.secretRefs.push(...entrySecretValueRefs.secretRefs); + result.secretSetFiles.push(...entrySecretValueRefs.secretSetFiles); + + return result; + }, + { plainValues: [], secretRefs: [], secretSetFiles: [] } as ReturnType + ); + + return { + customValues: secretValueRefs.plainValues, + helmSecretRefs: secretValueRefs.secretRefs, + secretSetFiles: secretValueRefs.secretSetFiles, + }; +} + +export async function constructHelmCustomValues(deploy: Deploy, chartType: ChartType): Promise { + const { customValues } = await constructHelmCustomValueConfiguration(deploy, chartType); + return customValues; } @@ -826,6 +951,14 @@ export function escapeHelmValue(value: string): string { return value.replace(/\//g, '\\/').replace(/,/g, '\\,'); } +function includesUnsafeSecretHelmArg(args?: string): boolean { + if (!args) { + return false; + } + + return /(^|\s)--(?:debug|dry-run)(?:[=\s]|$)/.test(args); +} + export async function validateHelmConfiguration(deploy: Deploy): Promise { const errors: string[] = []; const helm = await mergeHelmConfigWithGlobal(deploy); @@ -858,6 +991,18 @@ export async function validateHelmConfiguration(deploy: Deploy): Promise 0 && + (includesUnsafeSecretHelmArg(helm.args) || includesUnsafeSecretHelmArg(helm.nativeHelm?.defaultArgs)) + ) { + errors.push('Helm args --debug and --dry-run cannot be used with secret-backed Helm custom values'); + } + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + return errors; } diff --git a/src/server/lib/secretRefs.ts b/src/server/lib/secretRefs.ts index 3f6e51a4..edb34cf0 100644 --- a/src/server/lib/secretRefs.ts +++ b/src/server/lib/secretRefs.ts @@ -31,7 +31,48 @@ export interface ValidationResult { error?: string; } -const SECRET_REF_REGEX = /^\{\{(aws|gcp|barbican|vault|onepassword):([^:}]+)(?::([^}]+))?\}\}$/; +const SECRET_REF_PROVIDERS = 'aws|gcp|barbican|vault|onepassword'; +const SECRET_REF_REGEX = new RegExp(`^\\{\\{(${SECRET_REF_PROVIDERS}):([^:}]+)(?::([^}]+))?\\}\\}$`); +const SECRET_REF_TEMPLATE_REGEX = new RegExp(`\\{\\{(${SECRET_REF_PROVIDERS}):([^}]+)\\}\\}`, 'g'); + +export interface SecretRefTemplatePreservation { + template: string; + restore(rendered: string): string; +} + +export function preserveSecretRefsInTemplate(template: string): SecretRefTemplatePreservation { + const secretPlaceholders: Map = new Map(); + let placeholderIndex = 0; + + const preservedTemplate = template.replace(SECRET_REF_TEMPLATE_REGEX, (match) => { + const placeholder = `__LFC_SECRET_REF_PLACEHOLDER_${placeholderIndex}__`; + secretPlaceholders.set(placeholder, match); + placeholderIndex++; + return placeholder; + }); + + return { + template: preservedTemplate, + restore(rendered: string): string { + let restored = rendered; + + for (const [placeholder, original] of secretPlaceholders.entries()) { + restored = restored.replace(placeholder, original); + } + + return restored; + }, + }; +} + +export function containsSecretRefTemplate(value: string): boolean { + if (!value || typeof value !== 'string') { + return false; + } + + SECRET_REF_TEMPLATE_REGEX.lastIndex = 0; + return SECRET_REF_TEMPLATE_REGEX.test(value); +} export function isSecretRef(value: string): boolean { if (!value || typeof value !== 'string') { @@ -65,7 +106,7 @@ export function parseSecretRef(value: string): SecretRef | null { export function validateSecretRef( ref: SecretRef, - secretProviders: SecretProvidersConfig | undefined, + secretProviders: SecretProvidersConfig | undefined ): ValidationResult { if (!secretProviders) { return { valid: false, error: `Secret provider '${ref.provider}' not configured` }; diff --git a/src/server/services/__tests__/secretProcessor.test.ts b/src/server/services/__tests__/secretProcessor.test.ts index 22177d5d..f3a2eaca 100644 --- a/src/server/services/__tests__/secretProcessor.test.ts +++ b/src/server/services/__tests__/secretProcessor.test.ts @@ -254,6 +254,40 @@ describe('SecretProcessor', () => { }); describe('processEnvSecrets', () => { + it('processes explicit env, init, and Helm refs with one sync token', async () => { + const result = await processor.processSecretRefs({ + secretRefs: [ + { envKey: 'APP_TOKEN', provider: 'aws', path: 'myapp/app', key: 'APP_TOKEN' }, + { envKey: 'INIT_TOKEN', provider: 'aws', path: 'myapp/init', key: 'INIT_TOKEN' }, + { envKey: 'helm.auth.password.abc123', provider: 'aws', path: 'myapp/db', key: 'POSTGRES_PASSWORD' }, + ], + serviceName: 'api-server', + namespace: 'lfc-abc123', + buildUuid: 'abc123', + syncToken: 'sync-123', + strict: true, + }); + + expect(result.expectedKeysPerSecret).toEqual({ + 'api-server-aws-secrets': ['APP_TOKEN', 'INIT_TOKEN', 'helm.auth.password.abc123'], + }); + expect(result.syncTokensPerSecret).toEqual({ + 'api-server-aws-secrets': 'sync-123', + }); + expect(result.warnings).toEqual([]); + }); + + it('throws for invalid refs in strict mode', async () => { + await expect( + processor.processSecretRefs({ + secretRefs: [{ envKey: 'HELM_SECRET', provider: 'gcp', path: 'path', key: 'key' }], + serviceName: 'api-server', + namespace: 'lfc-abc123', + strict: true, + }) + ).rejects.toThrow("Secret provider 'gcp' is disabled"); + }); + it('extracts and validates secret references', async () => { const env = { DB_PASSWORD: '{{aws:myapp/db:password}}', diff --git a/src/server/services/secretProcessor.ts b/src/server/services/secretProcessor.ts index f3e141b1..3187d15c 100644 --- a/src/server/services/secretProcessor.ts +++ b/src/server/services/secretProcessor.ts @@ -37,6 +37,15 @@ export interface ProcessEnvSecretsOptions { syncToken?: string; } +export interface ProcessSecretRefsOptions { + secretRefs: SecretRefWithEnvKey[]; + serviceName: string; + namespace: string; + buildUuid?: string; + syncToken?: string; + strict?: boolean; +} + export interface ProcessEnvSecretsResult { secretRefs: SecretRefWithEnvKey[]; expectedKeysPerSecret: Record; @@ -125,26 +134,48 @@ export class SecretProcessor { return new Promise((resolve) => setTimeout(resolve, ms)); } - async processEnvSecrets(options: ProcessEnvSecretsOptions): Promise { - const { env, serviceName, namespace, buildUuid } = options; + async processSecretRefs(options: ProcessSecretRefsOptions): Promise { + const { secretRefs, serviceName, namespace, buildUuid } = options; const syncToken = options.syncToken ?? uuid(); + const strict = options.strict ?? false; const warnings: string[] = []; const validRefs: SecretRefWithEnvKey[] = []; + const refsBySecretKey = new Map(); + + for (const ref of secretRefs) { + const existingRef = refsBySecretKey.get(ref.envKey); + if ( + existingRef && + (existingRef.provider !== ref.provider || existingRef.path !== ref.path || existingRef.key !== ref.key) + ) { + const warning = `Secret reference ${ref.envKey} has conflicting remote refs`; + if (strict) { + throw new Error(warning); + } + warnings.push(warning); + getLogger().warn(warning); + continue; + } - const allRefs = parseSecretRefsFromEnv(env); + if (existingRef) { + continue; + } - for (const ref of allRefs) { const validation = validateSecretRef(ref, this.secretProviders); if (!validation.valid) { const warning = `Secret reference ${ref.envKey}={{${ref.provider}:${ref.path}:${ref.key || ''}}} skipped: ${ validation.error }`; + if (strict) { + throw new Error(warning); + } warnings.push(warning); getLogger().warn(warning); continue; } + refsBySecretKey.set(ref.envKey, ref); validRefs.push(ref); } @@ -177,10 +208,23 @@ export class SecretProcessor { } catch (error) { const errorMsg = (error as any)?.message || (error as any)?.stderr || String(error); const warning = `Failed to apply ExternalSecret for ${serviceName}: ${errorMsg}`; + if (strict) { + throw new Error(warning); + } warnings.push(warning); } } return { secretRefs: validRefs, expectedKeysPerSecret, syncTokensPerSecret, warnings }; } + + async processEnvSecrets(options: ProcessEnvSecretsOptions): Promise { + return this.processSecretRefs({ + secretRefs: parseSecretRefsFromEnv(options.env), + serviceName: options.serviceName, + namespace: options.namespace, + buildUuid: options.buildUuid, + syncToken: options.syncToken, + }); + } }