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
272 changes: 256 additions & 16 deletions public/utils/0-banner.js

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions src/server/db/migrations/009_add_secret_providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* 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 { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
await knex.raw(`
INSERT INTO global_config (key, config, "createdAt", "updatedAt", description)
VALUES (
'secretProviders',
'{"aws":{"enabled":true,"clusterSecretStore":"aws-secretsmanager","refreshInterval":"1h","allowedPrefixes":[]},"gcp":{"enabled":true,"clusterSecretStore":"gcp-secretmanager","refreshInterval":"1h","allowedPrefixes":[]}}',
now(),
now(),
'Cloud secret providers configuration for External Secrets Operator integration'
)
ON CONFLICT (key) DO NOTHING;
`);
}

export async function down(knex: Knex): Promise<void> {
await knex.raw(`DELETE FROM global_config WHERE key = 'secretProviders';`);
}
152 changes: 152 additions & 0 deletions src/server/lib/__tests__/envVariables.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* 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 * as mustache from 'mustache';

describe('envVariables - cloud secret pattern preservation', () => {
const preserveAndRestoreSecretPatterns = (template: string, data: Record<string, any>): string => {
const secretPatternRegex = /\{\{(aws|gcp):([^}]+)\}\}/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;
});

template = template.replace(/{{{?([^{}]*?)}}}?/g, '{{{$1}}}');

let rendered = mustache.render(template, data);

for (const [placeholder, original] of secretPlaceholders.entries()) {
rendered = rendered.replace(placeholder, original);
}

return rendered;
};

describe('preserves cloud secret patterns', () => {
it('preserves AWS secret pattern with key', () => {
const template = '{{aws:myapp/db:password}}';
const data = {};

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{{aws:myapp/db:password}}');
});

it('preserves AWS secret pattern without key', () => {
const template = '{{aws:myapp/api-key}}';
const data = {};

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{{aws:myapp/api-key}}');
});

it('preserves GCP secret pattern with key', () => {
const template = '{{gcp:my-project/secret:key}}';
const data = {};

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{{gcp:my-project/secret:key}}');
});

it('preserves GCP secret pattern without key', () => {
const template = '{{gcp:my-project/api-key}}';
const data = {};

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{{gcp:my-project/api-key}}');
});

it('preserves multiple secret patterns', () => {
const template = '{{aws:path1:key1}} and {{gcp:path2:key2}}';
const data = {};

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{{aws:path1:key1}} and {{gcp:path2:key2}}');
});

it('preserves secret patterns while rendering other variables', () => {
const template = '{{aws:myapp/db:password}} and {{service_url}}';
const data = { service_url: 'https://example.com' };

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{{aws:myapp/db:password}} and https://example.com');
});

it('preserves secret patterns in JSON-like structures', () => {
const template = '{"DB_PASSWORD":"{{aws:myapp/db:password}}","API_URL":"{{api_url}}"}';
const data = { api_url: 'https://api.example.com' };

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{"DB_PASSWORD":"{{aws:myapp/db:password}}","API_URL":"https://api.example.com"}');
});

it('does not affect regular mustache variables', () => {
const template = '{{service_publicUrl}} and {{buildUUID}}';
const data = { service_publicUrl: 'https://svc.example.com', buildUUID: 'uuid-123' };

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('https://svc.example.com and uuid-123');
});

it('handles nested path with multiple colons in secret pattern', () => {
const template = '{{aws:myorg/app/nested/secret:database.password}}';
const data = {};

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{{aws:myorg/app/nested/secret:database.password}}');
});

it('preserves secret patterns with special characters in path', () => {
const template = '{{aws:my-app_v2/db-creds:pass_word123}}';
const data = {};

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{{aws:my-app_v2/db-creds:pass_word123}}');
});

it('handles empty data object with only secret patterns', () => {
const template = '{{aws:secret1:key1}}';
const data = {};

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('{{aws:secret1:key1}}');
});

it('handles complex template with mixed patterns', () => {
const template = 'DB={{aws:db:pass}} HOST={{db_host}} GCP={{gcp:api:token}} PORT={{port}}';
const data = { db_host: 'localhost', port: '5432' };

const result = preserveAndRestoreSecretPatterns(template, data);

expect(result).toBe('DB={{aws:db:pass}} HOST=localhost GCP={{gcp:api:token}} PORT=5432');
});
});
});
44 changes: 44 additions & 0 deletions src/server/lib/__tests__/kubernetes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,47 @@ describe('Kubernetes Node Placement', () => {
});
});
});

