diff --git a/CHANGELOG.md b/CHANGELOG.md index 72acf229..f1235630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). - Support for OpenTelemetry SDK 2.0 - `@opentelemetry/instrumentation-undici` added to the list of default instrumentations - Support for `@sap/cds^10` +- Support for `telemetry-to-caas` kind for CaaS (Collector as a Service) ### Changed diff --git a/README.md b/README.md index 6843b802..d1ba0669 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Documentation can be found at [cap.cloud.sap](https://cap.cloud.sap/docs) and [o - [`telemetry-to-console`](#telemetry-to-console) - [`telemetry-to-dynatrace`](#telemetry-to-dynatrace) - [`telemetry-to-cloud-logging`](#telemetry-to-cloud-logging) + - [`telemetry-to-caas`](#telemetry-to-caas) - [`telemetry-to-jaeger`](#telemetry-to-jaeger) - [`telemetry-to-otlp`](#telemetry-to-otlp) - [Detailed Configuration Options](#detailed-configuration-options) @@ -200,7 +201,7 @@ Please note that in order for logs to be exported via OpenTelemetry, `cds.log()` ## Predefined Kinds -There are five predefined kinds as follows: +There are six predefined kinds as follows: ### `telemetry-to-console` @@ -283,12 +284,52 @@ In order to receive OpenTelemetry credentials in the binding to the SAP Cloud Lo If you are binding your app to SAP Cloud Logging via a [user-provided service instance](https://docs.cloudfoundry.org/devguide/services/user-provided.html), make sure that it has the tag `Cloud Logging`. -> Tip: To add the required tag to an existing user-provided service, you can use: +> Tip: To add the required tag to an existing user-provided service, you can use: > ``` > cf update-user-provided-service {service-name} -t "Cloud Logging" > ``` > For detailed information about binding resolution in CAP, consult [`cds.connect()` → Service Bindings](https://cap.cloud.sap/docs/node.js/cds-connect#service-bindings). +### `telemetry-to-caas` + +Exports traces and metrics to CaaS (Collector as a Service). +CaaS acts as a managed OpenTelemetry Collector that can route telemetry data to downstream backends like SAP Cloud Logging. + +Use via `cds.requires.telemetry.kind = 'to-caas'`. + +Required additional dependencies: +- `@opentelemetry/exporter-trace-otlp-proto` +- `@opentelemetry/exporter-metrics-otlp-proto` + +CaaS requires mTLS authentication with SAP-signed certificates. You need to: + +1. **Bind the CaaS service** to your app with subject/issuer configuration: +```yaml +# mta.yaml +requires: + - name: my-caas-instance + parameters: + config: + subject: "CN=my-app,..." + issuer: "CN=SAP PKI Certificate Service Client CA,..." +``` + +2. **Provide mTLS credentials** via environment variables (base64 encoded): +```yaml +# mta.yaml +properties: + CDS_REQUIRES_TELEMETRY_X509_CERT: '' + CDS_REQUIRES_TELEMETRY_X509_KEY: '' +``` + +Or set directly via Cloud Foundry CLI: +```bash +cf set-env my-app CDS_REQUIRES_TELEMETRY_X509_CERT "" +cf set-env my-app CDS_REQUIRES_TELEMETRY_X509_KEY "" +``` + +The mTLS certificate must be SAP-signed through the BTP Certificate Service. Certificates can be created with validity from 7 days up to 1 year and must be renewed before expiration. For detailed certificate setup and renewal instructions, refer to your project's certificate management documentation. + ### `telemetry-to-jaeger` Exports traces to Jaeger. diff --git a/cds-plugin.js b/cds-plugin.js index a59ca32b..1bf0bce5 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -12,5 +12,8 @@ if (!!process.env.NO_TELEMETRY && process.env.NO_TELEMETRY !== 'false') return - require('./lib')() + // Setup is async to support ZTI initialization (waits for SVID files) + require('./lib')().catch(err => { + cds.log('telemetry')._error && cds.log('telemetry').error('Telemetry setup failed:', err) + }) })() diff --git a/lib/index.js b/lib/index.js index 18a7b3cc..5fb12467 100644 --- a/lib/index.js +++ b/lib/index.js @@ -13,7 +13,7 @@ const { registerInstrumentations } = require('@opentelemetry/instrumentation') const tracing = require('./tracing') const metrics = require('./metrics') const logging = require('./logging') -const { getDiagLogLevel, getResource, hasDependency, _require } = require('./utils') +const { getDiagLogLevel, getResource, hasDependency, initializeZTI, _require } = require('./utils') function _getInstrumentations() { const _instrumentations = cds.env.requires.telemetry.instrumentations @@ -89,14 +89,30 @@ function _getInstrumentations() { return instrumentations } -function setup_standalone() { +async function setup_standalone() { // set logger and propagate log level diag.setLogger(cds.log('telemetry'), getDiagLogLevel()) // create resource const resource = getResource() - // setup tracing, metrics, and logging + // IMPORTANT: Set up CDS method wrapping BEFORE async ZTI wait + // This ensures incoming requests are traced even while waiting for SVID files + // The tracer is lazy-initialized on first use (see trace.js _getTracer) + require('./tracing/cds')() + require('./tracing/cloud_sdk')() + + // Initialize ZTI if configured (blocks until SVID files ready) + // Must complete BEFORE creating exporters so credentials are available + try { + await initializeZTI() + } catch (err) { + LOG._error && LOG.error('Failed to initialize ZTI for CaaS mTLS:', err) + // Don't throw - let telemetry continue without ZTI + // The warning will be logged when augmentCaaSCreds() is called + } + + // setup tracing, metrics, and logging (exporter/processor creation) const tracerProvider = tracing(resource) const meterProvider = metrics(resource) const loggerProvider = cds.env.requires.telemetry.logging ? logging(resource) : undefined @@ -110,7 +126,14 @@ function setup_standalone() { }) } -function setup_with_calm() { +async function setup_with_calm() { + // Initialize ZTI if configured - must complete before creating exporters + try { + await initializeZTI() + } catch (err) { + LOG._error && LOG.error('Failed to initialize ZTI for CaaS mTLS:', err) + } + // setup tracing, metrics, and logging tracing() metrics() diff --git a/lib/logging/index.js b/lib/logging/index.js index 08840201..6aa7fae4 100644 --- a/lib/logging/index.js +++ b/lib/logging/index.js @@ -3,7 +3,7 @@ const LOG = cds.log('telemetry') const { getStringFromEnv } = require('@opentelemetry/core') -const { getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils') +const { getCredsForCLSAsUPS, augmentCLCreds, augmentCaaSCreds, _require } = require('../utils') const _protocol2module = { grpc: '@opentelemetry/exporter-logs-otlp-grpc', @@ -33,7 +33,16 @@ function _getExporter() { } // use _require for better error message - const loggingExporterModule = _require(loggingExporter.module) + let loggingExporterModule + try { + loggingExporterModule = _require(loggingExporter.module) + } catch (err) { + if (err.code === 'MODULE_NOT_FOUND') { + LOG._warn && LOG.warn(`Logs exporter module '${loggingExporter.module}' not found. Logging telemetry will be disabled.`) + return null + } + throw err + } if (!loggingExporterModule[loggingExporter.class]) throw new Error(`Unknown logs exporter "${loggingExporter.class}" in module "${loggingExporter.module}"`) const config = { ...(loggingExporter.config || {}) } @@ -46,6 +55,16 @@ function _getExporter() { config.credentials ??= credentials.credentials } + if (kind.match(/to-caas$/)) { + if (!credentials) throw new Error('No CaaS credentials found.') + augmentCaaSCreds(credentials) + // Append /v1/logs to base URL (OTLP exporter expects full URL when config.url is provided) + config.url = credentials.baseUrl + '/v1/logs' + if (credentials.httpAgentOptions) { + config.httpAgentOptions = credentials.httpAgentOptions + } + } + const exporter = new loggingExporterModule[loggingExporter.class](config) LOG._debug && LOG.debug('Using logs exporter:', exporter) @@ -131,6 +150,10 @@ module.exports = resource => { * create processor */ const exporter = _getExporter() + if (!exporter) { + LOG._info && LOG.info('Logging telemetry is disabled due to missing exporter module') + return null + } const processor = _getCustomProcessor(exporter) || (process.env.NODE_ENV === 'production' diff --git a/lib/metrics/index.js b/lib/metrics/index.js index bd9be759..4cb85dbf 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -6,7 +6,7 @@ const { getStringFromEnv } = require('@opentelemetry/core') const { resourceFromAttributes } = require('@opentelemetry/resources') const { AggregationTemporality, MeterProvider, PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics') -const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils') +const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, augmentCaaSCreds, _require } = require('../utils') const _protocol2module = { grpc: '@opentelemetry/exporter-metrics-otlp-grpc', @@ -74,6 +74,16 @@ function _getExporter() { config.credentials ??= credentials.credentials } + if (kind.match(/to-caas$/)) { + if (!credentials) throw new Error('No CaaS credentials found.') + augmentCaaSCreds(credentials) + // Append /v1/metrics to base URL (OTLP exporter expects full URL when config.url is provided) + config.url = credentials.baseUrl + '/v1/metrics' + if (credentials.httpAgentOptions) { + config.httpAgentOptions = credentials.httpAgentOptions + } + } + const exporter = new metricsExporterModule[metricsExporter.class](config) LOG._debug && LOG.debug('Using metrics exporter:', exporter) return exporter diff --git a/lib/tracing/cds.js b/lib/tracing/cds.js index c6766273..c1fe5f2d 100644 --- a/lib/tracing/cds.js +++ b/lib/tracing/cds.js @@ -29,18 +29,26 @@ const _wrapStmt = (stmt, impl, sql, dbc) => { } module.exports = () => { + LOG._debug && LOG.debug('CDS method wrapping initialized') const { _tx, _hana_prom, _wrap_hana } = cds.env.requires.telemetry.tracing const _TX = { BEGIN: 1, COMMIT: 1, ROLLBACK: 1 } const { emit: _emit, handle: _handle } = cds.Service.prototype + LOG._debug && LOG.debug('CDS wrapping: captured emit and handle from prototype', { + hasEmit: typeof _emit === 'function', + hasHandle: typeof _handle === 'function' + }) + cds.Service.prototype.emit = wrap(_emit, { wrapper: function emit() { + LOG._debug && LOG.debug('CDS wrapped emit() called', { event: arguments[0]?.event || arguments[0], service: this?.name }) const event = arguments[0]?.event || arguments[0] return trace({ phase: 'emit', event }, _emit, this, arguments, {}) } }) cds.Service.prototype.handle = wrap(_handle, { wrapper: function handle(req) { + LOG._debug && LOG.debug('CDS wrapped handle() called', { event: req?.event, service: this?.name }) if (!_tx && req.event in _TX) return _handle.apply(this, arguments) return trace(req, _handle, this, arguments, {}) } diff --git a/lib/tracing/index.js b/lib/tracing/index.js index 8a887c3a..82bb203d 100644 --- a/lib/tracing/index.js +++ b/lib/tracing/index.js @@ -11,6 +11,7 @@ const { getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, + augmentCaaSCreds, hasDependency, _require } = require('../utils') @@ -125,6 +126,16 @@ function _getExporter() { config.credentials ??= credentials.credentials } + if (kind.match(/to-caas$/)) { + if (!credentials) throw new Error('No CaaS credentials found.') + augmentCaaSCreds(credentials) + // Append /v1/traces to base URL (OTLP exporter expects full URL when config.url is provided) + config.url = credentials.baseUrl + '/v1/traces' + if (credentials.httpAgentOptions) { + config.httpAgentOptions = credentials.httpAgentOptions + } + } + const exporter = new tracingExporterModule[tracingExporter.class](config) LOG._debug && LOG.debug('Using trace exporter:', exporter) @@ -143,14 +154,10 @@ module.exports = resource => { }) } - /* - * add tracing - */ - require('./cds')() - require('./cloud_sdk')() - /* * create processor + * NOTE: CDS method wrapping is now done earlier in lib/index.js (before ZTI wait) + * to ensure requests are traced even during async initialization */ let processor const via_one_agent = diff --git a/lib/tracing/trace.js b/lib/tracing/trace.js index dc9d70db..2b87570f 100644 --- a/lib/tracing/trace.js +++ b/lib/tracing/trace.js @@ -50,21 +50,56 @@ const $adjusted = Symbol('@cap-js/telemetry:adjusted') const $reqattrs = Symbol('@cap-js/telemetry:reqattrs') let tracer -cds.on('served', () => { - tracer = otel.trace.getTracer('@cap-js/telemetry', require('../../package.json').version) - - if (HRTIME) { - // monkey patch startActiveSpan to set a hr time as startTime (if none was provided) - const { startActiveSpan } = tracer - tracer.startActiveSpan = function (name, options, fn) { - options.startTime ??= _hrnow() - return startActiveSpan.call(this, name, options, fn) +let _hrtimePatched = false + +// Lazy-initialize tracer on first use - this avoids race condition with async ZTI init +// The tracer is obtained from the global TracerProvider, which is registered by tracing/index.js +// IMPORTANT: Don't cache until a real TracerProvider is registered, otherwise we cache a NoopTracer +function _getTracer() { + // Check if a real TracerProvider is registered (not the default NoopTracerProvider) + // The global trace API returns a ProxyTracerProvider that delegates to the registered provider + const registeredProvider = otel.trace.getTracerProvider() + const delegate = registeredProvider?.getDelegate?.() + const isNoopProvider = !registeredProvider || + registeredProvider.constructor.name === 'ProxyTracerProvider' && + delegate?.constructor.name === 'NoopTracerProvider' + + LOG._debug && LOG.debug('_getTracer() check:', { + providerName: registeredProvider?.constructor?.name, + delegateName: delegate?.constructor?.name, + isNoopProvider, + tracerCached: !!tracer + }) + + if (isNoopProvider) { + // TracerProvider not yet registered - don't cache, return null to skip tracing + return null + } + + if (!tracer) { + tracer = otel.trace.getTracer('@cap-js/telemetry', require('../../package.json').version) + LOG._debug && LOG.debug('_getTracer() created new tracer') + + // Apply hrtime patches once when tracer is first obtained + if (HRTIME && !_hrtimePatched) { + _hrtimePatched = true + // monkey patch startActiveSpan to set a hr time as startTime (if none was provided) + const { startActiveSpan } = tracer + tracer.startActiveSpan = function (name, options, fn) { + options.startTime ??= _hrnow() + return startActiveSpan.call(this, name, options, fn) + } } + } + return tracer +} - // attach a hr time to incoming requests for later adjustment of the root span start time +// Register listening handler for hrtime - this still needs 'served' but it's just for the optional hrtime feature +if (HRTIME) { + cds.on('served', () => { cds.on('listening', ({ server }) => server.on('request', req => (req[$hrnow] = _hrnow()))) - } -}) + }) +} // REVISIT: needs a cleanup function _getSpanName(arg, fn, that) { @@ -241,8 +276,18 @@ const _addDbRowCount = (span, res) => { } function trace(req, fn, that, args, opts = {}) { - // only trace once served and there is a cds.context - if (!tracer || !cds.context) return fn.apply(that, args) + // only trace if there is a cds.context + if (!cds.context) { + LOG._debug && LOG.debug('trace() skipped: no cds.context') + return fn.apply(that, args) + } + + // Lazy-init tracer on first use (avoids race with async ZTI init) + const currentTracer = _getTracer() + if (!currentTracer) { + LOG._debug && LOG.debug('trace() skipped: no tracer (NoopProvider still active)') + return fn.apply(that, args) + } const parent = otel.trace.getActiveSpan() @@ -326,7 +371,7 @@ function trace(req, fn, that, args, opts = {}) { if (name.length > 80 && _truncate_span_name !== false) name = name.substring(0, 79) + '…' // start a new active span, call the original function, and finally end the span - return tracer.startActiveSpan(name, options, span => { + return currentTracer.startActiveSpan(name, options, span => { // in case the span is non-recording, just call the original function if (span.constructor.name === 'NonRecordingSpan') return fn.apply(that, args) diff --git a/lib/utils.js b/lib/utils.js index f72c814e..da280586 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -137,6 +137,270 @@ function getCredsForCLSAsUPS() { } } +/** + * Manages ZTI SVID file loading with mtime-based caching + * Based on official ZTI blueprint: https://github.tools.sap/pse/blueprints + */ +class SVIDFileSource { + constructor(certPath, keyPath, bundlePath) { + this.certPath = certPath + this.keyPath = keyPath + this.bundlePath = bundlePath + this._cached = null + this._cachedMod = null + } + + /** + * Get certificate credentials, reloading if mtime changed + * @returns {{ cert: string, key: string, bundle: string }} + */ + getCertificate() { + let stat + try { + stat = fs.statSync(this.certPath) + } catch (e) { + // Transient stat failure during atomic rename - serve last-known-good + if (this._cached) { + LOG._debug && LOG.debug('ZTI: Transient stat failure, serving cached credentials') + return this._cached + } + throw e + } + + const mtime = stat.mtimeMs + if (this._cached && this._cachedMod === mtime) { + // Cache hit - no rotation since last check + return this._cached + } + + // mtime changed or first load - reload all files atomically + LOG._debug && LOG.debug('ZTI: Loading SVID files (mtime changed or first load)') + try { + const certPEM = fs.readFileSync(this.certPath, 'utf8') + const keyPEM = fs.readFileSync(this.keyPath, 'utf8') + const bundlePEM = fs.readFileSync(this.bundlePath, 'utf8') + + this._cached = { cert: certPEM, key: keyPEM, bundle: bundlePEM } + this._cachedMod = mtime + + LOG._info && LOG.info('ZTI: Loaded SVID certificate', { + certPath: this.certPath, + mtime: new Date(mtime).toISOString() + }) + + return this._cached + } catch (err) { + // Read failure after successful stat - likely mid-rotation + if (this._cached) { + LOG._warn && LOG.warn('ZTI: Failed to reload SVID files, serving cached', err) + return this._cached + } + throw err + } + } +} + +/** + * Detect ZTI binding from VCAP_SERVICES + * @returns {{ svidDir: string, svidName: string } | null} + */ +function getZTIConfig() { + if (!process.env.VCAP_SERVICES) return null + + const vcap = JSON.parse(process.env.VCAP_SERVICES) + const zti = vcap['zero-trust-identity'] + if (!zti || zti.length === 0) return null + + // Get SVID name from binding parameters + const svidName = zti[0].credentials?.parameters?.['svid-store']?.file?.name + if (!svidName) { + LOG._warn && LOG.warn('ZTI: zero-trust-identity binding found but svid-store.file.name not configured') + return null + } + + // Native CF apps have fixed SVID directory + const svidDir = process.env.CDS_REQUIRES_TELEMETRY_ZTI_DIR || '/home/vcap/app/spire-svids' + + return { svidDir, svidName } +} + +/** + * Create SVID file source with blocking retry until files exist + * Follows ZTI blueprint pattern: block startup until SVID files ready + * @param {string} svidDir - Directory containing SVID files + * @param {string} svidName - SVID file base name + * @returns {Promise} + */ +async function createSVIDFileSource(svidDir, svidName) { + const RETRY_INTERVAL_MS = 2000 + const MAX_RETRIES = 30 // 60 seconds total (30 * 2s) + + const source = new SVIDFileSource( + `${svidDir}/${svidName}.svid.pem`, + `${svidDir}/${svidName}.svid.key`, + `${svidDir}/${svidName}.bundle.pem` + ) + + let attempt = 0 + while (true) { + try { + // Try to load - will throw if files don't exist + source.getCertificate() + LOG._info && LOG.info(`ZTI: SVID files ready at ${svidDir}/${svidName}`) + return source + } catch (err) { + attempt++ + if (attempt >= MAX_RETRIES) { + throw new Error( + `ZTI: SVID files not available after ${MAX_RETRIES} attempts (${(MAX_RETRIES * RETRY_INTERVAL_MS) / 1000}s). ` + + `Expected files at ${svidDir}/${svidName}.svid.{pem,key}. ` + + `Error: ${err.message}`, + { cause: err } + ) + } + + LOG._debug && LOG.debug( + `ZTI: SVID files not ready yet (attempt ${attempt}/${MAX_RETRIES}), ` + + `retrying in ${RETRY_INTERVAL_MS}ms...` + ) + + await new Promise(resolve => setTimeout(resolve, RETRY_INTERVAL_MS)) + } + } +} + +// Global ZTI source instance (created once, reused across requests) +let _ztiSource = null +let _ztiInitPromise = null + +/** + * Initialize ZTI source (async) - called during telemetry plugin initialization + * @returns {Promise} + */ +async function initializeZTI() { + const useZTI = process.env.CDS_REQUIRES_TELEMETRY_USE_ZTI !== 'false' + if (!useZTI) return + + const ztiConfig = getZTIConfig() + if (!ztiConfig) return + + // Avoid double initialization + if (_ztiInitPromise) return _ztiInitPromise + + _ztiInitPromise = (async () => { + try { + LOG._info && LOG.info('ZTI: Initializing SVID file source for CaaS mTLS...') + _ztiSource = await createSVIDFileSource(ztiConfig.svidDir, ztiConfig.svidName) + } catch (err) { + LOG._error && LOG.error('ZTI: Failed to initialize SVID file source:', err) + _ztiInitPromise = null // Allow retry + throw err + } + })() + + return _ztiInitPromise +} + +/** + * Get mTLS credentials for CaaS telemetry + * Priority: + * 1. ZTI file-based (default, automatic rotation) - if USE_ZTI=true (default) + * 2. Env var base64 (legacy, manual rotation) - if USE_ZTI=false + * @returns {{ cert: string, key: string } | null} + */ +function getCredsForCaaSMtls() { + // Check flag: default to ZTI (only disable if explicitly set to 'false') + const useZTI = process.env.CDS_REQUIRES_TELEMETRY_USE_ZTI !== 'false' + + if (useZTI) { + // ZTI approach (default) + const ztiConfig = getZTIConfig() + if (ztiConfig) { + // ZTI binding found - use it + if (_ztiSource) { + // ZTI initialized - get cached or reloaded credentials + try { + const { cert, key } = _ztiSource.getCertificate() + return { cert, key } + } catch (err) { + LOG._error && LOG.error('ZTI: Failed to load SVID credentials:', err) + return null + } + } else { + // ZTI not yet initialized - warn and fall through + LOG._warn && LOG.warn( + 'ZTI: SVID file source not initialized. Ensure initializeZTI() is called during startup.' + ) + return null + } + } + // No ZTI binding - fall through to legacy check + } + + // Legacy env var approach (USE_ZTI=false or no ZTI binding) + const { x509 } = cds.env.requires.telemetry || {} + if (x509 && x509.cert && x509.key) { + LOG._debug && LOG.debug('Using legacy base64 env var credentials for CaaS mTLS') + return { + cert: x509.cert, + key: x509.key + } + } + + return null +} + +function augmentCaaSCreds(credentials) { + if (credentials._augmented) return + credentials._augmented = true + + // check for otlp http endpoint + if (!credentials.otlp?.http) { + throw new Error('No OTLP HTTP endpoint found in CaaS credentials. Make sure the CaaS instance is properly configured.') + } + + // Store the base URL - path will be added per signal type (traces: /v1/traces, metrics: /v1/metrics) + credentials.baseUrl = credentials.otlp.http + + // Check for mTLS credentials (cert + key) - either from ZTI or legacy env vars + const mtlsCreds = getCredsForCaaSMtls() + if (mtlsCreds) { + try { + let cert, key + + // Check if legacy base64 format (starts with base64 characters, not PEM header) + if (mtlsCreds.cert.startsWith('LS0t') || !mtlsCreds.cert.startsWith('-----BEGIN')) { + // Legacy: base64-encoded + cert = Buffer.from(mtlsCreds.cert, 'base64').toString('utf-8') + key = Buffer.from(mtlsCreds.key, 'base64').toString('utf-8') + } else { + // ZTI: already PEM format + cert = mtlsCreds.cert + key = mtlsCreds.key + } + + // Store the mTLS options for the exporter's httpAgentOptions + // The OTLP HTTP exporter will create an https.Agent with these options + credentials.httpAgentOptions = { + cert: cert, + key: key, + keepAlive: true + } + + LOG._debug && LOG.debug('CaaS mTLS configured successfully') + } catch (err) { + LOG._error && LOG.error('Failed to configure CaaS mTLS:', err.message) + } + } else { + LOG._warn && LOG.warn( + 'CaaS requires mTLS authentication. No mTLS credentials found. ' + + 'Either bind zero-trust-identity service (recommended) or set ' + + 'CDS_REQUIRES_TELEMETRY_X509_CERT and CDS_REQUIRES_TELEMETRY_X509_KEY ' + + 'environment variables (base64 encoded).' + ) + } +} + function augmentCLCreds(credentials) { if (credentials._augmented) return credentials._augmented = true @@ -203,7 +467,13 @@ module.exports = { getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, + augmentCaaSCreds, + initializeZTI, + getZTIConfig, + getCredsForCaaSMtls, hasDependency, _hrnow, - _require + _require, + // Exported for testing + _resetZTIState: () => { _ztiSource = null; _ztiInitPromise = null } } diff --git a/package.json b/package.json index 08d883a4..98d52596 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,30 @@ "metrics": { "exporter": "env" } + }, + "telemetry-to-caas": { + "vcap": { + "label": "caas-service" + }, + "mtls_service_pattern": "caas-mtls|caas-cert", + "tracing": { + "exporter": { + "module": "@opentelemetry/exporter-trace-otlp-proto", + "class": "OTLPTraceExporter" + } + }, + "metrics": { + "exporter": { + "module": "@opentelemetry/exporter-metrics-otlp-proto", + "class": "OTLPMetricExporter" + } + }, + "logging": { + "exporter": { + "module": "@opentelemetry/exporter-logs-otlp-proto", + "class": "OTLPLogExporter" + } + } } } } diff --git a/test/caas.test.js b/test/caas.test.js new file mode 100644 index 00000000..f25cc4e3 --- /dev/null +++ b/test/caas.test.js @@ -0,0 +1,338 @@ +const cds = require('@sap/cds') +const fs = require('fs') +const os = require('os') +const path = require('path') + +// Mock VCAP_SERVICES for CaaS +const MOCK_CAAS_VCAP = { + 'caas-service': [{ + name: 'test-caas', + credentials: { + otlp: { + http: 'https://caas.example.com/otlp', + grpc: 'grpc://caas.example.com:4317' + } + } + }] +} + +// Mock VCAP_SERVICES with ZTI binding +const MOCK_ZTI_VCAP = { + 'caas-service': [{ + name: 'test-caas', + credentials: { + otlp: { + http: 'https://caas.example.com/otlp' + } + } + }], + 'zero-trust-identity': [{ + name: 'test-zti', + credentials: { + parameters: { + 'svid-store': { + file: { name: 'test-svid' } + } + } + } + }] +} + +describe('augmentCaaSCreds', () => { + let originalVcap + + beforeAll(() => { + originalVcap = process.env.VCAP_SERVICES + }) + + afterAll(() => { + if (originalVcap) process.env.VCAP_SERVICES = originalVcap + else delete process.env.VCAP_SERVICES + }) + + beforeEach(() => { + cds.env.requires = cds.env.requires || {} + cds.env.requires.telemetry = { + x509: { + cert: Buffer.from('-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----').toString('base64'), + key: Buffer.from('-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----').toString('base64') + } + } + delete require.cache[require.resolve('../lib/utils')] + }) + + test('sets baseUrl from otlp.http', () => { + process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP) + delete require.cache[require.resolve('../lib/utils')] + const { augmentCaaSCreds } = require('../lib/utils') + + const credentials = { + otlp: { + http: 'https://caas.example.com/otlp', + grpc: 'grpc://caas.example.com:4317' + } + } + + augmentCaaSCreds(credentials) + + expect(credentials.baseUrl).toBe('https://caas.example.com/otlp') + }) + + test('sets httpAgentOptions when mTLS credentials found', () => { + process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP) + delete require.cache[require.resolve('../lib/utils')] + const { augmentCaaSCreds } = require('../lib/utils') + + const credentials = { + otlp: { http: 'https://caas.example.com/otlp' } + } + + augmentCaaSCreds(credentials) + + expect(credentials.httpAgentOptions).toBeDefined() + expect(credentials.httpAgentOptions.cert).toContain('BEGIN CERTIFICATE') + expect(credentials.httpAgentOptions.key).toContain('BEGIN PRIVATE KEY') + expect(credentials.httpAgentOptions.keepAlive).toBe(true) + }) + + test('throws when no OTLP endpoints', () => { + process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP) + delete require.cache[require.resolve('../lib/utils')] + const { augmentCaaSCreds } = require('../lib/utils') + + expect(() => augmentCaaSCreds({})).toThrow('No OTLP HTTP endpoint found') + }) + + test('does not augment twice', () => { + process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP) + delete require.cache[require.resolve('../lib/utils')] + const { augmentCaaSCreds } = require('../lib/utils') + + const credentials = { + otlp: { http: 'https://caas.example.com/otlp' } + } + + augmentCaaSCreds(credentials) + const originalBaseUrl = credentials.baseUrl + + credentials.otlp.http = 'https://different.com' + augmentCaaSCreds(credentials) + + expect(credentials.baseUrl).toBe(originalBaseUrl) + }) + + test('no httpAgentOptions when mTLS credentials not found', () => { + cds.env.requires.telemetry = {} // No x509 credentials + process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP) + delete require.cache[require.resolve('../lib/utils')] + const { augmentCaaSCreds } = require('../lib/utils') + + const credentials = { + otlp: { http: 'https://caas.example.com/otlp' } + } + + augmentCaaSCreds(credentials) + + expect(credentials.httpAgentOptions).toBeUndefined() + }) +}) + +describe('ZTI SVID File Loading', () => { + let tmpDir + let svidDir + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zti-test-')) + svidDir = path.join(tmpDir, 'spire-svids') + fs.mkdirSync(svidDir) + + // Set up ZTI environment + process.env.VCAP_SERVICES = JSON.stringify(MOCK_ZTI_VCAP) + process.env.CDS_REQUIRES_TELEMETRY_ZTI_DIR = svidDir + delete process.env.CDS_REQUIRES_TELEMETRY_USE_ZTI + + cds.env.requires = cds.env.requires || {} + cds.env.requires.telemetry = {} + delete require.cache[require.resolve('../lib/utils')] + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + delete process.env.VCAP_SERVICES + delete process.env.CDS_REQUIRES_TELEMETRY_ZTI_DIR + delete process.env.CDS_REQUIRES_TELEMETRY_USE_ZTI + }) + + test('loads SVID files on first call', async () => { + // Create SVID files + fs.writeFileSync(path.join(svidDir, 'test-svid.svid.pem'), '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----') + fs.writeFileSync(path.join(svidDir, 'test-svid.svid.key'), '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----') + fs.writeFileSync(path.join(svidDir, 'test-svid.bundle.pem'), '-----BEGIN CERTIFICATE-----\nbundle\n-----END CERTIFICATE-----') + + const { initializeZTI, getCredsForCaaSMtls, _resetZTIState } = require('../lib/utils') + _resetZTIState() + + await initializeZTI() + const creds = getCredsForCaaSMtls() + + expect(creds).toBeDefined() + expect(creds.cert).toContain('BEGIN CERTIFICATE') + expect(creds.key).toContain('BEGIN PRIVATE KEY') + }) + + test('reloads when mtime changes', async () => { + const certPath = path.join(svidDir, 'test-svid.svid.pem') + const keyPath = path.join(svidDir, 'test-svid.svid.key') + const bundlePath = path.join(svidDir, 'test-svid.bundle.pem') + + // Write initial files + fs.writeFileSync(certPath, '-----BEGIN CERTIFICATE-----\nv1\n-----END CERTIFICATE-----') + fs.writeFileSync(keyPath, '-----BEGIN PRIVATE KEY-----\nv1\n-----END PRIVATE KEY-----') + fs.writeFileSync(bundlePath, '-----BEGIN CERTIFICATE-----\nv1\n-----END CERTIFICATE-----') + + const { initializeZTI, getCredsForCaaSMtls, _resetZTIState } = require('../lib/utils') + _resetZTIState() + + await initializeZTI() + const creds1 = getCredsForCaaSMtls() + expect(creds1.cert).toContain('v1') + + // Wait to ensure mtime changes + await new Promise(resolve => setTimeout(resolve, 10)) + + // Update files (simulating ZTI rotation) + fs.writeFileSync(certPath, '-----BEGIN CERTIFICATE-----\nv2\n-----END CERTIFICATE-----') + fs.writeFileSync(keyPath, '-----BEGIN PRIVATE KEY-----\nv2\n-----END PRIVATE KEY-----') + + const creds2 = getCredsForCaaSMtls() + expect(creds2.cert).toContain('v2') + expect(creds2.key).toContain('v2') + }) + + test('retries if files not ready on initialization', async () => { + const certPath = path.join(svidDir, 'test-svid.svid.pem') + const keyPath = path.join(svidDir, 'test-svid.svid.key') + const bundlePath = path.join(svidDir, 'test-svid.bundle.pem') + + const { initializeZTI, _resetZTIState } = require('../lib/utils') + _resetZTIState() + + // Start initialization (files don't exist yet) + const initPromise = initializeZTI() + + // Wait 2.5 seconds, then create files (should succeed on 2nd retry) + await new Promise(resolve => setTimeout(resolve, 2500)) + fs.writeFileSync(certPath, '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----') + fs.writeFileSync(keyPath, '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----') + fs.writeFileSync(bundlePath, '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----') + + // Should complete successfully + await expect(initPromise).resolves.toBeUndefined() + }, 10000) +}) + +describe('ZTI flag behavior', () => { + beforeEach(() => { + cds.env.requires = { telemetry: {} } + delete process.env.CDS_REQUIRES_TELEMETRY_USE_ZTI + delete require.cache[require.resolve('../lib/utils')] + }) + + afterEach(() => { + delete process.env.CDS_REQUIRES_TELEMETRY_USE_ZTI + delete process.env.VCAP_SERVICES + }) + + test('detects ZTI config from VCAP_SERVICES', () => { + process.env.VCAP_SERVICES = JSON.stringify(MOCK_ZTI_VCAP) + delete require.cache[require.resolve('../lib/utils')] + + const { getZTIConfig } = require('../lib/utils') + const config = getZTIConfig() + + expect(config).not.toBeNull() + expect(config.svidName).toBe('test-svid') + expect(config.svidDir).toBe('/home/vcap/app/spire-svids') + }) + + test('returns null when no ZTI binding', () => { + process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP) + delete require.cache[require.resolve('../lib/utils')] + + const { getZTIConfig } = require('../lib/utils') + const config = getZTIConfig() + + expect(config).toBeNull() + }) + + test('uses legacy env vars when USE_ZTI=false', () => { + process.env.CDS_REQUIRES_TELEMETRY_USE_ZTI = 'false' + process.env.VCAP_SERVICES = JSON.stringify(MOCK_ZTI_VCAP) + cds.env.requires.telemetry.x509 = { + cert: Buffer.from('-----BEGIN CERTIFICATE-----\nlegacy\n-----END CERTIFICATE-----').toString('base64'), + key: Buffer.from('-----BEGIN PRIVATE KEY-----\nlegacy\n-----END PRIVATE KEY-----').toString('base64') + } + delete require.cache[require.resolve('../lib/utils')] + + const { getCredsForCaaSMtls } = require('../lib/utils') + const creds = getCredsForCaaSMtls() + + expect(creds).toBeDefined() + // Legacy credentials are still base64 at this point + expect(Buffer.from(creds.cert, 'base64').toString()).toContain('legacy') + }) + + test('augmentCaaSCreds handles PEM format from ZTI', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zti-test-')) + const svidDir = path.join(tmpDir, 'spire-svids') + fs.mkdirSync(svidDir) + + process.env.VCAP_SERVICES = JSON.stringify(MOCK_ZTI_VCAP) + process.env.CDS_REQUIRES_TELEMETRY_ZTI_DIR = svidDir + delete require.cache[require.resolve('../lib/utils')] + + // Create SVID files + fs.writeFileSync(path.join(svidDir, 'test-svid.svid.pem'), '-----BEGIN CERTIFICATE-----\nzti-cert\n-----END CERTIFICATE-----') + fs.writeFileSync(path.join(svidDir, 'test-svid.svid.key'), '-----BEGIN PRIVATE KEY-----\nzti-key\n-----END PRIVATE KEY-----') + fs.writeFileSync(path.join(svidDir, 'test-svid.bundle.pem'), '-----BEGIN CERTIFICATE-----\nbundle\n-----END CERTIFICATE-----') + + const { initializeZTI, augmentCaaSCreds, _resetZTIState } = require('../lib/utils') + _resetZTIState() + + await initializeZTI() + + const credentials = { + otlp: { http: 'https://caas.example.com/otlp' } + } + augmentCaaSCreds(credentials) + + expect(credentials.httpAgentOptions).toBeDefined() + expect(credentials.httpAgentOptions.cert).toContain('zti-cert') + expect(credentials.httpAgentOptions.key).toContain('zti-key') + + // Cleanup + delete process.env.CDS_REQUIRES_TELEMETRY_ZTI_DIR + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test('augmentCaaSCreds handles base64 format from legacy', () => { + process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP) + process.env.CDS_REQUIRES_TELEMETRY_USE_ZTI = 'false' + cds.env.requires.telemetry.x509 = { + cert: Buffer.from('-----BEGIN CERTIFICATE-----\nlegacy-cert\n-----END CERTIFICATE-----').toString('base64'), + key: Buffer.from('-----BEGIN PRIVATE KEY-----\nlegacy-key\n-----END PRIVATE KEY-----').toString('base64') + } + delete require.cache[require.resolve('../lib/utils')] + + const { augmentCaaSCreds } = require('../lib/utils') + + const credentials = { + otlp: { http: 'https://caas.example.com/otlp' } + } + augmentCaaSCreds(credentials) + + expect(credentials.httpAgentOptions).toBeDefined() + expect(credentials.httpAgentOptions.cert).toContain('legacy-cert') + expect(credentials.httpAgentOptions.key).toContain('legacy-key') + }) +})