Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/server/lib/__tests__/secretRefs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 4 additions & 17 deletions src/server/lib/envVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, string> = 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}}}');
Expand Down Expand Up @@ -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(
Expand Down
55 changes: 55 additions & 0 deletions src/server/lib/helm/__tests__/helm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
});
});
110 changes: 110 additions & 0 deletions src/server/lib/helm/__tests__/secretValueRefs.test.ts
Original file line number Diff line number Diff line change
@@ -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,
},
]);
});
});
3 changes: 3 additions & 0 deletions src/server/lib/helm/helm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '__');
Expand All @@ -61,6 +62,7 @@ export async function helmPublicDeployStep(deploy: Deploy): Promise<Record<strin

const templateResolvedValues = await renderTemplate(deploy.build, chart.values);
let customValues = mergeKeyValueArrays(configs[chart?.name]?.chart?.values, templateResolvedValues, '=');
assertNoHelmSecretValueRefs(customValues, 'Codefresh Helm deploy path');
const chartName = helm?.chart?.name;
const customLabels = [];
if (configs[chartName]?.label) {
Expand Down Expand Up @@ -134,6 +136,7 @@ export async function helmOrgAppDeployStep(deploy: Deploy): Promise<Record<strin

const partialCustomValues = mergeKeyValueArrays(configs[orgChartName].chart?.values, chart?.values, '=');
const customValues = mergeKeyValueArrays(partialCustomValues, templateResolvedValues, '=');
assertNoHelmSecretValueRefs(customValues, 'Codefresh Helm deploy path');
if (build?.isStatic) {
// add node affinity for static env deploys, so they are scheduled on static env nodes
// Note: this assumes we always have a eks.amazonaws.com/capacityType IN 'ON_DEMAND' affinity in the custom values file for each service
Expand Down
Loading
Loading