diff --git a/packages/k8s/src/k8s/index.ts b/packages/k8s/src/k8s/index.ts index 061b048c..21661eea 100644 --- a/packages/k8s/src/k8s/index.ts +++ b/packages/k8s/src/k8s/index.ts @@ -25,6 +25,7 @@ import { GITHUB_VOLUME_NAME, WORK_VOLUME } from './utils' +import { maybeInjectTransparentCache } from './utils/transparent-cache' import * as shlex from 'shlex' import { parsePositiveMsEnv, WebSocketHeartbeat } from './heartbeat' import type { HeartbeatWebSocket } from './heartbeat' @@ -66,6 +67,15 @@ export const requiredPermissions = [ } ] +async function cacheSecretExists(name: string): Promise { + try { + await k8sApi.readNamespacedSecret({ namespace: namespace(), name }) + return true + } catch { + return false + } +} + export async function createJobPod( name: string, jobContainer?: k8s.V1Container, @@ -180,6 +190,13 @@ export async function createJobPod( mergePodSpecWithOptions(appPod.spec, extension.spec) } + // Transparent cache injection (issue #1133): no-op unless the runner env + // enables it, and never overrides env/volumes set by the workflow or the + // extension template above. + await maybeInjectTransparentCache(appPod.spec, process.env, { + secretExists: cacheSecretExists + }) + return await k8sApi.createNamespacedPod({ namespace: namespace(), body: appPod @@ -233,6 +250,10 @@ export async function createContainerStepPod( mergePodSpecWithOptions(appPod.spec, extension.spec) } + await maybeInjectTransparentCache(appPod.spec, process.env, { + secretExists: cacheSecretExists + }) + return await k8sApi.createNamespacedPod({ namespace: namespace(), body: appPod diff --git a/packages/k8s/src/k8s/utils/transparent-cache.ts b/packages/k8s/src/k8s/utils/transparent-cache.ts new file mode 100644 index 00000000..e40a5e77 --- /dev/null +++ b/packages/k8s/src/k8s/utils/transparent-cache.ts @@ -0,0 +1,309 @@ +import * as k8s from '@kubernetes/client-node' +import * as http from 'http' +import * as core from '@actions/core' + +// ── Transparent cache injection (issue #1133) ──────────────────────────────── +// +// When the runner (listener) is configured with the ACTIONS_RUNNER_CACHE_* env +// vars below, every job pod created by the k8s hook transparently gets: +// 1. HTTP_PROXY/HTTPS_PROXY/NO_PROXY (+ lowercase) env vars pointing at the +// cluster squid caching proxy — only when the proxy answers a health +// probe (fail-open: a dead proxy must not break every job). +// 2. The squid CA certificate mounted at /etc/squid-ca plus the usual +// *_CA_* env vars (SSL_CERT_FILE, CURL_CA_BUNDLE, …) so TLS through the +// MITM proxy verifies. +// 3. An opt-out postStart hook that installs the CA into the container's +// system trust store (apt/yum only read the compiled bundle). +// +// Everything is gated: if ACTIONS_RUNNER_ENABLE_TRANSPARENT_CACHE is not +// exactly "true" (or the proxy is missing) the pod spec is left untouched. + +export const ENV_ENABLE_TRANSPARENT_CACHE = + 'ACTIONS_RUNNER_ENABLE_TRANSPARENT_CACHE' +export const ENV_CACHE_PROXY = 'ACTIONS_RUNNER_CACHE_PROXY' +export const ENV_CACHE_NO_PROXY = 'ACTIONS_RUNNER_CACHE_NO_PROXY' +export const ENV_CACHE_CA_SECRET = 'ACTIONS_RUNNER_CACHE_CA_SECRET' +export const ENV_CACHE_CA_TRUST_HOOK = 'ACTIONS_RUNNER_CACHE_CA_TRUST_HOOK' + +export const DEFAULT_CA_SECRET_NAME = 'squid-ca-cert' +export const DEFAULT_NO_PROXY = + 'localhost,127.0.0.1,::1,.svc,.cluster.local,10.0.0.0/8,169.254.169.254' + +const CA_VOLUME_NAME = 'squid-ca' +const CA_MOUNT_PATH = '/etc/squid-ca' +const CA_KEY = 'squid-ca.pem' + +export interface TransparentCacheConfig { + proxy: string + noProxy: string + caSecret: string + caTrustHook: boolean +} + +export interface InjectionResult { + /** CA volume/env injected into at least one container */ + caInjected: boolean + /** proxy env vars injected (requires healthy proxy) */ + proxyEnvInjected: boolean +} + +// Normalize a bare host:port into a full http URL. +export function normalizeProxyUrl(raw: string): string | undefined { + const trimmed = raw.trim() + if (!trimmed) return undefined + if (/^https?:\/\//i.test(trimmed)) return trimmed + return `http://${trimmed}` +} + +// Reads the runner env and returns the injection config, or undefined when +// the feature is disabled or incomplete (gate: never partially inject). +export function readTransparentCacheConfig( + env: Record = process.env +): TransparentCacheConfig | undefined { + if (env[ENV_ENABLE_TRANSPARENT_CACHE] !== 'true') { + return undefined + } + const proxy = normalizeProxyUrl(env[ENV_CACHE_PROXY] ?? '') + if (!proxy) { + core.warning( + `${ENV_ENABLE_TRANSPARENT_CACHE}=true but ${ENV_CACHE_PROXY} is unset; transparent cache disabled` + ) + return undefined + } + return { + proxy, + noProxy: env[ENV_CACHE_NO_PROXY]?.trim() || DEFAULT_NO_PROXY, + caSecret: env[ENV_CACHE_CA_SECRET]?.trim() || DEFAULT_CA_SECRET_NAME, + caTrustHook: env[ENV_CACHE_CA_TRUST_HOOK] !== 'false' + } +} + +// ── Proxy health probe (fail-open escape, REL-1) ───────────────────────────── +// +// A short-lived HTTP request through the proxy port; any HTTP response means +// the proxy is alive. Results are cached for PROBE_CACHE_TTL_MS so a burst of +// job pods does not re-probe per pod. + +const PROBE_TIMEOUT_MS = 2000 +const PROBE_CACHE_TTL_MS = 30_000 + +let probeCache: { proxy: string; healthy: boolean; at: number } | undefined + +export function resetProxyHealthCache(): void { + probeCache = undefined +} + +export async function isProxyHealthy( + proxyUrl: string, + timeoutMs = PROBE_TIMEOUT_MS +): Promise { + const now = Date.now() + if ( + probeCache && + probeCache.proxy === proxyUrl && + now - probeCache.at < PROBE_CACHE_TTL_MS + ) { + return probeCache.healthy + } + const healthy = await probeProxy(proxyUrl, timeoutMs) + probeCache = { proxy: proxyUrl, healthy, at: now } + return healthy +} + +async function probeProxy( + proxyUrl: string, + timeoutMs: number +): Promise { + return new Promise(resolve => { + let settled = false + const finish = (ok: boolean): void => { + if (!settled) { + settled = true + resolve(ok) + } + } + let url: URL + try { + url = new URL(proxyUrl) + } catch { + finish(false) + return + } + try { + const req = http.request( + { + hostname: url.hostname, + port: url.port || 80, + method: 'HEAD', + path: '/', + timeout: timeoutMs + }, + res => { + res.resume() + finish(true) + } + ) + req.on('timeout', () => { + req.destroy() + finish(false) + }) + req.on('error', () => finish(false)) + req.end() + } catch { + finish(false) + } + }) +} + +// Best-effort CA install for apt/yum/dnf which only read the compiled system +// bundle. Runs inside the job container: every statement is fault tolerant and +// the script always exits 0 (a failing postStart hook kills the pod). +const CA_TRUST_SCRIPT = [ + '/bin/sh', + '-c', + `SQUID_CA=${CA_MOUNT_PATH}/${CA_KEY} +i=0 +while [ $i -lt 10 ] && [ ! -f "$SQUID_CA" ]; do sleep 1; i=$((i+1)); done +if [ -f "$SQUID_CA" ]; then + if [ -f /etc/ssl/certs/ca-certificates.crt ]; then + cat "$SQUID_CA" >> /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true + command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates 2>/dev/null || true + fi + mkdir -p /etc/pki/ca-trust/source/anchors 2>/dev/null || true + cp "$SQUID_CA" /etc/pki/ca-trust/source/anchors/squid-ca.crt 2>/dev/null || true + command -v update-ca-trust >/dev/null 2>&1 && update-ca-trust extract 2>/dev/null || true +fi +exit 0` +] + +function addEnvIfMissing( + container: k8s.V1Container, + name: string, + value: string +): boolean { + if (container.env?.some(e => e.name === name)) return false + container.env = container.env ?? [] + container.env.push({ name, value }) + return true +} + +// Injects the transparent cache into a pod spec. Pure spec mutation, no I/O: +// `proxyHealthy` is decided by the caller (see maybeInjectTransparentCache). +export function injectTransparentCache( + spec: k8s.V1PodSpec, + config: TransparentCacheConfig, + opts: { proxyHealthy: boolean } +): InjectionResult { + const result: InjectionResult = { caInjected: false, proxyEnvInjected: false } + const containers = spec.containers ?? [] + if (containers.length === 0) return result + + // CA volume (skip silently when the name is already taken — an ops-provided + // template owns it then). + const volumeExists = + spec.volumes?.some(v => v.name === CA_VOLUME_NAME) ?? false + if (!volumeExists) { + spec.volumes = spec.volumes ?? [] + spec.volumes.push({ + name: CA_VOLUME_NAME, + secret: { + secretName: config.caSecret, + items: [{ key: CA_KEY, path: CA_KEY }] + } + }) + } + + const proxyEnvs: [string, string][] = opts.proxyHealthy + ? [ + ['HTTP_PROXY', config.proxy], + ['HTTPS_PROXY', config.proxy], + ['http_proxy', config.proxy], + ['https_proxy', config.proxy], + ['NO_PROXY', config.noProxy], + ['no_proxy', config.noProxy] + ] + : [] + + for (const container of containers) { + // CA mount + if (!container.volumeMounts?.some(m => m.name === CA_VOLUME_NAME)) { + container.volumeMounts = container.volumeMounts ?? [] + container.volumeMounts.push({ + name: CA_VOLUME_NAME, + mountPath: CA_MOUNT_PATH, + readOnly: true + }) + } + // CA env vars (only the common readers of a PEM file; apt/yum are covered + // by the postStart hook below) + addEnvIfMissing(container, 'SSL_CERT_FILE', `${CA_MOUNT_PATH}/${CA_KEY}`) + addEnvIfMissing(container, 'CURL_CA_BUNDLE', `${CA_MOUNT_PATH}/${CA_KEY}`) + addEnvIfMissing( + container, + 'REQUESTS_CA_BUNDLE', + `${CA_MOUNT_PATH}/${CA_KEY}` + ) + addEnvIfMissing(container, 'GIT_SSL_CAINFO', `${CA_MOUNT_PATH}/${CA_KEY}`) + addEnvIfMissing(container, 'PIP_CERT', `${CA_MOUNT_PATH}/${CA_KEY}`) + addEnvIfMissing( + container, + 'NODE_EXTRA_CA_CERTS', + `${CA_MOUNT_PATH}/${CA_KEY}` + ) + result.caInjected = true + + for (const [name, value] of proxyEnvs) { + if (addEnvIfMissing(container, name, value)) { + result.proxyEnvInjected = true + } + } + + // System trust store install — only when the container does not already + // define a postStart hook (ops-owned) and the fleet did not opt out. + if (config.caTrustHook && !container.lifecycle?.postStart) { + container.lifecycle = container.lifecycle ?? {} + container.lifecycle.postStart = { exec: { command: CA_TRUST_SCRIPT } } + } + } + return result +} + +// Entry point used by createJobPod/createContainerStepPod: reads the runner +// env, verifies the CA secret exists, probes the proxy health and applies the +// injection. No-op when the feature is not enabled. When the CA secret is +// missing nothing is injected at all — a pod referencing a non-existent +// secret fails to mount, which would break every job. +export async function maybeInjectTransparentCache( + spec: k8s.V1PodSpec, + env: Record = process.env, + deps: { secretExists?: (name: string) => Promise } = {} +): Promise { + const config = readTransparentCacheConfig(env) + if (!config) return undefined + if (deps.secretExists) { + let exists = false + try { + exists = await deps.secretExists(config.caSecret) + } catch { + exists = false + } + if (!exists) { + core.warning( + `[transparent-cache] secret ${config.caSecret} not found in the runner namespace — skipping injection entirely` + ) + return undefined + } + } + let healthy = false + try { + healthy = await isProxyHealthy(config.proxy) + } catch { + healthy = false + } + if (!healthy) { + core.warning( + `[transparent-cache] proxy ${config.proxy} unhealthy — injecting CA only, job pods go direct` + ) + } + return injectTransparentCache(spec, config, { proxyHealthy: healthy }) +} diff --git a/packages/k8s/tests/transparent-cache-test.ts b/packages/k8s/tests/transparent-cache-test.ts new file mode 100644 index 00000000..348d5ac3 --- /dev/null +++ b/packages/k8s/tests/transparent-cache-test.ts @@ -0,0 +1,383 @@ +import * as k8s from '@kubernetes/client-node' +import * as http from 'http' +import * as net from 'net' +import { + DEFAULT_CA_SECRET_NAME, + DEFAULT_NO_PROXY, + ENV_CACHE_CA_SECRET, + ENV_CACHE_NO_PROXY, + ENV_CACHE_PROXY, + ENV_ENABLE_TRANSPARENT_CACHE, + injectTransparentCache, + isProxyHealthy, + maybeInjectTransparentCache, + normalizeProxyUrl, + readTransparentCacheConfig, + resetProxyHealthCache +} from '../src/k8s/utils/transparent-cache' + +function ephemeralServer(): Promise { + return new Promise(resolve => { + const server = http.createServer((_req, res) => { + res.statusCode = 400 // any response means "alive" + res.end('no') + }) + server.listen(0, '127.0.0.1', () => resolve(server)) + }) +} + +function closedPort(): Promise { + return new Promise(resolve => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const port = (srv.address() as net.AddressInfo).port + srv.close(() => resolve(port)) + }) + }) +} + +function buildSpec(): k8s.V1PodSpec { + const container = new k8s.V1Container() + container.name = 'job' + const spec = new k8s.V1PodSpec() + spec.containers = [container] + return spec +} + +function enabledEnv(proxy: string): NodeJS.ProcessEnv { + return { + [ENV_ENABLE_TRANSPARENT_CACHE]: 'true', + [ENV_CACHE_PROXY]: proxy + } +} + +function envOf(container: k8s.V1Container, name: string): string | undefined { + return container.env?.find(e => e.name === name)?.value +} + +beforeEach(() => { + resetProxyHealthCache() +}) + +describe('normalizeProxyUrl', () => { + it('keeps urls that already carry a scheme', () => { + expect(normalizeProxyUrl('http://squid:3128')).toBe('http://squid:3128') + expect(normalizeProxyUrl('https://squid:3128')).toBe('https://squid:3128') + }) + + it('prepends http:// to bare host:port', () => { + expect(normalizeProxyUrl('squid-cache.squid.svc:3128')).toBe( + 'http://squid-cache.squid.svc:3128' + ) + }) + + it('returns undefined for empty input', () => { + expect(normalizeProxyUrl('')).toBeUndefined() + expect(normalizeProxyUrl(' ')).toBeUndefined() + }) +}) + +describe('readTransparentCacheConfig', () => { + it('is disabled when the gate env is missing', () => { + expect(readTransparentCacheConfig({})).toBeUndefined() + expect( + readTransparentCacheConfig({ [ENV_CACHE_PROXY]: 'http://squid:3128' }) + ).toBeUndefined() + }) + + it('is disabled when the gate is not exactly true', () => { + expect( + readTransparentCacheConfig({ + [ENV_ENABLE_TRANSPARENT_CACHE]: 'TRUE', + [ENV_CACHE_PROXY]: 'http://squid:3128' + }) + ).toBeUndefined() + expect( + readTransparentCacheConfig({ + [ENV_ENABLE_TRANSPARENT_CACHE]: '1', + [ENV_CACHE_PROXY]: 'http://squid:3128' + }) + ).toBeUndefined() + }) + + it('is disabled when the proxy is missing or blank', () => { + expect( + readTransparentCacheConfig({ + [ENV_ENABLE_TRANSPARENT_CACHE]: 'true' + }) + ).toBeUndefined() + expect( + readTransparentCacheConfig({ + [ENV_ENABLE_TRANSPARENT_CACHE]: 'true', + [ENV_CACHE_PROXY]: ' ' + }) + ).toBeUndefined() + }) + + it('returns defaults for a minimal enabled config', () => { + const cfg = readTransparentCacheConfig( + enabledEnv('squid-cache.squid.svc:3128') + ) + expect(cfg).toBeDefined() + expect(cfg!.proxy).toBe('http://squid-cache.squid.svc:3128') + expect(cfg!.noProxy).toBe(DEFAULT_NO_PROXY) + expect(cfg!.caSecret).toBe(DEFAULT_CA_SECRET_NAME) + expect(cfg!.caTrustHook).toBe(true) + }) + + it('honours explicit overrides', () => { + const cfg = readTransparentCacheConfig({ + [ENV_ENABLE_TRANSPARENT_CACHE]: 'true', + [ENV_CACHE_PROXY]: 'http://squid:3128', + [ENV_CACHE_NO_PROXY]: 'localhost,.svc', + [ENV_CACHE_CA_SECRET]: 'custom-ca', + ACTIONS_RUNNER_CACHE_CA_TRUST_HOOK: 'false' + }) + expect(cfg!.noProxy).toBe('localhost,.svc') + expect(cfg!.caSecret).toBe('custom-ca') + expect(cfg!.caTrustHook).toBe(false) + }) +}) + +describe('injectTransparentCache', () => { + const config = { + proxy: 'http://squid-cache.squid.svc.cluster.local:3128', + noProxy: 'localhost,127.0.0.1,.svc', + caSecret: 'squid-ca-cert', + caTrustHook: true + } + + it('injects proxy env, CA env, volume, mount and postStart when healthy', () => { + const spec = buildSpec() + const result = injectTransparentCache(spec, config, { + proxyHealthy: true + }) + expect(result.caInjected).toBe(true) + expect(result.proxyEnvInjected).toBe(true) + + const c = spec.containers![0] + expect(envOf(c, 'HTTP_PROXY')).toBe(config.proxy) + expect(envOf(c, 'HTTPS_PROXY')).toBe(config.proxy) + expect(envOf(c, 'http_proxy')).toBe(config.proxy) + expect(envOf(c, 'https_proxy')).toBe(config.proxy) + expect(envOf(c, 'NO_PROXY')).toBe(config.noProxy) + expect(envOf(c, 'no_proxy')).toBe(config.noProxy) + expect(envOf(c, 'SSL_CERT_FILE')).toBe('/etc/squid-ca/squid-ca.pem') + expect(envOf(c, 'PIP_CERT')).toBe('/etc/squid-ca/squid-ca.pem') + + const vol = spec.volumes!.find(v => v.name === 'squid-ca') + expect(vol).toBeDefined() + expect(vol!.secret!.secretName).toBe('squid-ca-cert') + expect(vol!.secret!.items![0]).toEqual({ + key: 'squid-ca.pem', + path: 'squid-ca.pem' + }) + expect( + c.volumeMounts!.some( + m => + m.name === 'squid-ca' && m.mountPath === '/etc/squid-ca' && m.readOnly + ) + ).toBe(true) + expect(c.lifecycle!.postStart!.exec!.command![0]).toBe('/bin/sh') + // The trust hook must never be able to fail the container. + expect(c.lifecycle!.postStart!.exec!.command![2]).toContain('exit 0') + }) + + it('injects only the CA when the proxy is unhealthy (fail-open)', () => { + const spec = buildSpec() + const result = injectTransparentCache(spec, config, { + proxyHealthy: false + }) + expect(result.caInjected).toBe(true) + expect(result.proxyEnvInjected).toBe(false) + const c = spec.containers![0] + expect(envOf(c, 'HTTP_PROXY')).toBeUndefined() + expect(envOf(c, 'https_proxy')).toBeUndefined() + expect(envOf(c, 'SSL_CERT_FILE')).toBe('/etc/squid-ca/squid-ca.pem') + expect(spec.volumes!.some(v => v.name === 'squid-ca')).toBe(true) + }) + + it('never overrides env vars already set by the workflow or ops template', () => { + const spec = buildSpec() + spec.containers![0].env = [ + { name: 'HTTPS_PROXY', value: 'http://ops-proxy:1' }, + { name: 'SSL_CERT_FILE', value: '/custom/ca.pem' } + ] + injectTransparentCache(spec, config, { proxyHealthy: true }) + const c = spec.containers![0] + expect(envOf(c, 'HTTPS_PROXY')).toBe('http://ops-proxy:1') + expect(envOf(c, 'SSL_CERT_FILE')).toBe('/custom/ca.pem') + // missing siblings are still added + expect(envOf(c, 'HTTP_PROXY')).toBe(config.proxy) + expect(envOf(c, 'NO_PROXY')).toBe(config.noProxy) + }) + + it('keeps an existing postStart hook untouched', () => { + const spec = buildSpec() + spec.containers![0].lifecycle = { + postStart: { exec: { command: ['/bin/true'] } } + } + injectTransparentCache(spec, config, { proxyHealthy: true }) + expect(spec.containers![0].lifecycle!.postStart!.exec!.command).toEqual([ + '/bin/true' + ]) + }) + + it('skips the postStart hook when caTrustHook is disabled', () => { + const spec = buildSpec() + injectTransparentCache( + spec, + { ...config, caTrustHook: false }, + { + proxyHealthy: true + } + ) + expect(spec.containers![0].lifecycle).toBeUndefined() + expect(envOf(spec.containers![0], 'HTTPS_PROXY')).toBe(config.proxy) + }) + + it('does not duplicate the CA volume when one already exists', () => { + const spec = buildSpec() + spec.volumes = [{ name: 'squid-ca', emptyDir: {} }] + injectTransparentCache(spec, config, { proxyHealthy: true }) + expect(spec.volumes!.filter(v => v.name === 'squid-ca')).toHaveLength(1) + }) + + it('injects into every container (job + services)', () => { + const spec = buildSpec() + const svc = new k8s.V1Container() + svc.name = 'redis' + spec.containers!.push(svc) + injectTransparentCache(spec, config, { proxyHealthy: true }) + for (const c of spec.containers!) { + expect(envOf(c, 'HTTPS_PROXY')).toBe(config.proxy) + expect(c.volumeMounts!.some(m => m.name === 'squid-ca')).toBe(true) + } + }) + + it('is a no-op for a spec without containers', () => { + const spec = new k8s.V1PodSpec() + spec.containers = [] + const result = injectTransparentCache(spec, config, { + proxyHealthy: true + }) + expect(result.caInjected).toBe(false) + expect(result.proxyEnvInjected).toBe(false) + expect(spec.volumes).toBeUndefined() + }) +}) + +describe('isProxyHealthy / maybeInjectTransparentCache', () => { + it('reports a responding proxy as healthy', async () => { + const server = await ephemeralServer() + const port = (server.address() as net.AddressInfo).port + await expect( + isProxyHealthy(`http://127.0.0.1:${port}`, 1000) + ).resolves.toBe(true) + await server.close() + }) + + it('reports a dead port as unhealthy', async () => { + const port = await closedPort() + await expect(isProxyHealthy(`http://127.0.0.1:${port}`, 500)).resolves.toBe( + false + ) + }) + + it('caches the probe result within the TTL', async () => { + const server = await ephemeralServer() + const port = (server.address() as net.AddressInfo).port + const url = `http://127.0.0.1:${port}` + await expect(isProxyHealthy(url, 1000)).resolves.toBe(true) + // Server gone: the cached verdict must still answer "healthy". + await server.close() + await new Promise(resolve => setImmediate(resolve)) + await expect(isProxyHealthy(url, 500)).resolves.toBe(true) + }) + + it('leaves the spec untouched when the feature is disabled', async () => { + const spec = buildSpec() + const result = await maybeInjectTransparentCache(spec, {}) + expect(result).toBeUndefined() + expect(spec.containers![0].env).toBeUndefined() + expect(spec.volumes).toBeUndefined() + }) + + it('injects proxy env end-to-end against a live proxy', async () => { + const server = await ephemeralServer() + const port = (server.address() as net.AddressInfo).port + const spec = buildSpec() + const result = await maybeInjectTransparentCache( + spec, + enabledEnv(`http://127.0.0.1:${port}`) + ) + expect(result!.proxyEnvInjected).toBe(true) + expect(envOf(spec.containers![0], 'HTTPS_PROXY')).toBe( + `http://127.0.0.1:${port}` + ) + await server.close() + }) + + it('injects CA only when the proxy is dead (fail-open escape)', async () => { + const port = await closedPort() + const spec = buildSpec() + const result = await maybeInjectTransparentCache( + spec, + enabledEnv(`http://127.0.0.1:${port}`) + ) + expect(result!.caInjected).toBe(true) + expect(result!.proxyEnvInjected).toBe(false) + expect(envOf(spec.containers![0], 'HTTPS_PROXY')).toBeUndefined() + expect(envOf(spec.containers![0], 'SSL_CERT_FILE')).toBe( + '/etc/squid-ca/squid-ca.pem' + ) + }) + + it('injects nothing when the CA secret is missing (no FailedMount)', async () => { + const server = await ephemeralServer() + const port = (server.address() as net.AddressInfo).port + const spec = buildSpec() + const result = await maybeInjectTransparentCache( + spec, + enabledEnv(`http://127.0.0.1:${port}`), + { secretExists: async () => false } + ) + expect(result).toBeUndefined() + expect(spec.containers![0].env).toBeUndefined() + expect(spec.volumes).toBeUndefined() + await server.close() + }) + + it('injects fully when the CA secret exists and the proxy is healthy', async () => { + const server = await ephemeralServer() + const port = (server.address() as net.AddressInfo).port + const spec = buildSpec() + const result = await maybeInjectTransparentCache( + spec, + enabledEnv(`http://127.0.0.1:${port}`), + { secretExists: async () => true } + ) + expect(result!.proxyEnvInjected).toBe(true) + expect(result!.caInjected).toBe(true) + expect(spec.volumes!.some(v => v.name === 'squid-ca')).toBe(true) + await server.close() + }) + + it('treats a throwing secret check as missing', async () => { + const server = await ephemeralServer() + const port = (server.address() as net.AddressInfo).port + const spec = buildSpec() + const result = await maybeInjectTransparentCache( + spec, + enabledEnv(`http://127.0.0.1:${port}`), + { + secretExists: async () => { + throw new Error('rbac denied') + } + } + ) + expect(result).toBeUndefined() + expect(spec.volumes).toBeUndefined() + await server.close() + }) +})