diff --git a/cf-java-logging-support-opentelemetry-agent-extension/README.md b/cf-java-logging-support-opentelemetry-agent-extension/README.md index 4c0ce2e8..0f7b8ef9 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/README.md +++ b/cf-java-logging-support-opentelemetry-agent-extension/README.md @@ -1,7 +1,7 @@ # OpenTelemetry Java Agent Extension for SAP BTP Observability This module provides an extension for the [OpenTelemetry Java Agent](https://opentelemetry.io/docs/instrumentation/java/automatic/). -The extension scans the service bindings of an application for SAP Collector as a Service (CaaS), [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) and [Dynatrace](https://docs.dynatrace.com/docs/setup-and-configuration/setup-on-container-platforms/cloud-foundry/deploy-oneagent-on-sap-cloud-platform-for-application-only-monitoring). +The extension scans the service bindings of an application for a generic OTel Collector, SAP Collector as a Service (CaaS), [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) and [Dynatrace](https://docs.dynatrace.com/docs/setup-and-configuration/setup-on-container-platforms/cloud-foundry/deploy-oneagent-on-sap-cloud-platform-for-application-only-monitoring). If such a binding is found, the OpenTelemetry Java Agent is configured to ship observability data to those services. Thus, this extension provides a convenient auto-instrumentation for Java applications running on SAP BTP. @@ -9,7 +9,8 @@ Thus, this extension provides a convenient auto-instrumentation for Java applica The extension provides the following main features: -* auto-configuration of the generic OpenTelemetry OTLP exporter to SAP Collector as a Service (CaaS) or [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) +* auto-configuration of the generic OpenTelemetry OTLP exporter to any OTel Collector service binding (identified by instance name), SAP Collector as a Service (CaaS) or [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) +* optional mTLS using client certificate and key read directly from the OTel Collector service binding credentials * additional exporters for logs, metrics and traces for [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) * additional exporter for metrics for [Dynatrace](https://docs.dynatrace.com/docs/setup-and-configuration/setup-on-container-platforms/cloud-foundry/deploy-oneagent-on-sap-cloud-platform-for-application-only-monitoring) * adding resource attributes describing the CF application @@ -44,8 +45,9 @@ See the [example manifest](../sample-spring-boot/manifest-otel-javaagent.yml), h Once the agent is attached to the JVM with the extension in place, the default `otlp` exporter is automatically configured based on available service bindings: -1. **CaaS Service Binding** (preferred): If a CaaS service binding is found, the `otlp` exporter sends data to the CaaS endpoint. -2. **Cloud Logging Service Binding** (fallback): If no CaaS binding exists, the `otlp` exporter sends data to Cloud Logging. +1. **Generic OTel Collector Binding** (highest priority, explicit opt-in): If `sap.otel.collector.cf.binding.name` is configured, the extension locates that CF service instance by name and uses it as the OTel Collector endpoint. Optionally uses mTLS credentials (`tls.crt`, `tls.key`) read directly from the binding. See [Using a Generic OTel Collector Service Binding](#using-a-generic-otel-collector-service-binding). +2. **CaaS Service Binding** (automatic, preferred): If no explicit binding name is set and a CaaS service binding is found, the `otlp` exporter sends data to the CaaS endpoint. +3. **Cloud Logging Service Binding** (automatic, fallback): If neither of the above applies, the `otlp` exporter sends data to Cloud Logging. This means **metrics and traces are automatically exported** without additional configuration when either service is bound. The recommended way to export data to Cloud Logging and Dynatrace is to use the provided exporters explicitly. @@ -92,23 +94,25 @@ There is no custom network client provided by this extension. The extension itself can be configured by specifying the following system properties: -| Property | Description | Default Value | -|--------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|-----------------| -| `sap.caas.cf.binding.label.value` | The label of the managed CaaS service binding to bind to. | `caas-service` | -| `sap.cloud-logging.cf.binding.label.value` | The label of the managed service binding to bind to. | `cloud-logging` | -| `sap.cloud-logging.cf.binding.tag.value` | The tag of any service binding (managed or user-provided) to bind to. | `Cloud Logging` | -| `sap.dynatrace.cf.binding.label.value` | The label of the managed service binding to bind to. | `dynatrace` | -| `sap.dynatrace.cf.binding.tag.value` | The tag of any service binding (managed or user-provided) to bind to. | `dynatrace` | -| `sap.dynatrace.cf.binding.token.name` | The name of the field containing the Dynatrace API token within the service binding credentials. This is required to send metrics to Dynatrace. | | -| `sap.cloudfoundry.otel.resources.enabled` | Whether to add CF resource attributes to all events. | `true` | -| `sap.cloudfoundry.otel.resources.format` | The semantic convention to follow for the CF resource attributes. Supported values are `SAP` and `OTEL`. | `SAP` | +| Property | Description | Default Value | +|-------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------| +| `sap.otel.collector.cf.binding.name` | The name of a CF service instance (managed or user-provided) to use as the **Generic OTel Collector** endpoint. When set, this binding takes priority over CaaS and Cloud Logging auto-detection. See [Using a Generic OTel Collector](#using-a-generic-otel-collector-service-binding). | *(absent)* | +| `sap.caas.cf.binding.label.value` | The label of the managed CaaS service binding to bind to. | `caas-service` | +| `sap.cloud-logging.cf.binding.label.value` | The label of the managed service binding to bind to. | `cloud-logging` | +| `sap.cloud-logging.cf.binding.tag.value` | The tag of any service binding (managed or user-provided) to bind to. | `Cloud Logging` | +| `sap.dynatrace.cf.binding.label.value` | The label of the managed service binding to bind to. | `dynatrace` | +| `sap.dynatrace.cf.binding.tag.value` | The tag of any service binding (managed or user-provided) to bind to. | `dynatrace` | +| `sap.dynatrace.cf.binding.token.name` | The name of the field containing the Dynatrace API token within the service binding credentials. This is required to send metrics to Dynatrace. | | +| `sap.cloudfoundry.otel.resources.enabled` | Whether to add CF resource attributes to all events. | `true` | +| `sap.cloudfoundry.otel.resources.format` | The semantic convention to follow for the CF resource attributes. Supported values are `SAP` and `OTEL`. | `SAP` | > Each property can also be provided as environment variable, e.g., `sap.cloud-logging.cf.binding.label.value` as `SAP.CLOUD-LOGGING.CF.BINDING.LABEL.VALUE`. The extension scans the `VCAP_SERVICES` environment variable for CF service bindings in the following order: -1. **CaaS bindings**: Searches for bindings matching the configured label (`sap.caas.cf.binding.label.value`, default: `caas-service`) -2. **Cloud Logging bindings**: If no CaaS binding is found, searches for bindings matching the configured label and tag (`sap.cloud-logging.cf.binding.label.value` and `sap.cloud-logging.cf.binding.tag.value`) +1. **Generic OTel Collector binding** (only when `sap.otel.collector.cf.binding.name` is configured): Finds the service instance by that exact name. Optionally reads mTLS credentials (`tls.crt`, `tls.key`) and a server CA (`tls.ca.crt`) directly from the binding credentials. +2. **CaaS bindings**: Searches for bindings matching the configured label (`sap.caas.cf.binding.label.value`, default: `caas-service`) +3. **Cloud Logging bindings**: If no CaaS binding is found, searches for bindings matching the configured label and tag (`sap.cloud-logging.cf.binding.label.value` and `sap.cloud-logging.cf.binding.tag.value`) User-provided bindings take precedence over managed bindings. The first matching binding configures the default OpenTelemetry `otlp` exporter. @@ -202,6 +206,63 @@ The following table summarizes all configuration properties provided by the exte | `sap.dynatrace.cf.binding.label.value` | The label value used to identify managed Dynatrace service bindings. | `dynatrace` | | `sap.dynatrace.cf.binding.tag.value` | The tag value used to identify managed Dynatrace service bindings. | `dynatrace` | | `sap.dynatrace.cf.binding.token.name` | The name of the field containing the Dynatrace API token within the service binding credentials. | | +| `sap.otel.collector.cf.binding.name` | CF service instance name to use as a Generic OTel Collector. When absent, this feature is disabled and the existing CaaS / Cloud Logging auto-detection is unaffected. | | + +## Using a Generic OTel Collector Service Binding + +_This feature was introduced with version 4.4.0 of the extension._ + +The extension supports sending observability data to **any OTel Collector** service binding — whether a managed service or a user-provided service — by specifying the CF service instance name via the `sap.otel.collector.cf.binding.name` property. +This is referred to as the **Generic OTel Collector** path to distinguish it from the CaaS-specific auto-detection. + +When `sap.otel.collector.cf.binding.name` is set, the extension: +1. Locates the CF service instance with that exact name in `VCAP_SERVICES`. +2. Reads the `url` (or `http-url`) credential field as the OTLP endpoint. +3. If `tls.crt` and `tls.key` credential fields are present, uses them for mTLS (client certificate and private key in PEM format). If `tls.ca.crt` is also present, uses it as the server CA certificate. +4. Reads the optional `token` credential field and sends it as a `Bearer` authorization header. This can be combined with mTLS. + +### Minimal setup (URL + token) + +Create a user-provided service with at least a `url` field and optionally a `token`: + +```bash +cf cups my-otel-collector -p '{"url":"https://my-otel-collector.example.com","token":""}' +``` + +Configure the extension to use it: + +```sh +-Dsap.otel.collector.cf.binding.name=my-otel-collector +``` + +### Setup with mTLS from binding credentials + +Place the PEM-encoded client certificate, private key, and optionally the server CA certificate directly in the user-provided service credentials: + +```bash +cf cups my-otel-collector -p '{ + "url": "https://my-otel-collector.example.com", + "tls.crt": "", + "tls.key": "", + "tls.ca.crt": "" +}' +``` + +Configure the extension to use it: + +```sh +-Dsap.otel.collector.cf.binding.name=my-otel-collector +``` + +The required credential fields for mTLS are: + +| Field name | Contents | Required | +|-------------|-------------------------------------------------------|----------| +| `url` | OTLP endpoint URL (e.g. `https://collector.example.com`). | Yes | +| `tls.crt` | PEM-encoded mTLS client certificate. | For mTLS | +| `tls.key` | PEM-encoded mTLS private key. | For mTLS | +| `tls.ca.crt`| PEM-encoded server CA certificate. | Optional | +| `token` | Bearer token sent as `Authorization: Bearer `. Can be combined with mTLS. | Optional | ## Using User-Provided Service Instances diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/CloudLoggingConfigurationCustomizerProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/CloudLoggingConfigurationCustomizerProvider.java index eae8e1e4..5a153c29 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/CloudLoggingConfigurationCustomizerProvider.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/CloudLoggingConfigurationCustomizerProvider.java @@ -3,6 +3,7 @@ import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CaasBindingPropertiesSupplier; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudLoggingBindingPropertiesSupplier; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.DefaultOtelBackendPropertiesSupplier; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.OtelCollectorBindingPropertiesSupplier; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.SanitizeSpanExporterCustomizer; import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; @@ -18,6 +19,7 @@ public class CloudLoggingConfigurationCustomizerProvider implements AutoConfigur private static DefaultOtelBackendPropertiesSupplier getDefaultOtelBackendPropertiesSupplier() { return builder() // + .add(new OtelCollectorBindingPropertiesSupplier()) // no-op unless sap.otel.collector.cf.binding.name is set .add(new CaasBindingPropertiesSupplier()) // this has priority .add(new CloudLoggingBindingPropertiesSupplier()) // look for Cloud Logging as fallback and backward compatibility .build(); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplier.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplier.java index 370cafb7..42568562 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplier.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplier.java @@ -5,7 +5,6 @@ import io.opentelemetry.common.ComponentLoader; import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; -import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.Map; @@ -28,6 +27,9 @@ public class CaasBindingPropertiesSupplier implements Supplier properties) { properties.put("otel.exporter.otlp.compression", "gzip"); } + /** + * Reads the CaaS service instance credentials from VCAP_SERVICES and returns the OpenTelemetry OTLP exporter + * configuration properties for mTLS. When client credentials or a server certificate are unavailable, returns + * basic endpoint properties without TLS. + * + * @return The pre-configured connection properties for the OpenTelemetry SDK, or an empty map when no usable + * CaaS service instance is found. + */ @Override public Map get() { CloudFoundryServiceInstance serviceInstance = serviceProvider.get(); @@ -78,19 +88,17 @@ public Map get() { String clientCert = credentials.getString(CAAS_CLIENT_CERT); String clientKey = credentials.getString(CAAS_CLIENT_KEY); - if (clientCert != null && clientKey != null) { + if (clientCert != null && !clientCert.isBlank() && clientKey != null && !clientKey.isBlank()) { try { String serverCert = serverCertificateDownloader.download(endpointUrl); if (serverCert == null || serverCert.isBlank()) { return properties; } - File serverCertFile = pemFileCreator.writeFile("caas-server-cert-", ".crt", serverCert); - File clientCertFile = pemFileCreator.writeFile("caas-client-cert-", ".crt", clientCert); - File clientKeyFile = pemFileCreator.writeFile("caas-client-key-", ".key", clientKey); - - properties.put("otel.exporter.otlp.certificate", serverCertFile.getAbsolutePath()); - properties.put("otel.exporter.otlp.client.certificate", clientCertFile.getAbsolutePath()); - properties.put("otel.exporter.otlp.client.key", clientKeyFile.getAbsolutePath()); + MtlsClientCertHelper.applyTlsProperties( + new TlsCertificates(clientCert, clientKey, serverCert), + pemFileCreator, + "caas-client-cert-", "caas-client-key-", "caas-server-cert-", + properties); } catch (IOException e) { LOG.warning( diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapter.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapter.java index e5b8b503..e6c2ec78 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapter.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapter.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; import java.util.logging.Logger; @@ -84,6 +85,39 @@ Stream stream(List serviceLabels, List findByName(String instanceName) { + if (instanceName == null || instanceName.isBlank()) { + return Optional.empty(); + } + if (vcapServicesJson == null || vcapServicesJson.isBlank()) { + return Optional.empty(); + } + try (JsonParser parser = new JsonFactory().createParser(vcapServicesJson)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + return Optional.empty(); + } + while (parser.nextToken() != JsonToken.END_OBJECT) { + String label = parser.currentName(); + parser.nextToken(); // START_ARRAY + if (parser.currentToken() != JsonToken.START_ARRAY) { + parser.skipChildren(); + continue; + } + while (parser.nextToken() != JsonToken.END_ARRAY) { + if (parser.currentToken() == JsonToken.START_OBJECT) { + CloudFoundryServiceInstance instance = parseServiceInstance(label, parser); + if (instanceName.equals(instance.getName())) { + return Optional.of(instance); + } + } + } + } + } catch (IOException e) { + LOG.warning("Failed to parse VCAP_SERVICES for findByName: " + e.getMessage()); + } + return Optional.empty(); + } + private static void parseServiceInstances(JsonParser parser, String label, Consumer consumer) throws IOException { if (parser.nextToken() == JsonToken.START_ARRAY) { diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/MtlsClientCertHelper.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/MtlsClientCertHelper.java new file mode 100644 index 00000000..230b5562 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/MtlsClientCertHelper.java @@ -0,0 +1,36 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.PemFileCreator; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +class MtlsClientCertHelper { + + private MtlsClientCertHelper() {} + + static boolean applyTlsProperties(TlsCertificates certs, + PemFileCreator pemFileCreator, + String certFilePrefix, String keyFilePrefix, + String serverCertFilePrefix, + Map props) throws IOException { + Map tlsProps = new LinkedHashMap<>(); + if (certs.serverCert != null && !certs.serverCert.isBlank()) { + File serverCertFile = pemFileCreator.writeFile(serverCertFilePrefix, ".crt", certs.serverCert); + tlsProps.put("otel.exporter.otlp.certificate", serverCertFile.getAbsolutePath()); + } + if (certs.clientCert == null || certs.clientCert.isBlank() + || certs.clientKey == null || certs.clientKey.isBlank()) { + props.putAll(tlsProps); + return false; + } + File certFile = pemFileCreator.writeFile(certFilePrefix, ".crt", certs.clientCert); + File keyFile = pemFileCreator.writeFile(keyFilePrefix, ".key", certs.clientKey); + tlsProps.put("otel.exporter.otlp.client.certificate", certFile.getAbsolutePath()); + tlsProps.put("otel.exporter.otlp.client.key", keyFile.getAbsolutePath()); + props.putAll(tlsProps); + return true; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorBindingPropertiesSupplier.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorBindingPropertiesSupplier.java new file mode 100644 index 00000000..07fb3641 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorBindingPropertiesSupplier.java @@ -0,0 +1,112 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.PemFileCreator; +import io.opentelemetry.common.ComponentLoader; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static java.util.Collections.emptyMap; + +public class OtelCollectorBindingPropertiesSupplier implements Supplier> { + + private static final Logger LOG = + Logger.getLogger(OtelCollectorBindingPropertiesSupplier.class.getName()); + + private final ConfigProperties config; + private final OtelCollectorServiceProvider collectorProvider; + private final PemFileCreator pemFileCreator; + + /** + * Creates a new instance using default service discovery and TLS infrastructure. + */ + public OtelCollectorBindingPropertiesSupplier() { + this(getDefaultConfigProperties(), new OtelCollectorServiceProvider(), new PemFileCreator()); + } + + OtelCollectorBindingPropertiesSupplier(ConfigProperties config, + OtelCollectorServiceProvider collectorProvider, + PemFileCreator pemFileCreator) { + this.config = config; + this.collectorProvider = collectorProvider; + this.pemFileCreator = pemFileCreator; + } + + private static DefaultConfigProperties getDefaultConfigProperties() { + ComponentLoader componentLoader = + ComponentLoader.forClassLoader(DefaultConfigProperties.class.getClassLoader()); + return DefaultConfigProperties.create(emptyMap(), componentLoader); + } + + /** + * Reads the OTel Collector service binding credentials from VCAP_SERVICES and returns the OpenTelemetry OTLP + * exporter configuration properties. Supports mTLS and optional Bearer token authentication. Logs a warning when + * client certificate or key is missing. + * + * @return The pre-configured connection properties for the OpenTelemetry SDK, or an empty map when no usable + * OTel Collector binding is found. + */ + @Override + public Map get() { + String bindingName = + ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.OTEL_COLLECTOR.BINDING_NAME + .getValue(config); + if (bindingName == null || bindingName.isBlank()) { + return Collections.emptyMap(); + } + + Optional instanceOpt = collectorProvider.get(); + if (!instanceOpt.isPresent()) { + LOG.warning("OTel Collector binding '" + bindingName + "' not found in VCAP_SERVICES"); + return Collections.emptyMap(); + } + + CloudFoundryCredentials creds = instanceOpt.get().getCredentials(); + if (creds == null) { + LOG.warning("OTel Collector binding '" + bindingName + "' has no credentials"); + return Collections.emptyMap(); + } + + String url = creds.getString("url"); + if (url == null || url.isBlank()) { + url = creds.getString("http-url"); + } + if (url == null || url.isBlank()) { + LOG.warning("OTel Collector binding '" + bindingName + "' has no 'url' or 'http-url'"); + return Collections.emptyMap(); + } + + Map props = new LinkedHashMap<>(); + props.put("otel.exporter.otlp.endpoint", url); + props.put("otel.exporter.otlp.protocol", "http/protobuf"); + props.put("otel.exporter.otlp.compression", "gzip"); + + TlsCertificates certs = new TlsCertificates( + creds.getString("tls.crt"), creds.getString("tls.key"), creds.getString("tls.ca.crt")); + try { + if (!MtlsClientCertHelper.applyTlsProperties(certs, pemFileCreator, + "otel-collector-client-cert-", "otel-collector-client-key-", "otel-collector-server-ca-", + props)) { + LOG.warning("OTel Collector binding '" + bindingName + "' is missing client certificate or key."); + } + } catch (IOException e) { + LOG.log(Level.WARNING, + "Failed to write TLS files for OTel Collector binding '" + bindingName + "'", e); + } + String token = creds.getString("token"); + if (token != null && !token.isBlank()) { + props.put("otel.exporter.otlp.headers", "Authorization=Bearer " + token); + } + + return props; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorServiceProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorServiceProvider.java new file mode 100644 index 00000000..aceac9c9 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorServiceProvider.java @@ -0,0 +1,43 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations; +import io.opentelemetry.common.ComponentLoader; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; + +import java.util.Optional; +import java.util.function.Supplier; + +import static java.util.Collections.emptyMap; + +class OtelCollectorServiceProvider implements Supplier> { + + private final ConfigProperties config; + private final CloudFoundryServicesAdapter adapter; + + OtelCollectorServiceProvider() { + this(getDefaultConfigProperties(), CloudFoundryServicesAdapter.builder().build()); + } + + OtelCollectorServiceProvider(ConfigProperties config, CloudFoundryServicesAdapter adapter) { + this.config = config; + this.adapter = adapter; + } + + @Override + public Optional get() { + String bindingName = + ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.OTEL_COLLECTOR.BINDING_NAME + .getValue(config); + if (bindingName == null || bindingName.isBlank()) { + return Optional.empty(); + } + return adapter.findByName(bindingName); + } + + private static DefaultConfigProperties getDefaultConfigProperties() { + ComponentLoader componentLoader = + ComponentLoader.forClassLoader(DefaultConfigProperties.class.getClassLoader()); + return DefaultConfigProperties.create(emptyMap(), componentLoader); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/TlsCertificates.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/TlsCertificates.java new file mode 100644 index 00000000..9db4770c --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/TlsCertificates.java @@ -0,0 +1,14 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +class TlsCertificates { + + final String clientCert; + final String clientKey; + final String serverCert; + + TlsCertificates(String clientCert, String clientKey, String serverCert) { + this.clientCert = clientCert; + this.clientKey = clientKey; + this.serverCert = serverCert; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java index 5c97be43..57802e57 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java @@ -294,6 +294,11 @@ interface DYNATRACE { DEPRECATED.RUNTIME.CLOUD_FOUNDRY.SERVICE.DYNATRACE.TOKEN_NAME_OTEL).build(); } + interface OTEL_COLLECTOR { + ConfigProperty BINDING_NAME = + stringValued("sap.otel.collector.cf.binding.name").build(); + } + } } } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/CloudLoggingConfigurationCustomizerProviderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/CloudLoggingConfigurationCustomizerProviderTest.java index a9977b4f..66651538 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/CloudLoggingConfigurationCustomizerProviderTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/CloudLoggingConfigurationCustomizerProviderTest.java @@ -20,4 +20,12 @@ public void canLoadViaSPI() { .anySatisfy(p -> assertThat(p).isInstanceOf(AutoConfigurationCustomizerProvider.class)); } + @Test + void otelCollectorSupplierHasNoArgConstructor() throws Exception { + Class supplierClass = Class.forName( + "com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.OtelCollectorBindingPropertiesSupplier"); + assertThat(java.util.Arrays.stream(supplierClass.getDeclaredConstructors())) + .anyMatch(c -> c.getParameterCount() == 0); + } + } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplierTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplierTest.java index 873992e6..1fa80374 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplierTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplierTest.java @@ -106,7 +106,7 @@ void shouldReturnBasicPropertiesWithoutTlsWhenClientCertMissing() { .containsEntry("otel.exporter.otlp.protocol", "http/protobuf") .containsEntry("otel.exporter.otlp.compression", "gzip") .doesNotContainKey("otel.exporter.otlp.certificate") - .doesNotContainKey("otel.exporter.otlp.client.cert") + .doesNotContainKey("otel.exporter.otlp.client.certificate") .doesNotContainKey("otel.exporter.otlp.client.key"); } @@ -169,6 +169,40 @@ void shouldReturnBasicPropertiesWhenPemFileCreationFails() throws IOException { .doesNotContainKey("otel.exporter.otlp.certificate"); } + @Test + void shouldReturnPropertiesWithNoTlsWhenClientCertWriteFails() throws IOException { + when(credentials.getString("http-url")).thenReturn("https://caas.example.com:4318"); + when(credentials.getString("tls.crt")).thenReturn("client-cert"); + when(credentials.getString("tls.key")).thenReturn("client-key"); + when(serverCertificateDownloader.download(anyString())).thenReturn("server-cert-content"); + when(pemFileCreator.writeFile(eq("caas-server-cert-"), eq(".crt"), eq("server-cert-content"))) + .thenReturn(serverCertFile); + when(pemFileCreator.writeFile(eq("caas-client-cert-"), eq(".crt"), eq("client-cert"))) + .thenThrow(new IOException("disk full")); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://caas.example.com:4318") + .doesNotContainKey("otel.exporter.otlp.certificate") + .doesNotContainKey("otel.exporter.otlp.client.certificate") + .doesNotContainKey("otel.exporter.otlp.client.key"); + } + + @Test + void shouldReturnBasicPropertiesWithoutTlsWhenClientCertIsBlank() { + when(credentials.getString("http-url")).thenReturn("https://caas.example.com:4318"); + when(credentials.getString("tls.crt")).thenReturn(" "); + when(credentials.getString("tls.key")).thenReturn("client-key"); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://caas.example.com:4318") + .doesNotContainKey("otel.exporter.otlp.certificate") + .doesNotContainKey("otel.exporter.otlp.client.certificate") + .doesNotContainKey("otel.exporter.otlp.client.key"); + verify(serverCertificateDownloader, never()).download(anyString()); + } + @Test void shouldReturnFullPropertiesWithTlsConfiguration() throws IOException { when(credentials.getString("http-url")).thenReturn("https://caas.example.com:4318"); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapterTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapterTest.java index 90ff5a88..35f239fd 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapterTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapterTest.java @@ -2,11 +2,13 @@ import org.assertj.core.api.AbstractListAssert; import org.assertj.core.api.ObjectAssert; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import java.net.URISyntaxException; import java.util.List; +import java.util.Optional; import java.util.stream.Stream; import static java.util.Collections.emptyList; @@ -63,6 +65,15 @@ public class CloudFoundryServicesAdapterTest { " ]\n" + // "}"; + private static final String VCAP_WITH_UPS_COLLECTOR = "{\n" + + " \"user-provided\": [{\n" + + " \"label\": \"user-provided\",\n" + + " \"name\": \"my-collector\",\n" + + " \"tags\": [],\n" + + " \"credentials\": { \"url\": \"https://otel.example.com:4318\" }\n" + + " }]\n" + + "}"; + static Stream adapters() { return Stream.of(new CloudFoundryServicesAdapter(DEFAULT_VCAP_SERVICES), createFileAdapter()); } @@ -124,4 +135,28 @@ private static AbstractListAssert, String, ObjectAsser List services) { return assertThat(services).extracting(CloudFoundryServiceInstance::getName); } + + @Test + void findByNameReturnsMatchingInstance() { + CloudFoundryServicesAdapter adapter = new CloudFoundryServicesAdapter(VCAP_WITH_UPS_COLLECTOR); + Optional result = adapter.findByName("my-collector"); + assertThat(result).isPresent(); + assertThat(result.get().getName()).isEqualTo("my-collector"); + } + + @Test + void findByNameReturnsEmptyWhenNameNotFound() { + CloudFoundryServicesAdapter adapter = new CloudFoundryServicesAdapter(VCAP_WITH_UPS_COLLECTOR); + Optional result = adapter.findByName("does-not-exist"); + assertThat(result).isEmpty(); + } + + @Test + void findByNameSearchesAcrossAllLabels() { + CloudFoundryServicesAdapter adapter = new CloudFoundryServicesAdapter(DEFAULT_VCAP_SERVICES); + assertThat(adapter.findByName("managed-find-me1")).isPresent(); + assertThat(adapter.findByName("ups-find-me2")).isPresent(); + assertThat(adapter.findByName("missing")).isEmpty(); + } + } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorBindingPropertiesSupplierTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorBindingPropertiesSupplierTest.java new file mode 100644 index 00000000..3cadfa98 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorBindingPropertiesSupplierTest.java @@ -0,0 +1,321 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.PemFileCreator; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OtelCollectorBindingPropertiesSupplierTest { + + @Mock private ConfigProperties config; + @Mock private OtelCollectorServiceProvider collectorProvider; + @Mock private PemFileCreator pemFileCreator; + @Mock private CloudFoundryServiceInstance instance; + @Mock private CloudFoundryCredentials creds; + @Mock private File certFile; + @Mock private File keyFile; + @Mock private File caFile; + + private OtelCollectorBindingPropertiesSupplier supplier; + + @BeforeEach + void setUp() { + supplier = new OtelCollectorBindingPropertiesSupplier(config, collectorProvider, pemFileCreator); + } + + @Test + void returnsEmptyMapWhenBindingNameAbsent() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn(null); + + Map result = supplier.get(); + + assertThat(result).isEmpty(); + verifyNoInteractions(collectorProvider); + } + + @Test + void returnsEmptyMapWhenBindingNameBlank() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn(" "); + + Map result = supplier.get(); + + assertThat(result).isEmpty(); + verifyNoInteractions(collectorProvider); + } + + @Test + void returnsEmptyMapWhenBindingNotFound() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.empty()); + + Map result = supplier.get(); + + assertThat(result).isEmpty(); + } + + @Test + void returnsEmptyMapWhenBindingHasNoCredentials() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(null); + + Map result = supplier.get(); + + assertThat(result).isEmpty(); + } + + @Test + void returnsEmptyMapWhenBothUrlAndHttpUrlAbsent() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn(null); + when(creds.getString("http-url")).thenReturn(null); + + Map result = supplier.get(); + + assertThat(result).isEmpty(); + } + + @Test + void returnsBasicPropertiesWithUrlWhenNoMtls() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn("https://otel.example.com:4318"); + when(creds.getString("tls.crt")).thenReturn(null); + when(creds.getString("tls.key")).thenReturn(null); + when(creds.getString("tls.ca.crt")).thenReturn(null); + when(creds.getString("token")).thenReturn(null); + + Map result = supplier.get(); + + assertThat(result) + .containsEntry("otel.exporter.otlp.endpoint", "https://otel.example.com:4318") + .containsEntry("otel.exporter.otlp.protocol", "http/protobuf") + .containsEntry("otel.exporter.otlp.compression", "gzip") + .doesNotContainKey("otel.exporter.otlp.client.certificate") + .doesNotContainKey("otel.exporter.otlp.client.key") + .doesNotContainKey("otel.exporter.otlp.certificate") + .doesNotContainKey("otel.exporter.otlp.headers"); + } + + @Test + void fallsBackToHttpUrlWhenUrlAbsent() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn(null); + when(creds.getString("http-url")).thenReturn("https://otel.example.com:4318"); + when(creds.getString("tls.crt")).thenReturn(null); + when(creds.getString("tls.key")).thenReturn(null); + when(creds.getString("tls.ca.crt")).thenReturn(null); + when(creds.getString("token")).thenReturn(null); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://otel.example.com:4318"); + } + + @Test + void addsBearerTokenHeaderWhenTokenPresentAndNoMtls() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn("https://otel.example.com:4318"); + when(creds.getString("tls.crt")).thenReturn(null); + when(creds.getString("tls.key")).thenReturn(null); + when(creds.getString("tls.ca.crt")).thenReturn(null); + when(creds.getString("token")).thenReturn("my-secret-token"); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.headers", + "Authorization=Bearer my-secret-token"); + } + + @Test + void setsClientCertAndKeyWhenMtlsCredsPresent() throws Exception { + String certPem = "-----BEGIN CERTIFICATE-----\nMIIBx\n-----END CERTIFICATE-----\n"; + String keyPem = "-----BEGIN PRIVATE KEY-----\nMIIE\n-----END PRIVATE KEY-----\n"; + + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn("https://otel.example.com:4318"); + when(creds.getString("tls.crt")).thenReturn(certPem); + when(creds.getString("tls.key")).thenReturn(keyPem); + when(creds.getString("tls.ca.crt")).thenReturn(null); + when(creds.getString("token")).thenReturn(null); + + when(certFile.getAbsolutePath()).thenReturn("/tmp/client.crt"); + when(keyFile.getAbsolutePath()).thenReturn("/tmp/client.key"); + when(pemFileCreator.writeFile(eq("otel-collector-client-cert-"), eq(".crt"), eq(certPem))) + .thenReturn(certFile); + when(pemFileCreator.writeFile(eq("otel-collector-client-key-"), eq(".key"), eq(keyPem))) + .thenReturn(keyFile); + + Map result = supplier.get(); + + assertThat(result) + .containsEntry("otel.exporter.otlp.client.certificate", "/tmp/client.crt") + .containsEntry("otel.exporter.otlp.client.key", "/tmp/client.key") + .doesNotContainKey("otel.exporter.otlp.headers"); + } + + @Test + void setsBothMtlsAndBearerTokenWhenBothPresent() throws Exception { + String certPem = "-----BEGIN CERTIFICATE-----\nMIIBx\n-----END CERTIFICATE-----\n"; + String keyPem = "-----BEGIN PRIVATE KEY-----\nMIIE\n-----END PRIVATE KEY-----\n"; + + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn("https://otel.example.com:4318"); + when(creds.getString("tls.crt")).thenReturn(certPem); + when(creds.getString("tls.key")).thenReturn(keyPem); + when(creds.getString("tls.ca.crt")).thenReturn(null); + when(creds.getString("token")).thenReturn("my-secret-token"); + + when(certFile.getAbsolutePath()).thenReturn("/tmp/client.crt"); + when(keyFile.getAbsolutePath()).thenReturn("/tmp/client.key"); + when(pemFileCreator.writeFile(eq("otel-collector-client-cert-"), eq(".crt"), eq(certPem))) + .thenReturn(certFile); + when(pemFileCreator.writeFile(eq("otel-collector-client-key-"), eq(".key"), eq(keyPem))) + .thenReturn(keyFile); + + Map result = supplier.get(); + + assertThat(result) + .containsEntry("otel.exporter.otlp.client.certificate", "/tmp/client.crt") + .containsEntry("otel.exporter.otlp.client.key", "/tmp/client.key") + .containsEntry("otel.exporter.otlp.headers", "Authorization=Bearer my-secret-token"); + } + + @Test + void setsServerCaWhenCaCrtPresent() throws Exception { + String certPem = "-----BEGIN CERTIFICATE-----\nMIIBx\n-----END CERTIFICATE-----\n"; + String keyPem = "-----BEGIN PRIVATE KEY-----\nMIIE\n-----END PRIVATE KEY-----\n"; + String caPem = "-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----\n"; + + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn("https://otel.example.com:4318"); + when(creds.getString("tls.crt")).thenReturn(certPem); + when(creds.getString("tls.key")).thenReturn(keyPem); + when(creds.getString("tls.ca.crt")).thenReturn(caPem); + + when(certFile.getAbsolutePath()).thenReturn("/tmp/client.crt"); + when(keyFile.getAbsolutePath()).thenReturn("/tmp/client.key"); + when(caFile.getAbsolutePath()).thenReturn("/tmp/ca.crt"); + when(pemFileCreator.writeFile(eq("otel-collector-client-cert-"), eq(".crt"), eq(certPem))) + .thenReturn(certFile); + when(pemFileCreator.writeFile(eq("otel-collector-client-key-"), eq(".key"), eq(keyPem))) + .thenReturn(keyFile); + when(pemFileCreator.writeFile(eq("otel-collector-server-ca-"), eq(".crt"), eq(caPem))) + .thenReturn(caFile); + + Map result = supplier.get(); + + assertThat(result) + .containsEntry("otel.exporter.otlp.client.certificate", "/tmp/client.crt") + .containsEntry("otel.exporter.otlp.client.key", "/tmp/client.key") + .containsEntry("otel.exporter.otlp.certificate", "/tmp/ca.crt") + .doesNotContainKey("otel.exporter.otlp.headers"); + } + + @Test + void setsServerCaAloneWhenOnlyCaPresent() throws Exception { + String caPem = "-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----\n"; + + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn("https://otel.example.com:4318"); + when(creds.getString("tls.crt")).thenReturn(null); + when(creds.getString("tls.key")).thenReturn(null); + when(creds.getString("tls.ca.crt")).thenReturn(caPem); + when(creds.getString("token")).thenReturn(null); + + when(caFile.getAbsolutePath()).thenReturn("/tmp/ca.crt"); + when(pemFileCreator.writeFile(eq("otel-collector-server-ca-"), eq(".crt"), eq(caPem))) + .thenReturn(caFile); + + Map result = supplier.get(); + + assertThat(result) + .containsEntry("otel.exporter.otlp.certificate", "/tmp/ca.crt") + .doesNotContainKey("otel.exporter.otlp.client.certificate") + .doesNotContainKey("otel.exporter.otlp.client.key"); + } + + @Test + void doesNotCrashWhenPemFileCreatorThrows() throws Exception { + String caPem = "-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----\n"; + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn("https://otel.example.com:4318"); + when(creds.getString("tls.crt")).thenReturn(null); + when(creds.getString("tls.key")).thenReturn(null); + when(creds.getString("tls.ca.crt")).thenReturn(caPem); + when(creds.getString("token")).thenReturn(null); + when(pemFileCreator.writeFile(anyString(), anyString(), anyString())) + .thenThrow(new IOException("disk full")); + + Map result = supplier.get(); + + // Must not throw; basic props still present + assertThat(result) + .containsEntry("otel.exporter.otlp.endpoint", "https://otel.example.com:4318") + .doesNotContainKey("otel.exporter.otlp.certificate"); + } + + @Test + void logsWarningWhenMtlsCredsAbsent() { + // Set up a custom log handler to capture warnings + java.util.logging.Logger supplierLogger = java.util.logging.Logger.getLogger( + OtelCollectorBindingPropertiesSupplier.class.getName()); + java.util.List records = new java.util.ArrayList<>(); + java.util.logging.Handler handler = new java.util.logging.Handler() { + @Override public void publish(java.util.logging.LogRecord record) { records.add(record); } + @Override public void flush() {} + @Override public void close() {} + }; + supplierLogger.addHandler(handler); + try { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(collectorProvider.get()).thenReturn(Optional.of(instance)); + when(instance.getCredentials()).thenReturn(creds); + when(creds.getString("url")).thenReturn("https://otel.example.com:4318"); + when(creds.getString("tls.crt")).thenReturn(null); + when(creds.getString("tls.key")).thenReturn(null); + when(creds.getString("tls.ca.crt")).thenReturn(null); + when(creds.getString("token")).thenReturn(null); + + supplier.get(); + + assertThat(records) + .anyMatch(r -> r.getLevel() == java.util.logging.Level.WARNING + && r.getMessage().contains("my-collector") + && r.getMessage().contains("missing client certificate or key")); + } finally { + supplierLogger.removeHandler(handler); + } + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorServiceProviderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorServiceProviderTest.java new file mode 100644 index 00000000..85d18f4b --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/OtelCollectorServiceProviderTest.java @@ -0,0 +1,64 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OtelCollectorServiceProviderTest { + + @Mock private ConfigProperties config; + @Mock private CloudFoundryServicesAdapter adapter; + @Mock private CloudFoundryServiceInstance instance; + + @Test + void returnsEmptyWhenBindingNamePropertyAbsent() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn(null); + + Optional result = + new OtelCollectorServiceProvider(config, adapter).get(); + + assertThat(result).isEmpty(); + verifyNoInteractions(adapter); + } + + @Test + void returnsEmptyWhenBindingNamePropertyBlank() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn(" "); + + Optional result = + new OtelCollectorServiceProvider(config, adapter).get(); + + assertThat(result).isEmpty(); + verifyNoInteractions(adapter); + } + + @Test + void delegatesToAdapterWhenBindingNameSet() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("my-collector"); + when(adapter.findByName("my-collector")).thenReturn(Optional.of(instance)); + + Optional result = + new OtelCollectorServiceProvider(config, adapter).get(); + + assertThat(result).contains(instance); + } + + @Test + void returnsEmptyWhenAdapterFindsNothing() { + when(config.getString("sap.otel.collector.cf.binding.name")).thenReturn("missing"); + when(adapter.findByName("missing")).thenReturn(Optional.empty()); + + Optional result = + new OtelCollectorServiceProvider(config, adapter).get(); + + assertThat(result).isEmpty(); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurationsTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurationsTest.java index 8ae309d5..1c4aed4b 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurationsTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurationsTest.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.stream.Stream; +import static com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.OTEL_COLLECTOR; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.params.provider.Arguments.of; @@ -73,7 +74,8 @@ private static Stream provideStringProperties() { of(ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.DYNATRACE.TOKEN_NAME, "sap.dynatrace.cf.binding.token.name", "api-token"), of(ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.DYNATRACE.TOKEN_NAME, - "otel.javaagent.extension.sap.cf.binding.dynatrace.metrics.token-name", "api-token")); + "otel.javaagent.extension.sap.cf.binding.dynatrace.metrics.token-name", "api-token"), + of(OTEL_COLLECTOR.BINDING_NAME, "sap.otel.collector.cf.binding.name", "my-collector")); } private static Stream provideBooleanProperties() {