diff --git a/backend/src/lib/agent.ts b/backend/src/lib/agent.ts index 753cb92d8e0..1715145daa2 100644 --- a/backend/src/lib/agent.ts +++ b/backend/src/lib/agent.ts @@ -1,7 +1,7 @@ /* Copyright Contributors to the Open Cluster Management project */ import type { AgentOptions } from 'node:https' import { Agent } from 'node:https' -import { getCACertificate, getServiceCACertificate } from './serviceAccountToken' +import { getCACertificate, getPlacementDebugCACertificate, getServiceCACertificate } from './serviceAccountToken' import { HttpsProxyAgent } from 'https-proxy-agent' const COMMON_AGENT_OPTIONS: Partial = { @@ -36,6 +36,23 @@ export function getServiceAgent() { return serviceAgent } +let placementDebugAgent: Agent | undefined +export function getPlacementDebugAgent(): Agent { + const ocmCA = getPlacementDebugCACertificate(() => { + placementDebugAgent = undefined + }) + + if (!ocmCA) return getServiceAgent() + + if (!placementDebugAgent) { + placementDebugAgent = new Agent({ + ca: ocmCA, + ...COMMON_AGENT_OPTIONS, + }) + } + return placementDebugAgent +} + let proxyAgent: HttpsProxyAgent export function getProxyAgent() { if (!proxyAgent && process.env.HTTPS_PROXY) { diff --git a/backend/src/lib/serviceAccountToken.ts b/backend/src/lib/serviceAccountToken.ts index a8312a867c2..19f47204589 100644 --- a/backend/src/lib/serviceAccountToken.ts +++ b/backend/src/lib/serviceAccountToken.ts @@ -101,6 +101,32 @@ export function getCACertificate(onChange?: () => void): Certificates { return ca_cert } +let placement_debug_ca: Certificates | undefined +export function getPlacementDebugCACertificate(onChange?: () => void): Certificates | undefined { + const caPath = process.env.PLACEMENT_CA_BUNDLE_PATH + if (!caPath) return undefined + + if (placement_debug_ca === undefined) { + if (process.env.NODE_ENV !== 'development') { + watchFile(caPath, () => { + placement_debug_ca = undefined + onChange?.() + }) + } + try { + placement_debug_ca = readFileSync(caPath, 'utf-8') + } catch (err) { + logger.warn({ + msg: 'PLACEMENT_CA_BUNDLE_PATH set but file not found', + path: caPath, + error: err instanceof Error ? err.message : String(err), + }) + return undefined + } + } + return placement_debug_ca +} + let service_ca_cert: Certificates export function getServiceCACertificate(onChange?: () => void): Certificates { if (service_ca_cert === undefined) { diff --git a/backend/src/routes/placementDebug.ts b/backend/src/routes/placementDebug.ts index 1c01e6fbc11..04ee2294334 100644 --- a/backend/src/routes/placementDebug.ts +++ b/backend/src/routes/placementDebug.ts @@ -4,7 +4,7 @@ import { constants } from 'node:http2' import type { RequestOptions } from 'node:https' import { request } from 'node:https' import { URL } from 'node:url' -import { getServiceAgent } from '../lib/agent' +import { getPlacementDebugAgent } from '../lib/agent' import { logger } from '../lib/logger' import { respondInternalServerError } from '../lib/respond' import { getAuthenticatedToken } from '../lib/token' @@ -74,7 +74,7 @@ export async function placementDebug(req: Http2ServerRequest, res: Http2ServerRe path: url.pathname, method: 'POST', headers, - agent: getServiceAgent(), + agent: getPlacementDebugAgent(), } const upstream = request(options, (response) => { diff --git a/backend/test/lib/agent.test.ts b/backend/test/lib/agent.test.ts new file mode 100644 index 00000000000..93ad8ba2e32 --- /dev/null +++ b/backend/test/lib/agent.test.ts @@ -0,0 +1,97 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +import { Agent } from 'node:https' + +jest.mock('../../src/lib/serviceAccountToken', () => ({ + getServiceCACertificate: jest.fn().mockReturnValue('service-ca-cert'), + getCACertificate: jest.fn().mockReturnValue('cluster-ca-cert'), + getPlacementDebugCACertificate: jest.fn().mockReturnValue(undefined), +})) + +import { getPlacementDebugCACertificate, getServiceCACertificate } from '../../src/lib/serviceAccountToken' + +const mockGetPlacementDebugCA = getPlacementDebugCACertificate as jest.MockedFunction< + typeof getPlacementDebugCACertificate +> +const mockGetServiceCA = getServiceCACertificate as jest.MockedFunction + +describe('getPlacementDebugAgent', () => { + let getPlacementDebugAgent: typeof import('../../src/lib/agent').getPlacementDebugAgent + let getServiceAgent: typeof import('../../src/lib/agent').getServiceAgent + + beforeEach(async () => { + jest.resetModules() + + // Re-apply mocks after module reset + jest.mock('../../src/lib/serviceAccountToken', () => ({ + getServiceCACertificate: mockGetServiceCA, + getCACertificate: jest.fn().mockReturnValue('cluster-ca-cert'), + getPlacementDebugCACertificate: mockGetPlacementDebugCA, + })) + + mockGetServiceCA.mockReturnValue('service-ca-cert') + mockGetPlacementDebugCA.mockReturnValue(undefined) + + const agentModule = await import('../../src/lib/agent') + getPlacementDebugAgent = agentModule.getPlacementDebugAgent + getServiceAgent = agentModule.getServiceAgent + }) + + it('falls back to service agent when PLACEMENT_CA_BUNDLE_PATH is not configured', () => { + mockGetPlacementDebugCA.mockReturnValue(undefined) + + const agent = getPlacementDebugAgent() + const serviceAgent = getServiceAgent() + + expect(agent).toBe(serviceAgent) + }) + + it('returns a dedicated agent when OCM CA is available', () => { + mockGetPlacementDebugCA.mockReturnValue('ocm-ca-bundle-cert') + + const agent = getPlacementDebugAgent() + const serviceAgent = getServiceAgent() + + expect(agent).toBeInstanceOf(Agent) + expect(agent).not.toBe(serviceAgent) + }) + + it('caches the placement debug agent across calls', () => { + mockGetPlacementDebugCA.mockReturnValue('ocm-ca-bundle-cert') + + const first = getPlacementDebugAgent() + const second = getPlacementDebugAgent() + + expect(first).toBe(second) + }) + + it('invalidates cached agent when OCM CA changes', () => { + let onChangeCallback: (() => void) | undefined + mockGetPlacementDebugCA.mockImplementation((onChange?: () => void) => { + onChangeCallback = onChange + return 'ocm-ca-bundle-cert' + }) + + const first = getPlacementDebugAgent() + expect(first).toBeInstanceOf(Agent) + expect(onChangeCallback).toBeDefined() + + // Simulate cert rotation + if (onChangeCallback) onChangeCallback() + + const second = getPlacementDebugAgent() + expect(second).toBeInstanceOf(Agent) + expect(second).not.toBe(first) + }) + + it('transitions from service agent to dedicated agent when CA becomes available', () => { + mockGetPlacementDebugCA.mockReturnValue(undefined) + const withoutCA = getPlacementDebugAgent() + expect(withoutCA).toBe(getServiceAgent()) + + mockGetPlacementDebugCA.mockReturnValue('ocm-ca-bundle-cert') + const withCA = getPlacementDebugAgent() + expect(withCA).not.toBe(withoutCA) + expect(withCA).toBeInstanceOf(Agent) + }) +}) diff --git a/backend/test/lib/serviceAccountToken.test.ts b/backend/test/lib/serviceAccountToken.test.ts new file mode 100644 index 00000000000..7f4e144d3b6 --- /dev/null +++ b/backend/test/lib/serviceAccountToken.test.ts @@ -0,0 +1,126 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +const mockReadFileSync = jest.fn() +const mockWatchFile = jest.fn() + +describe('getPlacementDebugCACertificate', () => { + const originalNodeEnv = process.env.NODE_ENV + + beforeEach(() => { + jest.resetModules() + mockReadFileSync.mockReset() + mockWatchFile.mockReset() + delete process.env.PLACEMENT_CA_BUNDLE_PATH + process.env.NODE_ENV = 'test' + }) + + afterEach(() => { + delete process.env.PLACEMENT_CA_BUNDLE_PATH + process.env.NODE_ENV = originalNodeEnv + }) + + async function loadFn() { + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + readFileSync: mockReadFileSync, + })) + jest.doMock('../../src/lib/fileWatch', () => ({ + watchFile: mockWatchFile, + stopFileWatches: jest.fn(), + })) + const mod = await import('../../src/lib/serviceAccountToken') + return mod.getPlacementDebugCACertificate + } + + it('returns undefined when PLACEMENT_CA_BUNDLE_PATH is not set', async () => { + const getPlacementDebugCACertificate = await loadFn() + const result = getPlacementDebugCACertificate() + expect(result).toBeUndefined() + expect(mockReadFileSync).not.toHaveBeenCalled() + }) + + it('reads the CA file from the configured path', async () => { + process.env.PLACEMENT_CA_BUNDLE_PATH = '/var/run/secrets/ocm-ca/ca-bundle.crt' + const fakeCert = '-----BEGIN CERTIFICATE-----\nfake-ca-cert\n-----END CERTIFICATE-----' + mockReadFileSync.mockReturnValue(fakeCert) + + const getPlacementDebugCACertificate = await loadFn() + const result = getPlacementDebugCACertificate() + + expect(result).toEqual(fakeCert) + expect(mockReadFileSync).toHaveBeenCalledWith('/var/run/secrets/ocm-ca/ca-bundle.crt', 'utf-8') + }) + + it('returns undefined and does not throw when file does not exist', async () => { + process.env.PLACEMENT_CA_BUNDLE_PATH = '/nonexistent/ca-bundle.crt' + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT') + }) + + const getPlacementDebugCACertificate = await loadFn() + const result = getPlacementDebugCACertificate() + + expect(result).toBeUndefined() + }) + + it('caches the certificate on subsequent calls', async () => { + process.env.PLACEMENT_CA_BUNDLE_PATH = '/var/run/secrets/ocm-ca/ca-bundle.crt' + const fakeCert = '-----BEGIN CERTIFICATE-----\ncached\n-----END CERTIFICATE-----' + mockReadFileSync.mockReturnValue(fakeCert) + + const getPlacementDebugCACertificate = await loadFn() + const first = getPlacementDebugCACertificate() + const second = getPlacementDebugCACertificate() + + expect(first).toEqual(fakeCert) + expect(second).toEqual(fakeCert) + expect(mockReadFileSync).toHaveBeenCalledTimes(1) + }) + + it('invalidates cache when onChange callback fires', async () => { + process.env.PLACEMENT_CA_BUNDLE_PATH = '/var/run/secrets/ocm-ca/ca-bundle.crt' + process.env.NODE_ENV = 'production' + const originalCert = '-----BEGIN CERTIFICATE-----\noriginal\n-----END CERTIFICATE-----' + const rotatedCert = '-----BEGIN CERTIFICATE-----\nrotated\n-----END CERTIFICATE-----' + mockReadFileSync.mockReturnValueOnce(originalCert).mockReturnValueOnce(rotatedCert) + + const getPlacementDebugCACertificate = await loadFn() + const first = getPlacementDebugCACertificate() + expect(first).toEqual(originalCert) + + expect(mockWatchFile).toHaveBeenCalledWith('/var/run/secrets/ocm-ca/ca-bundle.crt', expect.any(Function)) + const watchCall = mockWatchFile.mock.calls[0] as [string, () => void] + const watchCallback = watchCall[1] + watchCallback() + + const second = getPlacementDebugCACertificate() + expect(second).toEqual(rotatedCert) + expect(mockReadFileSync).toHaveBeenCalledTimes(2) + }) + + it('invokes the caller onChange callback on file change', async () => { + process.env.PLACEMENT_CA_BUNDLE_PATH = '/var/run/secrets/ocm-ca/ca-bundle.crt' + process.env.NODE_ENV = 'production' + mockReadFileSync.mockReturnValue('cert-data') + + const getPlacementDebugCACertificate = await loadFn() + const callerOnChange = jest.fn() + getPlacementDebugCACertificate(callerOnChange) + + const watchCall = mockWatchFile.mock.calls[0] as [string, () => void] + watchCall[1]() + + expect(callerOnChange).toHaveBeenCalledTimes(1) + }) + + it('does not set up file watching in development mode', async () => { + process.env.PLACEMENT_CA_BUNDLE_PATH = '/var/run/secrets/ocm-ca/ca-bundle.crt' + process.env.NODE_ENV = 'development' + mockReadFileSync.mockReturnValue('cert-data') + + const getPlacementDebugCACertificate = await loadFn() + getPlacementDebugCACertificate() + + expect(mockWatchFile).not.toHaveBeenCalled() + }) +})