Skip to content
Merged
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
2 changes: 1 addition & 1 deletion containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ COPY server.js logging.js metrics.js rate-limiter.js rate-limiter-window.js \
websocket-guards.js websocket-tunnel.js \
deprecated-header-tracker.js billing-headers.js upstream-response.js \
upstream-log.js upstream-retry.js upstream-token.js \
anthropic-cache.js otel.js otel-exporters.js otel-serialization.js \
anthropic-cache.js otel.js otel-exporters.js otel-serialization.js otel-workload-identity.js \
token-budget-log.js blocked-request-diagnostics.js \
provider-env-constants.js provider-env-constants.json provider-names.js \
model-api-mapping.js model-api-mapping.json ./
Expand Down
73 changes: 40 additions & 33 deletions containers/api-proxy/otel-exporters.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ class ProxyAwareOtlpExporter {
* @param {Record<string,string>} opts.headers - Extra request headers (auth etc.)
* @param {string|null} opts.httpsProxy - Squid proxy URL, or falsy to connect directly
* @param {import('@opentelemetry/resources').Resource} opts.resource
* @param {{getHeaders: () => Promise<Record<string, string>>}|null} [opts.headerProvider]
*/
constructor({ url, headers, httpsProxy, resource }) {
constructor({ url, headers, httpsProxy, resource, headerProvider = null }) {
const parsed = new URL(url);
const trimmedPath = parsed.pathname.replace(/\/+$/, '');
if (trimmedPath === '' || trimmedPath === '/') {
Expand All @@ -42,6 +43,7 @@ class ProxyAwareOtlpExporter {
this._headers = headers || {};
this._agent = httpsProxy ? new HttpsProxyAgent(httpsProxy) : undefined;
this._resource = resource;
this._headerProvider = headerProvider;
}

/**
Expand Down Expand Up @@ -73,39 +75,44 @@ class ProxyAwareOtlpExporter {
? parseInt(this._parsedUrl.port, 10)
: (isHttps ? 443 : 80);

const reqOptions = {
hostname: this._parsedUrl.hostname,
port,
path: `${this._parsedUrl.pathname}${this._parsedUrl.search || ''}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': bodyBuf.length,
...this._headers,
},
};
if (this._agent) reqOptions.agent = this._agent;

let req;
try {
req = Transport.request(reqOptions, (res) => {
res.on('data', () => {});
res.on('error', (err) => { settle({ code: 1, error: err }); });
res.on('end', () => {
const ok = res.statusCode >= 200 && res.statusCode < 300;
settle({ code: ok ? 0 : 1 });
Promise.resolve(this._headerProvider ? this._headerProvider.getHeaders() : {})
.then((dynamicHeaders) => {
const reqOptions = {
hostname: this._parsedUrl.hostname,
port,
path: `${this._parsedUrl.pathname}${this._parsedUrl.search || ''}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': bodyBuf.length,
...this._headers,
...dynamicHeaders,
},
};
if (this._agent) reqOptions.agent = this._agent;

let req;
try {
req = Transport.request(reqOptions, (res) => {
res.on('data', () => {});
res.on('error', (err) => { settle({ code: 1, error: err }); });
res.on('end', () => {
const ok = res.statusCode >= 200 && res.statusCode < 300;
settle({ code: ok ? 0 : 1 });
});
});
} catch (err) {
settle({ code: 1, error: err });
return;
}
req.setTimeout(EXPORT_TIMEOUT_MS, () => {
req.destroy(new Error(`OTLP export timeout after ${EXPORT_TIMEOUT_MS}ms`));
});
});
} catch (err) {
settle({ code: 1, error: err });
return;
}
req.setTimeout(EXPORT_TIMEOUT_MS, () => {
req.destroy(new Error(`OTLP export timeout after ${EXPORT_TIMEOUT_MS}ms`));
});
req.on('error', (err) => { settle({ code: 1, error: err }); });
req.write(bodyBuf);
req.end();
req.on('error', (err) => { settle({ code: 1, error: err }); });
req.write(bodyBuf);
req.end();
})
.catch((err) => settle({ code: 1, error: err }));
}

shutdown() { return Promise.resolve(); }
Expand Down
44 changes: 44 additions & 0 deletions containers/api-proxy/otel-fanout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@
const { FanOutSpanExporter } = require('./otel-exporters');
const { loadOtelModule } = require('./test-helpers/otel-test-utils');

const mockWorkloadIdentity = {
matchesEndpoint: jest.fn(endpoint => endpoint === 'https://google.example.com:4318'),
getHeaders: jest.fn().mockResolvedValue({ Authorization: 'Bearer exchanged-token' }),
shutdown: jest.fn(),
};

jest.mock('./otel-workload-identity', () => ({
createOtlpWorkloadIdentity: jest.fn(rawConfig => rawConfig ? mockWorkloadIdentity : null),
}));

// ── FanOutSpanExporter unit tests ─────────────────────────────────────────────

describe('FanOutSpanExporter', () => {
Expand Down Expand Up @@ -222,6 +232,40 @@ describe('otel fan-out initialization', () => {
expect(otel.isEnabled()).toBe(true);
});

test('attaches workload identity only to its configured fan-out endpoint', () => {
const endpoints = [
{ url: 'https://google.example.com:4318' },
{ url: 'https://other.example.com:4318', headers: { Authorization: 'Other secret' } },
];
const otel = loadOtelFresh({
GH_AW_OTLP_ENDPOINTS: JSON.stringify(endpoints),
GH_AW_OTLP_WORKLOAD_IDENTITY: JSON.stringify({
provider: 'gcp',
audience: 'projects/123/providers/github',
endpoint: endpoints[0].url,
}),
});
const processor = otel._provider.activeSpanProcessor._spanProcessors[0];
const exporters = processor._exporter._exporters;

expect(exporters[0]._headerProvider).toBe(mockWorkloadIdentity);
expect(exporters[1]._headerProvider).toBeNull();
expect(exporters[1]._headers).toEqual({ Authorization: 'Other secret' });
});

test('fails closed when workload identity matches no configured endpoint', () => {
expect(() => loadOtelFresh({
GH_AW_OTLP_ENDPOINTS: JSON.stringify([
{ url: 'https://other.example.com:4318' },
]),
GH_AW_OTLP_WORKLOAD_IDENTITY: JSON.stringify({
provider: 'gcp',
audience: 'projects/123/providers/github',
endpoint: 'https://google.example.com:4318',
}),
})).toThrow('does not match any configured OTLP endpoint');
});

test('uses FileSpanExporter when no OTLP config at all', () => {
const otel = loadOtelFresh({});
expect(otel.isEnabled()).toBe(true);
Expand Down
82 changes: 82 additions & 0 deletions containers/api-proxy/otel-workload-identity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use strict';

const { GcpOidcTokenProvider } = require('./gcp-oidc-token-provider');

function normalizeEndpoint(rawEndpoint) {
let endpoint;
try {
endpoint = new URL(rawEndpoint);
} catch {
throw new Error('OTLP workload identity requires a valid HTTPS endpoint');
}
if (endpoint.protocol !== 'https:') {
throw new Error('OTLP workload identity requires a valid HTTPS endpoint');
}
endpoint.hash = '';
endpoint.pathname = endpoint.pathname.replace(/\/+$/, '') || '/';
return endpoint.toString();
}

/**
* Creates an OTLP Authorization header provider from the workflow-generated
* workload identity configuration. The GitHub Actions runtime credentials stay
* inside the api-proxy sidecar; only the exchanged token reaches the collector.
*
* @param {string} rawConfig
* @returns {{getHeaders: () => Promise<Record<string, string>>, shutdown: () => void}|null}
*/
function createOtlpWorkloadIdentity(rawConfig) {
if (!rawConfig) return null;

let config;
try {
config = JSON.parse(rawConfig);
} catch {
throw new Error('OTLP workload identity configuration must be valid JSON');
}

if (!config || typeof config !== 'object'
|| !['gcp', 'google'].includes(config.provider)
|| typeof config.audience !== 'string' || !config.audience.trim()
|| typeof config.endpoint !== 'string' || !config.endpoint.trim()) {
throw new Error('OTLP workload identity requires provider "gcp", a non-empty audience, and an HTTPS endpoint');
}
const endpoint = normalizeEndpoint(config.endpoint.trim());
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL || !process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) {
throw new Error('OTLP workload identity requires GitHub Actions OIDC runtime credentials');
}

const provider = new GcpOidcTokenProvider({
requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
workloadIdentityProvider: config.audience.trim(),
oidcAudience: config.audience.trim(),
serviceAccount: typeof config['service-account'] === 'string'
? config['service-account'].trim() || undefined
: undefined,
});
const initialized = provider.initialize();

return {
matchesEndpoint(candidate) {
try {
return normalizeEndpoint(candidate) === endpoint;
} catch {
return false;
}
},
async getHeaders() {
await initialized;
const token = provider.getToken();
if (!token) {
throw new Error('OTLP workload identity token is unavailable');
}
return { Authorization: 'Bearer ' + token };
},
shutdown() {
provider.shutdown();
},
};
}

module.exports = { createOtlpWorkloadIdentity, normalizeEndpoint };
96 changes: 96 additions & 0 deletions containers/api-proxy/otel-workload-identity.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use strict';

const mockInitialize = jest.fn().mockResolvedValue(undefined);
const mockGetToken = jest.fn().mockReturnValue('exchanged-token');
const mockShutdown = jest.fn();
const mockGcpOidcTokenProvider = jest.fn().mockImplementation(() => ({
initialize: mockInitialize,
getToken: mockGetToken,
shutdown: mockShutdown,
}));

jest.mock('./gcp-oidc-token-provider', () => ({ GcpOidcTokenProvider: mockGcpOidcTokenProvider }));

const { createOtlpWorkloadIdentity } = require('./otel-workload-identity');

describe('createOtlpWorkloadIdentity', () => {
const savedEnv = {
requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
};

beforeEach(() => {
jest.clearAllMocks();
mockInitialize.mockResolvedValue(undefined);
mockGetToken.mockReturnValue('exchanged-token');
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://oidc.example/token';
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runtime-token';
});

afterAll(() => {
if (savedEnv.requestUrl === undefined) delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
else process.env.ACTIONS_ID_TOKEN_REQUEST_URL = savedEnv.requestUrl;
if (savedEnv.requestToken === undefined) delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
else process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = savedEnv.requestToken;
});

it('uses GCP WIF and service account impersonation for OTLP authorization', async () => {
const identity = createOtlpWorkloadIdentity(JSON.stringify({
provider: 'gcp',
audience: 'projects/123/locations/global/workloadIdentityPools/pool/providers/github',
'service-account': 'telemetry@example.iam.gserviceaccount.com',
endpoint: 'https://telemetry.googleapis.com/',
}));

expect(mockGcpOidcTokenProvider).toHaveBeenCalledWith({
requestUrl: 'https://oidc.example/token',
requestToken: 'runtime-token',
workloadIdentityProvider: 'projects/123/locations/global/workloadIdentityPools/pool/providers/github',
oidcAudience: 'projects/123/locations/global/workloadIdentityPools/pool/providers/github',
serviceAccount: 'telemetry@example.iam.gserviceaccount.com',
});
expect(await identity.getHeaders()).toEqual({ Authorization: 'Bearer ' + 'exchanged-token' });
expect(identity.matchesEndpoint('https://telemetry.googleapis.com')).toBe(true);
expect(identity.matchesEndpoint('https://other.example.com')).toBe(false);
identity.shutdown();
expect(mockShutdown).toHaveBeenCalled();
});

it.each([
'not-json',
JSON.stringify({ provider: 'azure', audience: 'audience' }),
JSON.stringify({ provider: 'gcp' }),
JSON.stringify({
provider: 'gcp',
audience: 'projects/123/providers/github',
endpoint: 'http://telemetry.googleapis.com',
}),
])('fails closed for invalid workload identity config: %s', (config) => {
expect(() => createOtlpWorkloadIdentity(config)).toThrow('OTLP workload identity');
expect(mockGcpOidcTokenProvider).not.toHaveBeenCalled();
});

it('fails export authorization when the exchange does not yield a token', async () => {
mockGetToken.mockReturnValue(null);
const identity = createOtlpWorkloadIdentity(JSON.stringify({
provider: 'google',
audience: 'projects/123/locations/global/workloadIdentityPools/pool/providers/github',
endpoint: 'https://telemetry.googleapis.com',
}));

await expect(identity.getHeaders()).rejects.toThrow('OTLP workload identity token is unavailable');
identity.shutdown();
});

it('normalizes only insignificant trailing slashes when matching endpoints', () => {
const identity = createOtlpWorkloadIdentity(JSON.stringify({
provider: 'gcp',
audience: 'projects/123/locations/global/workloadIdentityPools/pool/providers/github',
endpoint: 'https://collector.example.com/custom/',
}));

expect(identity.matchesEndpoint('https://collector.example.com/custom')).toBe(true);
expect(identity.matchesEndpoint('https://collector.example.com/other')).toBe(false);
expect(identity.matchesEndpoint('not-a-url')).toBe(false);
});
});
Loading
Loading