From 3aef555ed28eb1eb6a83e9c44ca264f38520e0ee Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Mon, 19 Jan 2026 13:59:00 -0800 Subject: [PATCH 1/5] feat: aws, gcp support for secrets using ESO --- .../db/migrations/009_add_secret_providers.ts | 35 +++ src/server/lib/__tests__/envVariables.test.ts | 152 ++++++++++ .../lib/__tests__/secretEnvBuilder.test.ts | 117 ++++++++ src/server/lib/__tests__/secretRefs.test.ts | 227 +++++++++++++++ src/server/lib/envVariables.ts | 19 +- src/server/lib/kubernetes.ts | 150 ++++++++-- .../__tests__/externalSecret.test.ts | 171 +++++++++++ src/server/lib/kubernetes/externalSecret.ts | 162 +++++++++++ src/server/lib/nativeBuild/engines.ts | 23 +- src/server/lib/secretEnvBuilder.ts | 73 +++++ src/server/lib/secretRefs.ts | 112 ++++++++ .../__tests__/secretProcessor.test.ts | 268 ++++++++++++++++++ src/server/services/deploy.ts | 45 +++ src/server/services/secretProcessor.ts | 154 ++++++++++ src/server/services/types/globalConfig.ts | 13 + 15 files changed, 1688 insertions(+), 33 deletions(-) create mode 100644 src/server/db/migrations/009_add_secret_providers.ts create mode 100644 src/server/lib/__tests__/envVariables.test.ts create mode 100644 src/server/lib/__tests__/secretEnvBuilder.test.ts create mode 100644 src/server/lib/__tests__/secretRefs.test.ts create mode 100644 src/server/lib/kubernetes/__tests__/externalSecret.test.ts create mode 100644 src/server/lib/kubernetes/externalSecret.ts create mode 100644 src/server/lib/secretEnvBuilder.ts create mode 100644 src/server/lib/secretRefs.ts create mode 100644 src/server/services/__tests__/secretProcessor.test.ts create mode 100644 src/server/services/secretProcessor.ts diff --git a/src/server/db/migrations/009_add_secret_providers.ts b/src/server/db/migrations/009_add_secret_providers.ts new file mode 100644 index 0000000..a9cbff7 --- /dev/null +++ b/src/server/db/migrations/009_add_secret_providers.ts @@ -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 { + 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 { + await knex.raw(`DELETE FROM global_config WHERE key = 'secretProviders';`); +} diff --git a/src/server/lib/__tests__/envVariables.test.ts b/src/server/lib/__tests__/envVariables.test.ts new file mode 100644 index 0000000..467ac6f --- /dev/null +++ b/src/server/lib/__tests__/envVariables.test.ts @@ -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 => { + const secretPatternRegex = /\{\{(aws|gcp):([^}]+)\}\}/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; + }); + + 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'); + }); + }); +}); diff --git a/src/server/lib/__tests__/secretEnvBuilder.test.ts b/src/server/lib/__tests__/secretEnvBuilder.test.ts new file mode 100644 index 0000000..803c11a --- /dev/null +++ b/src/server/lib/__tests__/secretEnvBuilder.test.ts @@ -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([]); + }); + }); +}); diff --git a/src/server/lib/__tests__/secretRefs.test.ts b/src/server/lib/__tests__/secretRefs.test.ts new file mode 100644 index 0000000..03c9b99 --- /dev/null +++ b/src/server/lib/__tests__/secretRefs.test.ts @@ -0,0 +1,227 @@ +/** + * 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 { parseSecretRef, parseSecretRefsFromEnv, isSecretRef, validateSecretRef, SecretRef } from '../secretRefs'; + +describe('secretRefs', () => { + describe('isSecretRef', () => { + it('returns true for valid AWS secret reference', () => { + expect(isSecretRef('{{aws:myapp/db:password}}')).toBe(true); + }); + + it('returns true for valid GCP secret reference', () => { + expect(isSecretRef('{{gcp:my-project/secret:key}}')).toBe(true); + }); + + it('returns true for secret without key (plaintext)', () => { + expect(isSecretRef('{{aws:myapp/api-key}}')).toBe(true); + }); + + it('returns false for regular template variable', () => { + expect(isSecretRef('{{service_publicUrl}}')).toBe(false); + }); + + it('returns false for triple-brace template', () => { + expect(isSecretRef('{{{buildUUID}}}')).toBe(false); + }); + + it('returns false for static value', () => { + expect(isSecretRef('static-value')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isSecretRef('')).toBe(false); + }); + }); + + describe('parseSecretRef', () => { + it('parses AWS secret with key', () => { + const result = parseSecretRef('{{aws:myapp/rds-credentials:password}}'); + expect(result).toEqual({ + provider: 'aws', + path: 'myapp/rds-credentials', + key: 'password', + }); + }); + + it('parses AWS secret without key (plaintext)', () => { + const result = parseSecretRef('{{aws:myapp/api-key}}'); + expect(result).toEqual({ + provider: 'aws', + path: 'myapp/api-key', + key: undefined, + }); + }); + + it('parses GCP secret with key', () => { + const result = parseSecretRef('{{gcp:my-project/db-creds:password}}'); + expect(result).toEqual({ + provider: 'gcp', + path: 'my-project/db-creds', + key: 'password', + }); + }); + + it('parses nested key with dot notation', () => { + const result = parseSecretRef('{{aws:myapp/config:database.password}}'); + expect(result).toEqual({ + provider: 'aws', + path: 'myapp/config', + key: 'database.password', + }); + }); + + it('returns null for non-secret reference', () => { + expect(parseSecretRef('{{service_publicUrl}}')).toBeNull(); + expect(parseSecretRef('static-value')).toBeNull(); + }); + + it('returns null for invalid syntax with trailing colon', () => { + expect(parseSecretRef('{{aws:path:}}')).toBeNull(); + }); + + it('returns null for whitespace in pattern', () => { + expect(parseSecretRef('{{ aws:path:key }}')).toBeNull(); + }); + }); + + describe('validateSecretRef', () => { + const enabledConfig = { + aws: { + enabled: true, + clusterSecretStore: 'aws-sm', + refreshInterval: '1h', + }, + }; + + const disabledConfig = { + aws: { + enabled: false, + clusterSecretStore: 'aws-sm', + refreshInterval: '1h', + }, + }; + + const configWithPrefixes = { + aws: { + enabled: true, + clusterSecretStore: 'aws-sm', + refreshInterval: '1h', + allowedPrefixes: ['myorg/', 'shared/'], + }, + }; + + it('returns valid for enabled provider', () => { + const ref: SecretRef = { provider: 'aws', path: 'myapp/secret', key: 'key' }; + const result = validateSecretRef(ref, enabledConfig); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('returns invalid for unconfigured provider', () => { + const ref: SecretRef = { provider: 'gcp', path: 'path', key: 'key' }; + const result = validateSecretRef(ref, enabledConfig); + expect(result.valid).toBe(false); + expect(result.error).toContain('not configured'); + }); + + it('returns invalid for disabled provider', () => { + const ref: SecretRef = { provider: 'aws', path: 'path', key: 'key' }; + const result = validateSecretRef(ref, disabledConfig); + expect(result.valid).toBe(false); + expect(result.error).toContain('disabled'); + }); + + it('returns valid for path matching allowed prefix', () => { + const ref: SecretRef = { provider: 'aws', path: 'myorg/app/secret', key: 'key' }; + const result = validateSecretRef(ref, configWithPrefixes); + expect(result.valid).toBe(true); + }); + + it('returns invalid for path not matching allowed prefixes', () => { + const ref: SecretRef = { provider: 'aws', path: 'other/secret', key: 'key' }; + const result = validateSecretRef(ref, configWithPrefixes); + expect(result.valid).toBe(false); + expect(result.error).toContain('not in allowed prefixes'); + }); + + it('allows any path when allowedPrefixes is empty', () => { + const ref: SecretRef = { provider: 'aws', path: 'any/path', key: 'key' }; + const result = validateSecretRef(ref, enabledConfig); + expect(result.valid).toBe(true); + }); + }); + + describe('parseSecretRefsFromEnv', () => { + it('extracts secret references from env object', () => { + const env = { + DB_PASSWORD: '{{aws:myapp/db:password}}', + DB_HOST: 'localhost', + API_KEY: '{{aws:myapp/api-key}}', + SERVICE_URL: '{{backend_publicUrl}}', + }; + + const result = parseSecretRefsFromEnv(env); + + expect(result).toHaveLength(2); + expect(result).toContainEqual({ + envKey: 'DB_PASSWORD', + provider: 'aws', + path: 'myapp/db', + key: 'password', + }); + expect(result).toContainEqual({ + envKey: 'API_KEY', + provider: 'aws', + path: 'myapp/api-key', + key: undefined, + }); + }); + + it('returns empty array for env without secrets', () => { + const env = { + APP_ENV: 'production', + SERVICE_URL: '{{backend_publicUrl}}', + }; + + const result = parseSecretRefsFromEnv(env); + expect(result).toEqual([]); + }); + + it('handles empty env object', () => { + const result = parseSecretRefsFromEnv({}); + expect(result).toEqual([]); + }); + + it('handles null/undefined env', () => { + expect(parseSecretRefsFromEnv(null as any)).toEqual([]); + expect(parseSecretRefsFromEnv(undefined as any)).toEqual([]); + }); + + it('extracts from multiple providers', () => { + const env = { + AWS_SECRET: '{{aws:path:key}}', + GCP_SECRET: '{{gcp:path:key}}', + }; + + const result = parseSecretRefsFromEnv(env); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.provider)).toContain('aws'); + expect(result.map((r) => r.provider)).toContain('gcp'); + }); + }); +}); diff --git a/src/server/lib/envVariables.ts b/src/server/lib/envVariables.ts index a10008e..ef81a8a 100644 --- a/src/server/lib/envVariables.ts +++ b/src/server/lib/envVariables.ts @@ -304,6 +304,17 @@ export abstract class EnvironmentVariables { * @returns the rendered template */ async customRender(template, data, useDefaultUUID = true, namespace: string) { + const secretPatternRegex = /\{\{(aws|gcp):([^}]+)\}\}/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; + }); + // Convert any remaining double-curly placeholders into triple-curly ones to render unescaped HTML template = template.replace(/{{{?([^{}]*?)}}}?/g, '{{{$1}}}'); @@ -379,7 +390,13 @@ export abstract class EnvironmentVariables { } } - return mustache.render(template, data); + let rendered = mustache.render(template, data); + + for (const [placeholder, original] of secretPlaceholders.entries()) { + rendered = rendered.replace(placeholder, original); + } + + return rendered; } public abstract resolve( diff --git a/src/server/lib/kubernetes.ts b/src/server/lib/kubernetes.ts index c062c2b..fb417dd 100644 --- a/src/server/lib/kubernetes.ts +++ b/src/server/lib/kubernetes.ts @@ -30,6 +30,8 @@ import fs from 'fs'; import GlobalConfigService from 'server/services/globalConfig'; import { setupServiceAccountWithRBAC } from './kubernetes/rbac'; import { staticEnvTolerations } from './helm/constants'; +import { parseSecretRefsFromEnv, SecretRefWithEnvKey } from './secretRefs'; +import { generateSecretName } from './kubernetes/externalSecret'; interface VOLUME { name: string; @@ -855,21 +857,47 @@ export function generateDeployManifests( * 1. It merges the deploy environment with the comment based environment. * 2. It then filters out any nested values, which aren't supported in Kubernetes. * 3. It then flattens out any nulls + * 4. Handles cloud secret references ({{aws:path:key}} or {{gcp:path:key}}) */ - const env: Array> = _.flatten( - Object.entries( - _.merge({ __NAMESPACE__: 'lifecycle' }, deploy.env || '{}', flattenObject(build.commentRuntimeEnv)) - ).map(([key, value]) => { - // Filter out nested objects which aren't supported - if (_.isObject(value) === false) { - return { - name: key, - value, - }; - } else { - return null; - } - }) + const mergedEnv = _.merge( + { __NAMESPACE__: 'lifecycle' }, + deploy.env || '{}', + flattenObject(build.commentRuntimeEnv) + ); + const secretRefs = parseSecretRefsFromEnv(mergedEnv as Record); + const secretRefMap = new Map(); + for (const ref of secretRefs) { + secretRefMap.set(ref.envKey, ref); + } + const envServiceName = enableFullYaml ? deploy.deployable?.name : deploy.service?.name; + + const env: Array> = _.compact( + _.flatten( + Object.entries(mergedEnv).map(([key, value]) => { + // Filter out nested objects which aren't supported + if (_.isObject(value) === false) { + const secretRef = secretRefMap.get(key); + if (secretRef && envServiceName) { + const secretName = generateSecretName(envServiceName, secretRef.provider); + return { + name: key, + valueFrom: { + secretKeyRef: { + name: secretName, + key: key, + }, + }, + }; + } + return { + name: key, + value, + }; + } else { + return null; + } + }) + ) ); env.push( @@ -997,12 +1025,36 @@ export function generateDeployManifests( ]; if (deploy.initDockerImage) { - const initEnv: Array> = Object.entries( - _.merge({ __NAMESPACE__: 'lifecycle' }, deploy.initEnv || '{}', flattenObject(build.commentInitEnv)) - ).map(([key, value]) => ({ - name: key, - value, - })); + const initEnvMerged = _.merge( + { __NAMESPACE__: 'lifecycle' }, + deploy.initEnv || '{}', + flattenObject(build.commentInitEnv) + ); + const initSecretRefs = parseSecretRefsFromEnv(initEnvMerged as Record); + const initSecretRefMap = new Map(); + for (const ref of initSecretRefs) { + initSecretRefMap.set(ref.envKey, ref); + } + + const initEnv: Array> = Object.entries(initEnvMerged).map(([key, value]) => { + const initSecretRef = initSecretRefMap.get(key); + if (initSecretRef) { + const initSecretName = generateSecretName(envServiceName, initSecretRef.provider); + return { + name: key, + valueFrom: { + secretKeyRef: { + name: initSecretName, + key: key, + }, + }, + }; + } + return { + name: key, + value, + }; + }); initEnv.push( { name: 'POD_IP', @@ -1819,12 +1871,33 @@ function generateSingleDeploymentManifest({ flattenObject(build.commentInitEnv) ); + const initSecretRefs = parseSecretRefsFromEnv(initEnvObj as Record); + const initSecretRefMap = new Map(); + for (const ref of initSecretRefs) { + initSecretRefMap.set(ref.envKey, ref); + } + const initEnvArray: Array> = Object.entries(initEnvObj) .filter(([, value]) => !_.isObject(value)) - .map(([key, value]) => ({ - name: key, - value: String(value), - })); + .map(([key, value]) => { + const secretRef = initSecretRefMap.get(key); + if (secretRef) { + const secretRefName = generateSecretName(serviceName || 'service', secretRef.provider); + return { + name: key, + valueFrom: { + secretKeyRef: { + name: secretRefName, + key: key, + }, + }, + }; + } + return { + name: key, + value: String(value), + }; + }); initEnvArray.push( { @@ -1897,12 +1970,33 @@ function generateSingleDeploymentManifest({ // Handle main container const mainEnvObj = _.merge({ __NAMESPACE__: 'lifecycle' }, envToUse, flattenObject(build.commentRuntimeEnv)); + const mainSecretRefs = parseSecretRefsFromEnv(mainEnvObj as Record); + const mainSecretRefMap = new Map(); + for (const ref of mainSecretRefs) { + mainSecretRefMap.set(ref.envKey, ref); + } + const mainEnvArray: Array> = Object.entries(mainEnvObj) .filter(([, value]) => !_.isObject(value)) - .map(([key, value]) => ({ - name: key, - value: String(value), - })); + .map(([key, value]) => { + const secretRef = mainSecretRefMap.get(key); + if (secretRef) { + const secretRefName = generateSecretName(serviceName || 'service', secretRef.provider); + return { + name: key, + valueFrom: { + secretKeyRef: { + name: secretRefName, + key: key, + }, + }, + }; + } + return { + name: key, + value: String(value), + }; + }); // Add Kubernetes field references for pod metadata mainEnvArray.push( diff --git a/src/server/lib/kubernetes/__tests__/externalSecret.test.ts b/src/server/lib/kubernetes/__tests__/externalSecret.test.ts new file mode 100644 index 0000000..275b55c --- /dev/null +++ b/src/server/lib/kubernetes/__tests__/externalSecret.test.ts @@ -0,0 +1,171 @@ +/** + * 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 { generateExternalSecretManifest, generateSecretName, groupSecretRefsByProvider } from '../externalSecret'; +import { SecretRefWithEnvKey } from 'server/lib/secretRefs'; + +describe('externalSecret', () => { + describe('generateSecretName', () => { + it('generates name with provider suffix', () => { + expect(generateSecretName('api-server', 'aws')).toBe('api-server-aws-secrets'); + }); + + it('generates name for gcp provider', () => { + expect(generateSecretName('worker', 'gcp')).toBe('worker-gcp-secrets'); + }); + + it('truncates long names to 63 characters', () => { + const longName = 'this-is-a-very-long-service-name-that-exceeds-the-limit'; + const result = generateSecretName(longName, 'aws'); + expect(result.length).toBeLessThanOrEqual(63); + expect(result).toMatch(/-aws-secrets$/); + }); + + it('removes trailing hyphen after truncation', () => { + const longName = 'service-name-that-ends-at-truncation-point-exactly-here'; + const result = generateSecretName(longName, 'aws'); + expect(result).not.toMatch(/-$/); + }); + }); + + describe('groupSecretRefsByProvider', () => { + it('groups refs by provider', () => { + const refs: SecretRefWithEnvKey[] = [ + { envKey: 'AWS_VAR1', provider: 'aws', path: 'path1', key: 'key1' }, + { envKey: 'GCP_VAR', provider: 'gcp', path: 'path2', key: 'key2' }, + { envKey: 'AWS_VAR2', provider: 'aws', path: 'path3', key: 'key3' }, + ]; + + const result = groupSecretRefsByProvider(refs); + + expect(Object.keys(result)).toEqual(['aws', 'gcp']); + expect(result.aws).toHaveLength(2); + expect(result.gcp).toHaveLength(1); + }); + + it('returns empty object for empty input', () => { + expect(groupSecretRefsByProvider([])).toEqual({}); + }); + }); + + describe('generateExternalSecretManifest', () => { + const providerConfig = { + enabled: true, + clusterSecretStore: 'aws-secretsmanager', + refreshInterval: '1h', + }; + + it('generates valid ExternalSecret manifest', () => { + const refs: SecretRefWithEnvKey[] = [ + { envKey: 'DB_PASSWORD', provider: 'aws', path: 'myapp/db', key: 'password' }, + { envKey: 'DB_USER', provider: 'aws', path: 'myapp/db', key: 'username' }, + ]; + + const manifest = generateExternalSecretManifest({ + name: 'api-server', + namespace: 'lfc-abc123', + provider: 'aws', + secretRefs: refs, + providerConfig, + }); + + expect(manifest.apiVersion).toBe('external-secrets.io/v1beta1'); + expect(manifest.kind).toBe('ExternalSecret'); + expect(manifest.metadata.name).toBe('api-server-aws-secrets'); + expect(manifest.metadata.namespace).toBe('lfc-abc123'); + expect(manifest.spec.refreshInterval).toBe('1h'); + expect(manifest.spec.secretStoreRef.name).toBe('aws-secretsmanager'); + expect(manifest.spec.secretStoreRef.kind).toBe('ClusterSecretStore'); + expect(manifest.spec.target.name).toBe('api-server-aws-secrets'); + expect(manifest.spec.data).toHaveLength(2); + }); + + it('generates correct data entries for JSON secrets', () => { + const refs: SecretRefWithEnvKey[] = [ + { envKey: 'DB_PASSWORD', provider: 'aws', path: 'myapp/db', key: 'password' }, + ]; + + const manifest = generateExternalSecretManifest({ + name: 'api-server', + namespace: 'ns', + provider: 'aws', + secretRefs: refs, + providerConfig, + }); + + expect(manifest.spec.data[0]).toEqual({ + secretKey: 'DB_PASSWORD', + remoteRef: { + key: 'myapp/db', + property: 'password', + }, + }); + }); + + it('generates correct data entry for plaintext secret (no key)', () => { + const refs: SecretRefWithEnvKey[] = [ + { envKey: 'API_KEY', provider: 'aws', path: 'myapp/api-key', key: undefined }, + ]; + + const manifest = generateExternalSecretManifest({ + name: 'api-server', + namespace: 'ns', + provider: 'aws', + secretRefs: refs, + providerConfig, + }); + + expect(manifest.spec.data[0]).toEqual({ + secretKey: 'API_KEY', + remoteRef: { + key: 'myapp/api-key', + }, + }); + }); + + it('handles nested key with dot notation', () => { + const refs: SecretRefWithEnvKey[] = [ + { envKey: 'REDIS_HOST', provider: 'aws', path: 'config', key: 'redis.host' }, + ]; + + const manifest = generateExternalSecretManifest({ + name: 'api-server', + namespace: 'ns', + provider: 'aws', + secretRefs: refs, + providerConfig, + }); + + expect(manifest.spec.data[0].remoteRef.property).toBe('redis.host'); + }); + + it('includes lifecycle labels', () => { + const refs: SecretRefWithEnvKey[] = [{ envKey: 'SECRET', provider: 'aws', path: 'path', key: 'key' }]; + + const manifest = generateExternalSecretManifest({ + name: 'api-server', + namespace: 'lfc-abc123', + provider: 'aws', + secretRefs: refs, + providerConfig, + buildUuid: 'abc123', + }); + + expect(manifest.metadata.labels['app.kubernetes.io/managed-by']).toBe('lifecycle'); + expect(manifest.metadata.labels['lfc/uuid']).toBe('abc123'); + }); + }); +}); diff --git a/src/server/lib/kubernetes/externalSecret.ts b/src/server/lib/kubernetes/externalSecret.ts new file mode 100644 index 0000000..04a4297 --- /dev/null +++ b/src/server/lib/kubernetes/externalSecret.ts @@ -0,0 +1,162 @@ +/** + * 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 yaml from 'js-yaml'; +import fs from 'fs'; +import { shellPromise } from 'server/lib/shell'; +import { getLogger } from 'server/lib/logger'; +import { SecretRefWithEnvKey } from 'server/lib/secretRefs'; +import { SecretProviderConfig } from 'server/services/types/globalConfig'; + +export interface ExternalSecretManifest { + apiVersion: string; + kind: string; + metadata: { + name: string; + namespace: string; + labels: Record; + }; + spec: { + refreshInterval: string; + secretStoreRef: { + name: string; + kind: string; + }; + target: { + name: string; + }; + data: Array<{ + secretKey: string; + remoteRef: { + key: string; + property?: string; + }; + }>; + }; +} + +export interface GenerateExternalSecretOptions { + name: string; + namespace: string; + provider: string; + secretRefs: SecretRefWithEnvKey[]; + providerConfig: SecretProviderConfig; + buildUuid?: 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 function groupSecretRefsByProvider(refs: SecretRefWithEnvKey[]): Record { + const grouped: Record = {}; + + for (const ref of refs) { + if (!grouped[ref.provider]) { + grouped[ref.provider] = []; + } + grouped[ref.provider].push(ref); + } + + return grouped; +} + +export function generateExternalSecretManifest(options: GenerateExternalSecretOptions): ExternalSecretManifest { + const { name, namespace, provider, secretRefs, providerConfig, buildUuid } = options; + + const secretName = generateSecretName(name, provider); + + const data = secretRefs.map((ref) => { + const entry: { secretKey: string; remoteRef: { key: string; property?: string } } = { + secretKey: ref.envKey, + remoteRef: { + key: ref.path, + }, + }; + + if (ref.key) { + entry.remoteRef.property = ref.key; + } + + return entry; + }); + + const labels: Record = { + 'app.kubernetes.io/managed-by': 'lifecycle', + 'lfc/secret-provider': provider, + }; + + if (buildUuid) { + labels['lfc/uuid'] = buildUuid; + } + + return { + apiVersion: 'external-secrets.io/v1beta1', + kind: 'ExternalSecret', + metadata: { + name: secretName, + namespace, + labels, + }, + spec: { + refreshInterval: providerConfig.refreshInterval, + secretStoreRef: { + name: providerConfig.clusterSecretStore, + kind: 'ClusterSecretStore', + }, + target: { + name: secretName, + }, + data, + }, + }; +} + +const MANIFEST_PATH = '/tmp/lifecycle/manifests/externalsecrets'; + +export async function applyExternalSecret(manifest: ExternalSecretManifest, namespace: string): Promise { + const manifestYaml = yaml.dump(manifest); + const fileName = `${manifest.metadata.name}.yaml`; + const localPath = `${MANIFEST_PATH}/${fileName}`; + + await fs.promises.mkdir(MANIFEST_PATH, { recursive: true }); + await fs.promises.writeFile(localPath, manifestYaml, 'utf8'); + + getLogger().info(`ExternalSecret: applying name=${manifest.metadata.name} namespace=${namespace}`); + + await shellPromise(`kubectl apply -f ${localPath} --namespace ${namespace}`); +} + +export async function deleteExternalSecret(name: string, namespace: string): Promise { + getLogger().info(`ExternalSecret: deleting name=${name} namespace=${namespace}`); + + try { + await shellPromise(`kubectl delete externalsecret ${name} --namespace ${namespace} --ignore-not-found`); + } catch (error) { + getLogger().warn({ error }, `ExternalSecret: delete failed name=${name}`); + } +} diff --git a/src/server/lib/nativeBuild/engines.ts b/src/server/lib/nativeBuild/engines.ts index 5816776..e08a98f 100644 --- a/src/server/lib/nativeBuild/engines.ts +++ b/src/server/lib/nativeBuild/engines.ts @@ -48,6 +48,7 @@ export interface NativeBuildOptions { requests?: Record; limits?: Record; }; + secretRefs?: string[]; } interface BuildEngine { @@ -194,7 +195,8 @@ function createBuildContainer( envVars: Record, resources: any, buildArgs: Record, - ecrDomain: string + ecrDomain: string, + secretRefs?: string[] ): any { const args = engine.createArgs({ contextPath, @@ -223,7 +225,7 @@ function createBuildContainer( containerEnvVars['DOCKER_CONFIG'] = '/kaniko/.docker'; } - return { + const container: any = { name, image: engine.image, command: engine.command, @@ -232,6 +234,17 @@ function createBuildContainer( volumeMounts, resources, }; + + if (secretRefs && secretRefs.length > 0) { + container.envFrom = secretRefs.map((secretName) => ({ + secretRef: { + name: secretName, + optional: false, + }, + })); + } + + return container; } export async function buildWithEngine( @@ -336,7 +349,8 @@ export async function buildWithEngine( envVars, resources, options.envVars, - options.ecrDomain + options.ecrDomain, + options.secretRefs ) ); @@ -353,7 +367,8 @@ export async function buildWithEngine( envVars, resources, options.envVars, - options.ecrDomain + options.ecrDomain, + options.secretRefs ) ); getLogger().debug('Build: including init image'); diff --git a/src/server/lib/secretEnvBuilder.ts b/src/server/lib/secretEnvBuilder.ts new file mode 100644 index 0000000..ba3c2c9 --- /dev/null +++ b/src/server/lib/secretEnvBuilder.ts @@ -0,0 +1,73 @@ +/** + * 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 { SecretRefWithEnvKey } from './secretRefs'; +import { generateSecretName } from './kubernetes/externalSecret'; + +export interface PodEnvEntry { + name: string; + value?: string; + valueFrom?: { + secretKeyRef?: { + name: string; + key: string; + }; + fieldRef?: { + fieldPath: string; + }; + }; +} + +export function buildPodEnvWithSecrets( + env: Record | null | undefined, + secretRefs: SecretRefWithEnvKey[], + serviceName: string +): PodEnvEntry[] { + if (!env) { + return []; + } + + const secretRefMap = new Map(); + for (const ref of secretRefs) { + secretRefMap.set(ref.envKey, ref); + } + + const entries: PodEnvEntry[] = []; + + for (const [name, value] of Object.entries(env)) { + const secretRef = secretRefMap.get(name); + + if (secretRef) { + const secretName = generateSecretName(serviceName, secretRef.provider); + entries.push({ + name, + valueFrom: { + secretKeyRef: { + name: secretName, + key: name, + }, + }, + }); + } else { + entries.push({ + name, + value, + }); + } + } + + return entries; +} diff --git a/src/server/lib/secretRefs.ts b/src/server/lib/secretRefs.ts new file mode 100644 index 0000000..dcd4c46 --- /dev/null +++ b/src/server/lib/secretRefs.ts @@ -0,0 +1,112 @@ +/** + * 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 { SecretProvidersConfig } from 'server/services/types/globalConfig'; + +export interface SecretRef { + provider: string; + path: string; + key?: string; +} + +export interface SecretRefWithEnvKey extends SecretRef { + envKey: string; +} + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +const SECRET_REF_REGEX = /^\{\{(aws|gcp):([^:}]+)(?::([^}]+))?\}\}$/; + +export function isSecretRef(value: string): boolean { + if (!value || typeof value !== 'string') { + return false; + } + return SECRET_REF_REGEX.test(value); +} + +export function parseSecretRef(value: string): SecretRef | null { + if (!value || typeof value !== 'string') { + return null; + } + + const match = value.match(SECRET_REF_REGEX); + if (!match) { + return null; + } + + const [, provider, path, key] = match; + + if (key === '') { + return null; + } + + return { + provider, + path, + key: key || undefined, + }; +} + +export function validateSecretRef( + ref: SecretRef, + secretProviders: SecretProvidersConfig | undefined +): ValidationResult { + if (!secretProviders) { + return { valid: false, error: `Secret provider '${ref.provider}' not configured` }; + } + + const providerConfig = secretProviders[ref.provider]; + + if (!providerConfig) { + return { valid: false, error: `Secret provider '${ref.provider}' not configured` }; + } + + if (!providerConfig.enabled) { + return { valid: false, error: `Secret provider '${ref.provider}' is disabled` }; + } + + if (providerConfig.allowedPrefixes && providerConfig.allowedPrefixes.length > 0) { + const isAllowed = providerConfig.allowedPrefixes.some((prefix) => ref.path.startsWith(prefix)); + if (!isAllowed) { + return { + valid: false, + error: `Secret path '${ref.path}' not in allowed prefixes: ${providerConfig.allowedPrefixes.join(', ')}`, + }; + } + } + + return { valid: true }; +} + +export function parseSecretRefsFromEnv(env: Record | null | undefined): SecretRefWithEnvKey[] { + if (!env) { + return []; + } + + const refs: SecretRefWithEnvKey[] = []; + + for (const [envKey, value] of Object.entries(env)) { + const ref = parseSecretRef(value); + if (ref) { + refs.push({ envKey, ...ref }); + } + } + + return refs; +} diff --git a/src/server/services/__tests__/secretProcessor.test.ts b/src/server/services/__tests__/secretProcessor.test.ts new file mode 100644 index 0000000..58fc2b2 --- /dev/null +++ b/src/server/services/__tests__/secretProcessor.test.ts @@ -0,0 +1,268 @@ +/** + * 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 { SecretProcessor } from '../secretProcessor'; +import { SecretProvidersConfig } from 'server/services/types/globalConfig'; + +jest.mock('server/lib/kubernetes/externalSecret', () => ({ + applyExternalSecret: jest.fn().mockResolvedValue(undefined), + generateExternalSecretManifest: jest.requireActual('server/lib/kubernetes/externalSecret') + .generateExternalSecretManifest, + generateSecretName: jest.requireActual('server/lib/kubernetes/externalSecret').generateSecretName, + groupSecretRefsByProvider: jest.requireActual('server/lib/kubernetes/externalSecret').groupSecretRefsByProvider, +})); + +jest.mock('server/lib/logger', () => ({ + getLogger: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }), +})); + +describe('SecretProcessor', () => { + const secretProviders: SecretProvidersConfig = { + aws: { + enabled: true, + clusterSecretStore: 'aws-secretsmanager', + refreshInterval: '1h', + }, + gcp: { + enabled: false, + clusterSecretStore: 'gcp-sm', + refreshInterval: '1h', + }, + }; + + let processor: SecretProcessor; + + beforeEach(() => { + processor = new SecretProcessor(secretProviders); + jest.clearAllMocks(); + }); + + describe('waitForSecretSync', () => { + it('resolves when secret exists with data', async () => { + const mockReadNamespacedSecret = jest.fn().mockResolvedValue({ + body: { + data: { key: 'dmFsdWU=' }, + }, + }); + + jest.spyOn(processor as any, 'getK8sClient').mockReturnValue({ + readNamespacedSecret: mockReadNamespacedSecret, + }); + + await expect(processor.waitForSecretSync(['my-secret'], 'test-ns', 5000)).resolves.toBeUndefined(); + + expect(mockReadNamespacedSecret).toHaveBeenCalledWith('my-secret', 'test-ns'); + }); + + it('throws error on timeout when secret does not exist', async () => { + const mockReadNamespacedSecret = jest.fn().mockRejectedValue({ statusCode: 404 }); + + jest.spyOn(processor as any, 'getK8sClient').mockReturnValue({ + readNamespacedSecret: mockReadNamespacedSecret, + }); + + await expect(processor.waitForSecretSync(['my-secret'], 'test-ns', 1000)).rejects.toThrow('Secret sync timeout'); + }); + + it('throws error on timeout when secret has no data', async () => { + const mockReadNamespacedSecret = jest.fn().mockResolvedValue({ + body: { data: null }, + }); + + jest.spyOn(processor as any, 'getK8sClient').mockReturnValue({ + readNamespacedSecret: mockReadNamespacedSecret, + }); + + await expect(processor.waitForSecretSync(['my-secret'], 'test-ns', 1000)).rejects.toThrow('Secret sync timeout'); + }); + + it('waits for multiple secrets', async () => { + const mockReadNamespacedSecret = jest.fn().mockResolvedValue({ + body: { data: { key: 'dmFsdWU=' } }, + }); + + jest.spyOn(processor as any, 'getK8sClient').mockReturnValue({ + readNamespacedSecret: mockReadNamespacedSecret, + }); + + await processor.waitForSecretSync(['secret-1', 'secret-2'], 'test-ns', 5000); + + expect(mockReadNamespacedSecret).toHaveBeenCalledWith('secret-1', 'test-ns'); + expect(mockReadNamespacedSecret).toHaveBeenCalledWith('secret-2', 'test-ns'); + }); + }); + + describe('processEnvSecrets', () => { + it('extracts and validates secret references', async () => { + const env = { + DB_PASSWORD: '{{aws:myapp/db:password}}', + APP_ENV: 'production', + }; + + const result = await processor.processEnvSecrets({ + env, + serviceName: 'api-server', + namespace: 'lfc-abc123', + buildUuid: 'abc123', + }); + + expect(result.secretRefs).toHaveLength(1); + expect(result.secretRefs[0].envKey).toBe('DB_PASSWORD'); + expect(result.warnings).toHaveLength(0); + }); + + it('returns warning for disabled provider', async () => { + const env = { + GCP_SECRET: '{{gcp:path:key}}', + }; + + const result = await processor.processEnvSecrets({ + env, + serviceName: 'api-server', + namespace: 'lfc-abc123', + }); + + expect(result.secretRefs).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('disabled'); + }); + + it('returns warning for unconfigured provider', async () => { + const limitedConfig: SecretProvidersConfig = { + aws: { enabled: true, clusterSecretStore: 'aws-sm', refreshInterval: '1h' }, + }; + const limitedProcessor = new SecretProcessor(limitedConfig); + + const env = { + GCP_SECRET: '{{gcp:path:key}}', + }; + + const result = await limitedProcessor.processEnvSecrets({ + env, + serviceName: 'api-server', + namespace: 'lfc-abc123', + }); + + expect(result.secretRefs).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('not configured'); + }); + + it('creates ExternalSecrets for valid refs', async () => { + const { applyExternalSecret } = require('server/lib/kubernetes/externalSecret'); + + const env = { + DB_PASSWORD: '{{aws:myapp/db:password}}', + DB_USER: '{{aws:myapp/db:username}}', + }; + + await processor.processEnvSecrets({ + env, + serviceName: 'api-server', + namespace: 'lfc-abc123', + buildUuid: 'abc123', + }); + + expect(applyExternalSecret).toHaveBeenCalledTimes(1); + expect(applyExternalSecret).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + name: 'api-server-aws-secrets', + }), + }), + 'lfc-abc123' + ); + }); + + it('creates separate ExternalSecrets for multiple providers', async () => { + const multiProviderConfig: SecretProvidersConfig = { + aws: { enabled: true, clusterSecretStore: 'aws-sm', refreshInterval: '1h' }, + gcp: { enabled: true, clusterSecretStore: 'gcp-sm', refreshInterval: '1h' }, + }; + const multiProcessor = new SecretProcessor(multiProviderConfig); + const { applyExternalSecret } = require('server/lib/kubernetes/externalSecret'); + + const env = { + AWS_SECRET: '{{aws:path:key}}', + GCP_SECRET: '{{gcp:path:key}}', + }; + + await multiProcessor.processEnvSecrets({ + env, + serviceName: 'api-server', + namespace: 'ns', + }); + + expect(applyExternalSecret).toHaveBeenCalledTimes(2); + }); + + it('handles empty env', async () => { + const result = await processor.processEnvSecrets({ + env: {}, + serviceName: 'api-server', + namespace: 'ns', + }); + + expect(result.secretRefs).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it('returns warning when ExternalSecret apply fails', async () => { + const { applyExternalSecret } = require('server/lib/kubernetes/externalSecret'); + applyExternalSecret.mockRejectedValueOnce(new Error('kubectl failed')); + + const env = { + DB_PASSWORD: '{{aws:myapp/db:password}}', + }; + + const result = await processor.processEnvSecrets({ + env, + serviceName: 'api-server', + namespace: 'lfc-abc123', + }); + + expect(result.secretRefs).toHaveLength(1); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('Failed to apply ExternalSecret'); + }); + + it('returns secret names for mounting', async () => { + const env = { + AWS_SECRET: '{{aws:path:key}}', + GCP_SECRET: '{{gcp:path:key}}', + }; + + const multiProcessor = new SecretProcessor({ + aws: { enabled: true, clusterSecretStore: 'aws-sm', refreshInterval: '1h' }, + gcp: { enabled: true, clusterSecretStore: 'gcp-sm', refreshInterval: '1h' }, + }); + + const result = await multiProcessor.processEnvSecrets({ + env, + serviceName: 'api-server', + namespace: 'ns', + }); + + expect(result.secretNames).toContain('api-server-aws-secrets'); + expect(result.secretNames).toContain('api-server-gcp-secrets'); + }); + }); +}); diff --git a/src/server/services/deploy.ts b/src/server/services/deploy.ts index 9d0396c..e476b17 100644 --- a/src/server/services/deploy.ts +++ b/src/server/services/deploy.ts @@ -37,6 +37,7 @@ import { getLogs } from 'server/lib/codefresh'; import { buildWithNative } from 'server/lib/nativeBuild'; import { constructEcrTag } from 'server/lib/codefresh/utils'; import { ChartType, determineChartType } from 'server/lib/nativeHelm'; +import { SecretProcessor } from 'server/services/secretProcessor'; export interface DeployOptions { ownerId?: number; @@ -1043,12 +1044,56 @@ export default class DeployService extends BaseService { if (['buildkit', 'kaniko'].includes(deployable.builder?.engine)) { getLogger().info(`Image: building engine=${deployable.builder.engine}`); + let buildSecretNames: string[] = []; + const globalConfigs = await GlobalConfigService.getInstance().getAllConfigs(); + const secretProviders = globalConfigs.secretProviders; + + if (secretProviders && deploy.env) { + const secretProcessor = new SecretProcessor(secretProviders); + const envToProcess = deploy.env as Record; + + const secretResult = await secretProcessor.processEnvSecrets({ + env: envToProcess, + serviceName: deployable.name, + namespace: deploy.build.namespace, + buildUuid: deploy.uuid, + }); + + if (secretResult.warnings.length > 0) { + getLogger().warn( + `Build: secret processing warnings service=${deployable.name} warnings=${secretResult.warnings.join( + ', ' + )}` + ); + } + + if (secretResult.secretNames.length > 0) { + getLogger().info(`Build: waiting for secrets to sync secrets=[${secretResult.secretNames.join(', ')}]`); + + const providerTimeouts = Object.values(secretProviders) + .map((p) => p.secretSyncTimeout) + .filter((t): t is number => t !== undefined); + const timeout = providerTimeouts.length > 0 ? Math.max(...providerTimeouts) * 1000 : 60000; + + try { + await secretProcessor.waitForSecretSync(secretResult.secretNames, deploy.build.namespace, timeout); + buildSecretNames = secretResult.secretNames; + getLogger().info(`Build: secrets synced count=${buildSecretNames.length}`); + } catch (error) { + getLogger().error({ error }, `Build: secret sync failed service=${deployable.name}`); + await this.patchAndUpdateActivityFeed(deploy, { status: DeployStatus.BUILD_FAILED }, runUUID); + return false; + } + } + } + const nativeOptions = { ...buildOptions, namespace: deploy.build.namespace, buildId: String(deploy.build.id), deployUuid: deploy.uuid, // Use the full deploy UUID which includes service name cacheRegistry: buildDefaults?.cacheRegistry, + secretRefs: buildSecretNames, }; if (!initDockerfilePath) { diff --git a/src/server/services/secretProcessor.ts b/src/server/services/secretProcessor.ts new file mode 100644 index 0000000..05f32df --- /dev/null +++ b/src/server/services/secretProcessor.ts @@ -0,0 +1,154 @@ +/** + * 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 { getLogger } from 'server/lib/logger'; +import { parseSecretRefsFromEnv, validateSecretRef, SecretRefWithEnvKey } from 'server/lib/secretRefs'; +import { + applyExternalSecret, + generateExternalSecretManifest, + groupSecretRefsByProvider, + generateSecretName, +} from 'server/lib/kubernetes/externalSecret'; +import { SecretProvidersConfig } from 'server/services/types/globalConfig'; +import { CoreV1Api, KubeConfig } from '@kubernetes/client-node'; + +const DEFAULT_SECRET_SYNC_TIMEOUT = 60000; + +export interface ProcessEnvSecretsOptions { + env: Record; + serviceName: string; + namespace: string; + buildUuid?: string; +} + +export interface ProcessEnvSecretsResult { + secretRefs: SecretRefWithEnvKey[]; + secretNames: string[]; + warnings: string[]; +} + +export class SecretProcessor { + private secretProviders: SecretProvidersConfig | undefined; + private k8sClient: CoreV1Api | null = null; + + constructor(secretProviders: SecretProvidersConfig | undefined) { + this.secretProviders = secretProviders; + } + + private getK8sClient(): CoreV1Api { + if (!this.k8sClient) { + const kc = new KubeConfig(); + kc.loadFromDefault(); + this.k8sClient = kc.makeApiClient(CoreV1Api); + } + return this.k8sClient; + } + + async waitForSecretSync(secretNames: string[], namespace: string, timeoutMs?: number): Promise { + const timeout = timeoutMs ?? DEFAULT_SECRET_SYNC_TIMEOUT; + const pollInterval = 1000; + const startTime = Date.now(); + + const k8sClient = this.getK8sClient(); + + for (const secretName of secretNames) { + let synced = false; + + while (!synced) { + if (Date.now() - startTime > timeout) { + throw new Error(`Secret sync timeout: ${secretName} not ready after ${timeout}ms`); + } + + try { + const response = await k8sClient.readNamespacedSecret(secretName, namespace); + if (response.body.data && Object.keys(response.body.data).length > 0) { + getLogger().info(`Secret: synced name=${secretName} namespace=${namespace}`); + synced = true; + } else { + getLogger().debug(`Secret: waiting for data name=${secretName}`); + await this.sleep(pollInterval); + } + } catch (error: any) { + if (error.statusCode === 404) { + getLogger().debug(`Secret: not found yet name=${secretName}`); + await this.sleep(pollInterval); + } else { + throw error; + } + } + } + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + async processEnvSecrets(options: ProcessEnvSecretsOptions): Promise { + const { env, serviceName, namespace, buildUuid } = options; + const warnings: string[] = []; + const validRefs: SecretRefWithEnvKey[] = []; + + const allRefs = parseSecretRefsFromEnv(env); + + 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 + }`; + warnings.push(warning); + getLogger().warn(warning); + continue; + } + + validRefs.push(ref); + } + + if (validRefs.length === 0) { + return { secretRefs: [], secretNames: [], warnings }; + } + + const grouped = groupSecretRefsByProvider(validRefs); + const secretNames: string[] = []; + + for (const [provider, refs] of Object.entries(grouped)) { + const providerConfig = this.secretProviders![provider]; + const secretName = generateSecretName(serviceName, provider); + + const manifest = generateExternalSecretManifest({ + name: serviceName, + namespace, + provider, + secretRefs: refs, + providerConfig, + buildUuid, + }); + + try { + await applyExternalSecret(manifest, namespace); + secretNames.push(secretName); + } catch (error) { + const errorMsg = (error as any)?.message || (error as any)?.stderr || String(error); + const warning = `Failed to apply ExternalSecret for ${serviceName}: ${errorMsg}`; + warnings.push(warning); + } + } + + return { secretRefs: validRefs, secretNames, warnings }; + } +} diff --git a/src/server/services/types/globalConfig.ts b/src/server/services/types/globalConfig.ts index b4efb01..807c0bd 100644 --- a/src/server/services/types/globalConfig.ts +++ b/src/server/services/types/globalConfig.ts @@ -38,6 +38,7 @@ export type GlobalConfig = { app_setup: AppSetup; labels: LabelsConfig; ttl_cleanup?: TTLCleanupConfig; + secretProviders?: SecretProvidersConfig; }; export type AppSetup = { @@ -159,3 +160,15 @@ export type TTLCleanupConfig = { commentTemplate?: string; excludedRepositories: string[]; }; + +export type SecretProviderConfig = { + enabled: boolean; + clusterSecretStore: string; + refreshInterval: string; + allowedPrefixes?: string[]; + secretSyncTimeout?: number; +}; + +export type SecretProvidersConfig = { + [provider: string]: SecretProviderConfig; +}; From 2dfe65d4f0a82de5cf40719e4796ca7d334de41d Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Mon, 19 Jan 2026 18:14:09 -0800 Subject: [PATCH 2/5] fix namespace not existing --- src/server/services/deploy.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/server/services/deploy.ts b/src/server/services/deploy.ts index e476b17..754f72a 100644 --- a/src/server/services/deploy.ts +++ b/src/server/services/deploy.ts @@ -1045,10 +1045,14 @@ export default class DeployService extends BaseService { getLogger().info(`Image: building engine=${deployable.builder.engine}`); let buildSecretNames: string[] = []; + let secretEnvKeys: Set = new Set(); const globalConfigs = await GlobalConfigService.getInstance().getAllConfigs(); const secretProviders = globalConfigs.secretProviders; if (secretProviders && deploy.env) { + const { ensureNamespaceExists } = await import('server/lib/nativeBuild/utils'); + await ensureNamespaceExists(deploy.build.namespace); + const secretProcessor = new SecretProcessor(secretProviders); const envToProcess = deploy.env as Record; @@ -1059,6 +1063,8 @@ export default class DeployService extends BaseService { buildUuid: deploy.uuid, }); + secretEnvKeys = new Set(secretResult.secretRefs.map((ref) => ref.envKey)); + if (secretResult.warnings.length > 0) { getLogger().warn( `Build: secret processing warnings service=${deployable.name} warnings=${secretResult.warnings.join( @@ -1087,11 +1093,19 @@ export default class DeployService extends BaseService { } } + const filteredEnvVars = + secretEnvKeys.size > 0 + ? Object.fromEntries( + Object.entries(buildOptions.envVars || {}).filter(([key]) => !secretEnvKeys.has(key)) + ) + : buildOptions.envVars; + const nativeOptions = { ...buildOptions, + envVars: filteredEnvVars, namespace: deploy.build.namespace, buildId: String(deploy.build.id), - deployUuid: deploy.uuid, // Use the full deploy UUID which includes service name + deployUuid: deploy.uuid, cacheRegistry: buildDefaults?.cacheRegistry, secretRefs: buildSecretNames, }; From 35a60cbb3f28dbf5f48607b2f1232b574bdc3042 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Mon, 19 Jan 2026 19:04:35 -0800 Subject: [PATCH 3/5] pass secrets to native build job --- src/server/lib/nativeBuild/engines.ts | 29 ++++++++++++++++++++++----- src/server/services/deploy.ts | 1 + 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/server/lib/nativeBuild/engines.ts b/src/server/lib/nativeBuild/engines.ts index e08a98f..46f5632 100644 --- a/src/server/lib/nativeBuild/engines.ts +++ b/src/server/lib/nativeBuild/engines.ts @@ -49,6 +49,7 @@ export interface NativeBuildOptions { limits?: Record; }; secretRefs?: string[]; + secretEnvKeys?: string[]; } interface BuildEngine { @@ -69,6 +70,7 @@ interface BuildArgOptions { cacheRef: string; buildArgs: Record; ecrDomain: string; + secretEnvKeys?: string[]; } const ENGINES: Record = { @@ -76,7 +78,7 @@ const ENGINES: Record = { name: 'buildkit', image: 'moby/buildkit:v0.12.0', command: ['/bin/sh', '-c'], - createArgs: ({ contextPath, dockerfilePath, destination, cacheRef, buildArgs, ecrDomain }) => { + createArgs: ({ contextPath, dockerfilePath, destination, cacheRef, buildArgs, ecrDomain, secretEnvKeys }) => { const buildctlArgs = [ 'build', '--frontend', @@ -139,8 +141,21 @@ fi echo "Setting DOCKER_CONFIG..." export DOCKER_CONFIG=~/.docker +# Build secret env vars as build args +SECRET_BUILD_ARGS="" +${ + secretEnvKeys && secretEnvKeys.length > 0 + ? secretEnvKeys + .map( + (key) => + `if [ -n "\\$${key}" ]; then SECRET_BUILD_ARGS="\\$SECRET_BUILD_ARGS --opt build-arg:${key}=\\$${key}"; fi` + ) + .join('\n') + : '# No secret env keys' +} + echo "Running buildctl..." -buildctl ${buildctlArgs.join(' \\\n ')} +buildctl ${buildctlArgs.join(' \\\n ')} $SECRET_BUILD_ARGS `; return [script.trim()]; @@ -196,7 +211,8 @@ function createBuildContainer( resources: any, buildArgs: Record, ecrDomain: string, - secretRefs?: string[] + secretRefs?: string[], + secretEnvKeys?: string[] ): any { const args = engine.createArgs({ contextPath, @@ -205,6 +221,7 @@ function createBuildContainer( cacheRef, buildArgs, ecrDomain, + secretEnvKeys, }); const containerEnvVars = engine.name === 'buildkit' ? envVars : buildArgs; @@ -350,7 +367,8 @@ export async function buildWithEngine( resources, options.envVars, options.ecrDomain, - options.secretRefs + options.secretRefs, + options.secretEnvKeys ) ); @@ -368,7 +386,8 @@ export async function buildWithEngine( resources, options.envVars, options.ecrDomain, - options.secretRefs + options.secretRefs, + options.secretEnvKeys ) ); getLogger().debug('Build: including init image'); diff --git a/src/server/services/deploy.ts b/src/server/services/deploy.ts index 754f72a..7f5e3f1 100644 --- a/src/server/services/deploy.ts +++ b/src/server/services/deploy.ts @@ -1108,6 +1108,7 @@ export default class DeployService extends BaseService { deployUuid: deploy.uuid, cacheRegistry: buildDefaults?.cacheRegistry, secretRefs: buildSecretNames, + secretEnvKeys: Array.from(secretEnvKeys), }; if (!initDockerfilePath) { From a170adddd78a8fad1fe4867dc86a32afe3e89619 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Mon, 19 Jan 2026 19:43:17 -0800 Subject: [PATCH 4/5] simplify secret arg script --- .../nativeBuild/__tests__/buildkit.test.ts | 31 ++++++++++++++++++- src/server/lib/nativeBuild/engines.ts | 23 ++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/server/lib/nativeBuild/__tests__/buildkit.test.ts b/src/server/lib/nativeBuild/__tests__/buildkit.test.ts index 84afd93..7ae40ed 100644 --- a/src/server/lib/nativeBuild/__tests__/buildkit.test.ts +++ b/src/server/lib/nativeBuild/__tests__/buildkit.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { buildkitBuild, BuildkitBuildOptions } from '../engines'; +import { buildkitBuild, BuildkitBuildOptions, generateSecretArgsScript } from '../engines'; import { shellPromise } from '../../shell'; import { waitForJobAndGetLogs, getGitHubToken } from '../utils'; import GlobalConfigService from '../../../services/globalConfig'; @@ -250,3 +250,32 @@ describe('buildkitBuild', () => { expect(fullCommand).toContain('lifecycle.io/ecr-repo: "test-repo"'); }); }); + +describe('generateSecretArgsScript', () => { + it('returns comment when no secret keys provided', () => { + expect(generateSecretArgsScript(undefined)).toBe('# No secret env keys'); + expect(generateSecretArgsScript([])).toBe('# No secret env keys'); + }); + + it('generates shell script for single secret key', () => { + const result = generateSecretArgsScript(['AWS_SECRET']); + expect(result).toBe( + '[ -n "$AWS_SECRET" ] && SECRET_BUILD_ARGS="$SECRET_BUILD_ARGS --opt build-arg:AWS_SECRET=$AWS_SECRET"' + ); + }); + + it('generates shell script for multiple secret keys', () => { + const result = generateSecretArgsScript(['SECRET_A', 'SECRET_B']); + const lines = result.split('\n'); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain('SECRET_A'); + expect(lines[1]).toContain('SECRET_B'); + }); + + it('produces valid shell syntax', () => { + const result = generateSecretArgsScript(['MY_SECRET']); + expect(result).not.toContain('\\$'); + expect(result).toContain('$MY_SECRET'); + expect(result).toContain('--opt build-arg:MY_SECRET='); + }); +}); diff --git a/src/server/lib/nativeBuild/engines.ts b/src/server/lib/nativeBuild/engines.ts index 46f5632..9ddfc55 100644 --- a/src/server/lib/nativeBuild/engines.ts +++ b/src/server/lib/nativeBuild/engines.ts @@ -73,6 +73,18 @@ interface BuildArgOptions { secretEnvKeys?: string[]; } +export function generateSecretArgsScript(secretEnvKeys?: string[]): string { + if (!secretEnvKeys || secretEnvKeys.length === 0) { + return '# No secret env keys'; + } + + const lines = secretEnvKeys.map( + (key) => `[ -n "$${key}" ] && SECRET_BUILD_ARGS="$SECRET_BUILD_ARGS --opt build-arg:${key}=$${key}"` + ); + + return lines.join('\n'); +} + const ENGINES: Record = { buildkit: { name: 'buildkit', @@ -143,16 +155,7 @@ export DOCKER_CONFIG=~/.docker # Build secret env vars as build args SECRET_BUILD_ARGS="" -${ - secretEnvKeys && secretEnvKeys.length > 0 - ? secretEnvKeys - .map( - (key) => - `if [ -n "\\$${key}" ]; then SECRET_BUILD_ARGS="\\$SECRET_BUILD_ARGS --opt build-arg:${key}=\\$${key}"; fi` - ) - .join('\n') - : '# No secret env keys' -} +${generateSecretArgsScript(secretEnvKeys)} echo "Running buildctl..." buildctl ${buildctlArgs.join(' \\\n ')} $SECRET_BUILD_ARGS From 1343ea1615e8c0c42707f5561c00111d13e7dee5 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Mon, 19 Jan 2026 20:29:11 -0800 Subject: [PATCH 5/5] envLens fixes --- public/utils/0-banner.js | 272 ++++++++++++++++++-- src/server/lib/__tests__/kubernetes.test.ts | 44 ++++ src/server/lib/helm/utils.ts | 5 + src/server/lib/kubernetes.ts | 2 + 4 files changed, 307 insertions(+), 16 deletions(-) diff --git a/public/utils/0-banner.js b/public/utils/0-banner.js index 39401d7..e0baa3b 100644 --- a/public/utils/0-banner.js +++ b/public/utils/0-banner.js @@ -26,12 +26,13 @@ color: '#fff', borderRadius: '8px', fontSize: '18px', - zIndex: '1000', + zIndex: '2147483647', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', transition: 'all 0.2s ease', border: '1px solid rgba(255, 255, 255, 0.1)', userSelect: 'text', overflow: 'hidden', + pointerEvents: 'auto', }; const TERMINAL_COLORS = { @@ -50,18 +51,83 @@ const LOG_BUTTON_ID = 'showLogsButton'; const SEARCH_INPUT_ID = 'logSearchInput'; + let shadowRoot = null; let logsModal = null; let evtSource = null; const state = { isHidden: true, terminalWasOpen: false, shouldShowBadge: false, + isCollapsed: false, badgePosition: { x: null, y: null, }, }; + function createShadowHost() { + const host = document.createElement('div'); + host.id = 'lifecycle-banner-host'; + host.style.cssText = ` + position: fixed !important; + z-index: 2147483647 !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + pointer-events: none !important; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + `; + document.body.appendChild(host); + shadowRoot = host.attachShadow({ mode: 'closed' }); + + const style = document.createElement('style'); + style.textContent = ` + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + `; + shadowRoot.appendChild(style); + + return shadowRoot; + } + + function formatTimeRemaining(createdAt, ttlDays = 7) { + if (!createdAt) return null; + + const created = new Date(createdAt); + const expiresAt = new Date(created.getTime() + ttlDays * 24 * 60 * 60 * 1000); + const now = new Date(); + const remaining = expiresAt - now; + + if (remaining <= 0) return 'Expired'; + + const days = Math.floor(remaining / (24 * 60 * 60 * 1000)); + const hours = Math.floor((remaining % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)); + + if (days > 0) return `${days}d ${hours}h`; + + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); + if (hours > 0) return `${hours}h ${minutes}m`; + + return `${minutes}m`; + } + + function getStatusColor(status) { + const statusLower = (status || '').toLowerCase(); + if (statusLower === 'running' || statusLower === 'success') return '#4CAF50'; + if ( + statusLower === 'deploying' || + statusLower === 'building' || + statusLower === 'pending' || + statusLower === 'queued' + ) + return '#FFA726'; + if (statusLower === 'failed' || statusLower === 'error') return '#FF5252'; + return '#888'; + } + function loadState() { const hiddenState = localStorage.getItem('componentsHidden'); const badgeVisibleState = localStorage.getItem('badgeVisible'); @@ -69,6 +135,7 @@ state.isHidden = hiddenState === null ? true : hiddenState === 'true'; state.shouldShowBadge = badgeVisibleState === null ? false : badgeVisibleState === 'true'; state.terminalWasOpen = localStorage.getItem('terminalWasOpen') === 'true'; + state.isCollapsed = localStorage.getItem('bannerCollapsed') === 'true'; const savedPosition = localStorage.getItem('badgePosition'); if (savedPosition) { @@ -96,7 +163,7 @@ } function toggleBadge(forceHide = false) { - let badge = document.getElementById(BADGE_ID); + let badge = shadowRoot.getElementById(BADGE_ID); if (forceHide) { if (badge) badge.style.display = 'none'; @@ -107,13 +174,13 @@ if (!content) return; if (badge) { - badge.style.display = 'block'; + badge.style.display = state.isCollapsed ? 'flex' : 'block'; return; } badge = createBadge(content); addShowLogsButton(badge); - document.body.appendChild(badge); + shadowRoot.appendChild(badge); saveState(); } @@ -122,12 +189,45 @@ localStorage.setItem('terminalWasOpen', state.terminalWasOpen); localStorage.setItem('badgeVisible', state.shouldShowBadge); localStorage.setItem('badgePosition', JSON.stringify(state.badgePosition)); + localStorage.setItem('bannerCollapsed', state.isCollapsed); } function buildBadgeContent() { - if (!window.LFC_BANNER || !window.LFC_BANNER.length) return 'Test Content'; - - return window.LFC_BANNER.filter((item) => item.value) + const status = window.LFC_DEPLOY_STATUS || ''; + const statusColor = getStatusColor(status); + const statusLower = (status || '').toLowerCase(); + const isPulsing = + statusLower === 'deploying' || + statusLower === 'building' || + statusLower === 'pending' || + statusLower === 'queued'; + + const statusIndicator = status + ? ` +
+ + ${status} +
+ ` + : ''; + + const ttlRemaining = formatTimeRemaining(window.LFC_CREATED_AT); + const ttlDisplay = ttlRemaining + ? ` +
+ Expires in: + ${ttlRemaining} +
+ ` + : ''; + + if (!window.LFC_BANNER || !window.LFC_BANNER.length) return statusIndicator + ttlDisplay + 'Test Content'; + + const bannerContent = window.LFC_BANNER.filter((item) => item.value) .map((item) => { const label = item.label; const value = item.url @@ -139,6 +239,8 @@ `; }) .join(''); + + return statusIndicator + ttlDisplay + bannerContent; } function makeDraggable(badge) { @@ -146,6 +248,7 @@ let startX, startY, initialX, initialY; const dragHandle = document.createElement('div'); + dragHandle.id = 'drag-handle'; dragHandle.style.cssText = ` width: 100%; height: 25px; @@ -170,7 +273,8 @@ badge.insertBefore(dragHandle, badge.firstChild); function handleMouseDown(e) { - if (e.target !== dragHandle && e.target !== dragIndicator) return; + const collapseBtn = badge.querySelector('#collapse-btn'); + if (e.target !== dragHandle && e.target !== dragIndicator && e.target !== collapseBtn) return; isDragging = true; startX = e.clientX; @@ -218,12 +322,88 @@ document.addEventListener('mouseup', handleMouseUp); } + function addCollapseButton(badge, contentWrapper) { + const collapseBtn = document.createElement('button'); + collapseBtn.id = 'collapse-btn'; + collapseBtn.innerHTML = state.isCollapsed ? '⚡' : '−'; + Object.assign(collapseBtn.style, { + position: 'absolute', + top: '5px', + right: '5px', + width: '20px', + height: '20px', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + border: 'none', + borderRadius: '4px', + color: '#fff', + cursor: 'pointer', + fontSize: '16px', + lineHeight: '1', + padding: '0', + zIndex: '10', + }); + + collapseBtn.addEventListener('click', (e) => { + e.stopPropagation(); + state.isCollapsed = !state.isCollapsed; + updateBadgeCollapsedState(badge, contentWrapper); + saveState(); + }); + + badge.appendChild(collapseBtn); + } + + function updateBadgeCollapsedState(badge, contentWrapper) { + const collapseBtn = badge.querySelector('#collapse-btn'); + const buttonContainer = shadowRoot.getElementById(`${LOG_BUTTON_ID}-container`); + const dragHandle = badge.querySelector('#drag-handle'); + + if (state.isCollapsed) { + contentWrapper.style.display = 'none'; + if (buttonContainer) buttonContainer.style.display = 'none'; + if (dragHandle) dragHandle.style.display = 'none'; + badge.style.width = '50px'; + badge.style.height = '50px'; + badge.style.borderRadius = '50%'; + badge.style.display = 'flex'; + badge.style.alignItems = 'center'; + badge.style.justifyContent = 'center'; + badge.style.padding = '0'; + collapseBtn.innerHTML = '⚡'; + collapseBtn.style.position = 'static'; + collapseBtn.style.width = '100%'; + collapseBtn.style.height = '100%'; + collapseBtn.style.fontSize = '24px'; + collapseBtn.style.backgroundColor = 'transparent'; + collapseBtn.style.borderRadius = '50%'; + } else { + contentWrapper.style.display = 'block'; + if (buttonContainer && !state.terminalWasOpen) buttonContainer.style.display = 'block'; + if (dragHandle) dragHandle.style.display = 'block'; + badge.style.width = ''; + badge.style.height = ''; + badge.style.borderRadius = '8px'; + badge.style.display = 'block'; + badge.style.alignItems = ''; + badge.style.justifyContent = ''; + badge.style.padding = ''; + collapseBtn.innerHTML = '−'; + collapseBtn.style.position = 'absolute'; + collapseBtn.style.width = '20px'; + collapseBtn.style.height = '20px'; + collapseBtn.style.fontSize = '16px'; + collapseBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'; + collapseBtn.style.borderRadius = '4px'; + } + } + function createBadge(content) { const badge = document.createElement('div'); badge.id = BADGE_ID; Object.assign(badge.style, BADGE_STYLES); const contentWrapper = document.createElement('div'); + contentWrapper.id = 'badge-content-wrapper'; contentWrapper.style.padding = '0 15px'; contentWrapper.innerHTML = content; badge.appendChild(contentWrapper); @@ -235,9 +415,16 @@ badge.style.top = `${state.badgePosition.y}px`; } + addCollapseButton(badge, contentWrapper); + + if (state.isCollapsed) { + updateBadgeCollapsedState(badge, contentWrapper); + } + makeDraggable(badge); return badge; } + function addShowLogsButton(badge) { const buttonContainer = document.createElement('div'); buttonContainer.id = `${LOG_BUTTON_ID}-container`; @@ -245,7 +432,7 @@ padding: '8px 15px 15px 15px', borderTop: '1px solid rgba(255, 255, 255, 0.1)', marginTop: '10px', - display: state.terminalWasOpen ? 'none' : 'block', + display: state.terminalWasOpen || state.isCollapsed ? 'none' : 'block', }); const btn = document.createElement('button'); @@ -286,10 +473,11 @@ height: '400px', backgroundColor: TERMINAL_COLORS.bg, borderTop: `2px solid ${TERMINAL_COLORS.prompt}`, - zIndex: '2000', + zIndex: '2147483647', overflow: 'hidden', display: 'none', fontFamily: '"Fira Code", "Source Code Pro", monospace', + pointerEvents: 'auto', }); const header = document.createElement('div'); @@ -338,8 +526,49 @@ searchInput.placeholder = 'Filter logs...'; searchContainer.appendChild(searchInput); + const copyBtn = document.createElement('button'); + copyBtn.textContent = 'Copy'; + Object.assign(copyBtn.style, { + backgroundColor: '#3D3D3D', + border: 'none', + color: TERMINAL_COLORS.text, + padding: '5px 10px', + borderRadius: '4px', + marginLeft: '8px', + cursor: 'pointer', + fontSize: '14px', + height: '28px', + }); + + copyBtn.addEventListener('click', async () => { + const contentDiv = shadowRoot.getElementById(LOG_MODAL_CONTENT_ID); + const logLines = Array.from(contentDiv.getElementsByClassName('log-line')) + .filter((line) => line.style.display !== 'none') + .map((line) => line.textContent) + .join('\n'); + + try { + await navigator.clipboard.writeText(logLines); + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 2000); + } catch (err) { + console.error('Failed to copy logs:', err); + copyBtn.textContent = 'Failed'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 2000); + } + }); + + copyBtn.addEventListener('mouseover', () => (copyBtn.style.backgroundColor = '#4D4D4D')); + copyBtn.addEventListener('mouseout', () => (copyBtn.style.backgroundColor = '#3D3D3D')); + + searchContainer.appendChild(copyBtn); + const closeBtn = document.createElement('button'); - closeBtn.textContent = '✕'; + closeBtn.textContent = '\u2715'; Object.assign(closeBtn.style, { position: 'absolute', right: '10px', @@ -412,7 +641,7 @@ }); }); - document.body.appendChild(modal); + shadowRoot.appendChild(modal); return modal; } @@ -439,7 +668,7 @@ state.terminalWasOpen = true; saveState(); - const contentDiv = document.getElementById(LOG_MODAL_CONTENT_ID); + const contentDiv = shadowRoot.getElementById(LOG_MODAL_CONTENT_ID); contentDiv.innerHTML = ''; const url = `${getBaseUrl()}/api/v1/builds/${encodeURIComponent(UUID)}/services/${encodeURIComponent( @@ -453,7 +682,7 @@ logLine.innerHTML = colorizeLog(event.data); contentDiv.appendChild(logLine); - const searchTerm = document.getElementById(SEARCH_INPUT_ID).value.toLowerCase(); + const searchTerm = shadowRoot.getElementById(SEARCH_INPUT_ID).value.toLowerCase(); if (searchTerm) { logLine.style.display = event.data.toLowerCase().includes(searchTerm) ? 'block' : 'none'; } @@ -469,8 +698,8 @@ state.terminalWasOpen = false; saveState(); - const buttonContainer = document.getElementById(`${LOG_BUTTON_ID}-container`); - if (buttonContainer) buttonContainer.style.display = 'block'; + const buttonContainer = shadowRoot.getElementById(`${LOG_BUTTON_ID}-container`); + if (buttonContainer && !state.isCollapsed) buttonContainer.style.display = 'block'; } if (evtSource) { evtSource.close(); @@ -505,6 +734,7 @@ } function initialize() { + createShadowHost(); loadState(); initShortcut(); @@ -517,6 +747,16 @@ showLogsModal(); } } + + setInterval(() => { + const badge = shadowRoot?.getElementById(BADGE_ID); + if (badge && !state.isCollapsed) { + const contentWrapper = badge.querySelector('#badge-content-wrapper'); + if (contentWrapper) { + contentWrapper.innerHTML = buildBadgeContent(); + } + } + }, 60000); } if (document.readyState === 'loading') { diff --git a/src/server/lib/__tests__/kubernetes.test.ts b/src/server/lib/__tests__/kubernetes.test.ts index e283f86..9b4eaf5 100644 --- a/src/server/lib/__tests__/kubernetes.test.ts +++ b/src/server/lib/__tests__/kubernetes.test.ts @@ -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'); + }); +}); diff --git a/src/server/lib/helm/utils.ts b/src/server/lib/helm/utils.ts index 072542c..72f1560 100644 --- a/src/server/lib/helm/utils.ts +++ b/src/server/lib/helm/utils.ts @@ -99,12 +99,17 @@ export function createBannerVars(options: BannerOptions[], deploy: Deploy): stri const uuid = deploy?.build?.uuid || ''; const serviceName = deploy?.deployable?.name || ''; + const deployStatus = deploy?.status || ''; + const createdAt = deploy?.build?.createdAt || ''; return [ `window.LFC_BANNER = ${JSON.stringify(bannerItems)};`, `window.LFC_UUID = "${uuid}";`, `window.LFC_SERVICE_NAME = "${serviceName}";`, `window.LFC_BASE_URL = "${APP_HOST}";`, + `window.LFC_DEPLOY_STATUS = "${deployStatus}";`, + `window.LFC_CREATED_AT = "${createdAt}";`, + `window.LFC_DASHBOARD_URL = "${LIFECYCLE_UI_HOSTHAME_WITH_SCHEME}/build/${uuid}";`, ].join('\n'); } diff --git a/src/server/lib/kubernetes.ts b/src/server/lib/kubernetes.ts index fb417dd..51800e6 100644 --- a/src/server/lib/kubernetes.ts +++ b/src/server/lib/kubernetes.ts @@ -2197,6 +2197,7 @@ function generateSingleDeploymentManifest({ lc_uuid: build.uuid, deploy_uuid: deploy.uuid, dd_name: `lifecycle-${build.uuid}`, + 'app.kubernetes.io/instance': `${serviceName}-${build.uuid}`, ...datadogLabels, }, }, @@ -2221,6 +2222,7 @@ function generateSingleDeploymentManifest({ lc_uuid: build.uuid, deploy_uuid: deploy.uuid, dd_name: `lifecycle-${build.uuid}`, + 'app.kubernetes.io/instance': `${serviceName}-${build.uuid}`, ...datadogLabels, }, },