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/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__/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/__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/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 c062c2b..51800e6 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(
@@ -2103,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,
},
},
@@ -2127,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,
},
},
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/__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 5816776..9ddfc55 100644
--- a/src/server/lib/nativeBuild/engines.ts
+++ b/src/server/lib/nativeBuild/engines.ts
@@ -48,6 +48,8 @@ export interface NativeBuildOptions {
requests?: Record;
limits?: Record;
};
+ secretRefs?: string[];
+ secretEnvKeys?: string[];
}
interface BuildEngine {
@@ -68,6 +70,19 @@ interface BuildArgOptions {
cacheRef: string;
buildArgs: Record;
ecrDomain: string;
+ 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 = {
@@ -75,7 +90,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',
@@ -138,8 +153,12 @@ fi
echo "Setting DOCKER_CONFIG..."
export DOCKER_CONFIG=~/.docker
+# Build secret env vars as build args
+SECRET_BUILD_ARGS=""
+${generateSecretArgsScript(secretEnvKeys)}
+
echo "Running buildctl..."
-buildctl ${buildctlArgs.join(' \\\n ')}
+buildctl ${buildctlArgs.join(' \\\n ')} $SECRET_BUILD_ARGS
`;
return [script.trim()];
@@ -194,7 +213,9 @@ function createBuildContainer(
envVars: Record,
resources: any,
buildArgs: Record,
- ecrDomain: string
+ ecrDomain: string,
+ secretRefs?: string[],
+ secretEnvKeys?: string[]
): any {
const args = engine.createArgs({
contextPath,
@@ -203,6 +224,7 @@ function createBuildContainer(
cacheRef,
buildArgs,
ecrDomain,
+ secretEnvKeys,
});
const containerEnvVars = engine.name === 'buildkit' ? envVars : buildArgs;
@@ -223,7 +245,7 @@ function createBuildContainer(
containerEnvVars['DOCKER_CONFIG'] = '/kaniko/.docker';
}
- return {
+ const container: any = {
name,
image: engine.image,
command: engine.command,
@@ -232,6 +254,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 +369,9 @@ export async function buildWithEngine(
envVars,
resources,
options.envVars,
- options.ecrDomain
+ options.ecrDomain,
+ options.secretRefs,
+ options.secretEnvKeys
)
);
@@ -353,7 +388,9 @@ export async function buildWithEngine(
envVars,
resources,
options.envVars,
- options.ecrDomain
+ options.ecrDomain,
+ options.secretRefs,
+ options.secretEnvKeys
)
);
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..7f5e3f1 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,71 @@ export default class DeployService extends BaseService {
if (['buildkit', 'kaniko'].includes(deployable.builder?.engine)) {
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;
+
+ const secretResult = await secretProcessor.processEnvSecrets({
+ env: envToProcess,
+ serviceName: deployable.name,
+ namespace: deploy.build.namespace,
+ 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(
+ ', '
+ )}`
+ );
+ }
+
+ 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 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,
+ secretEnvKeys: Array.from(secretEnvKeys),
};
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;
+};