describe('generateDeployManifest labels for envLens', () => {
it('should include app.kubernetes.io/instance label for log streaming compatibility', () => {
const build: any = {
uuid: 'test-build-uuid',
isStatic: false,
capacityType: 'ON_DEMAND',
enableFullYaml: true,
};

const deploy: any = {
uuid: 'test-deploy-uuid',
active: true,
replicaCount: 1,
dockerImage: 'test-image:latest',
env: {},
deployable: {
name: 'my-service',
port: '8080',
capacityType: 'ON_DEMAND',
memoryRequest: '512Mi',
memoryLimit: '1Gi',
cpuRequest: '250m',
cpuLimit: '500m',
},
};

const manifest = k8s.generateDeployManifest({
deploy,
build,
namespace: 'test-namespace',
serviceAccountName: 'default',
});

// generateDeployManifest returns multiple YAML documents (deployment + services)
const docs: any[] = yaml.loadAll(manifest);
const deployment = docs.find((d) => d?.kind === 'Deployment');

// The label should be serviceName-buildUUID to match Helm convention
// Log streaming uses: app.kubernetes.io/instance=${name}-${uuid}
expect(deployment.metadata.labels['app.kubernetes.io/instance']).toBe('my-service-test-build-uuid');
expect(deployment.spec.template.metadata.labels['app.kubernetes.io/instance']).toBe('my-service-test-build-uuid');
});
});
117 changes: 117 additions & 0 deletions src/server/lib/__tests__/secretEnvBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* 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 { buildPodEnvWithSecrets, PodEnvEntry } from '../secretEnvBuilder';
import { SecretRefWithEnvKey } from '../secretRefs';

describe('secretEnvBuilder', () => {
describe('buildPodEnvWithSecrets', () => {
it('returns regular env vars as direct values', () => {
const env = {
APP_ENV: 'production',
SERVICE_URL: 'https://example.com',
};

const result = buildPodEnvWithSecrets(env, [], 'service');

expect(result).toEqual([
{ name: 'APP_ENV', value: 'production' },
{ name: 'SERVICE_URL', value: 'https://example.com' },
]);
});

it('returns secret refs as secretKeyRef', () => {
const env = {
DB_PASSWORD: '{{aws:myapp/db:password}}',
};
const secretRefs: SecretRefWithEnvKey[] = [
{ envKey: 'DB_PASSWORD', provider: 'aws', path: 'myapp/db', key: 'password' },
];

const result = buildPodEnvWithSecrets(env, secretRefs, 'api-server');

expect(result).toEqual([
{
name: 'DB_PASSWORD',
valueFrom: {
secretKeyRef: {
name: 'api-server-aws-secrets',
key: 'DB_PASSWORD',
},
},
},
]);
});

it('handles mixed regular and secret env vars', () => {
const env = {
APP_ENV: 'production',
DB_PASSWORD: '{{aws:myapp/db:password}}',
API_URL: 'https://api.example.com',
API_KEY: '{{aws:myapp/api-key}}',
};
const secretRefs: SecretRefWithEnvKey[] = [
{ envKey: 'DB_PASSWORD', provider: 'aws', path: 'myapp/db', key: 'password' },
{ envKey: 'API_KEY', provider: 'aws', path: 'myapp/api-key', key: undefined },
];

const result = buildPodEnvWithSecrets(env, secretRefs, 'api-server');

expect(result).toHaveLength(4);

const appEnv = result.find((e) => e.name === 'APP_ENV') as PodEnvEntry;
expect(appEnv.value).toBe('production');

const dbPassword = result.find((e) => e.name === 'DB_PASSWORD') as PodEnvEntry;
expect(dbPassword.valueFrom?.secretKeyRef?.name).toBe('api-server-aws-secrets');

const apiUrl = result.find((e) => e.name === 'API_URL') as PodEnvEntry;
expect(apiUrl.value).toBe('https://api.example.com');

const apiKey = result.find((e) => e.name === 'API_KEY') as PodEnvEntry;
expect(apiKey.valueFrom?.secretKeyRef?.name).toBe('api-server-aws-secrets');
});

it('handles multiple providers', () => {
const env = {
AWS_SECRET: '{{aws:path:key}}',
GCP_SECRET: '{{gcp:path:key}}',
};
const secretRefs: SecretRefWithEnvKey[] = [
{ envKey: 'AWS_SECRET', provider: 'aws', path: 'path', key: 'key' },
{ envKey: 'GCP_SECRET', provider: 'gcp', path: 'path', key: 'key' },
];

const result = buildPodEnvWithSecrets(env, secretRefs, 'service');

const awsEntry = result.find((e) => e.name === 'AWS_SECRET') as PodEnvEntry;
expect(awsEntry.valueFrom?.secretKeyRef?.name).toBe('service-aws-secrets');

const gcpEntry = result.find((e) => e.name === 'GCP_SECRET') as PodEnvEntry;
expect(gcpEntry.valueFrom?.secretKeyRef?.name).toBe('service-gcp-secrets');
});

it('returns empty array for empty input', () => {
const result = buildPodEnvWithSecrets({}, [], 'service');
expect(result).toEqual([]);
});

it('handles null/undefined env', () => {
expect(buildPodEnvWithSecrets(null as any, [], 'service')).toEqual([]);
expect(buildPodEnvWithSecrets(undefined as any, [], 'service')).toEqual([]);
});
});
});
Loading