Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion backend/src/lib/agent.ts
Original file line number Diff line number Diff line change
@@ -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<AgentOptions> = {
Expand Down Expand Up @@ -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<string>
export function getProxyAgent() {
if (!proxyAgent && process.env.HTTPS_PROXY) {
Expand Down
26 changes: 26 additions & 0 deletions backend/src/lib/serviceAccountToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions backend/src/routes/placementDebug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => {
Expand Down
97 changes: 97 additions & 0 deletions backend/test/lib/agent.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getServiceCACertificate>

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)
})
})
126 changes: 126 additions & 0 deletions backend/test/lib/serviceAccountToken.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:fs')>('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()
})
})