diff --git a/cf-java-logging-support-opentelemetry-agent-extension/README.md b/cf-java-logging-support-opentelemetry-agent-extension/README.md index c711e16c..4dc52f24 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/README.md +++ b/cf-java-logging-support-opentelemetry-agent-extension/README.md @@ -1,15 +1,15 @@ # 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 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 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. 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) * 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) -* auto-configuration of the generic OpenTelemetry connection to [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) * adding resource attributes describing the CF application See the section on [configuration](#configuration) for further details. @@ -39,9 +39,13 @@ java -javaagent:BOOT-INF/lib/opentelemetry-javaagent-.jar \ See the [example manifest](../sample-spring-boot/manifest-otel-javaagent.yml), how this translates into a deployment description. -Once the agent is attached to the JVM with the extension in place, there are two ways, which can be used to send data to [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging): +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. Use the `cloud-logging` and/or `dynatrace` exporters explicitly as provided by the extension. +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. + +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. This can be achieved via system properties or environment variables: ```sh @@ -57,26 +61,6 @@ export OTEL_TRACES_EXPORTER=cloud-logging java #... ``` -2. Use the default `otlp` exporter with the provided default configuration from the extension: - -```sh --Dotel.logs.exporter=otlp \ --Dotel.metrics.exporter=otlp \ # default value --Dotel.traces.exporter=otlp # default value - -#or - -export OTEL_LOGS_EXPORTER=otlp -export OTEL_METRICS_EXPORTER=otlp # default value -export OTEL_TRACES_EXPORTER=otlp # default value -java #... -``` - -Note, that the OpenTelemetry Java Agent currently sends traces and metrics by default using the `otlp` exporter. -That means, without any configuration the agent with the extension will forward metrics and traces to [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging). -The difference between `cloud-logging` and `otlp` exporters are explained in an own [section](#implementation-differences-between-cloud-logging-and-otlp-exporter). -The benefit of the `cloud-logging` exporter is, that it can be combined with a different configuration of the `otlp` exporter. - For the instrumentation to send observability data to [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) or [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 application needs to be bound to a corresponding service instances. The service instances can be either managed or [user-provided](#using-user-provided-service-instances). @@ -105,23 +89,27 @@ 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.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.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 will scan the environment variable `VCAP_SERVICES` for CF service bindings. -User-provided bindings will take precedence over managed bindings of the configured label ("cloud-logging" or "dynatrace" by default). -All matching bindings are filtered for the configured tag ("Cloud Logging" od "dynatrace" by default). -The first Cloud Logging binding will be taken for configuration for the standard OpenTelemetry (otlp) exporter. -Preferring user-provided services over managed service instances allows better control of the binding properties, e.g. syslog drains. +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`) + +User-provided bindings take precedence over managed bindings. +The first matching binding configures the default OpenTelemetry `otlp` exporter. +Bindings to Cloud Logging and Dynatrace are also used to configure the respective exporters. ### Recommended Agent Configuration @@ -165,41 +153,41 @@ The configuration applies to both the `cloud-logging` and `dynatrace` exporters The following table summarizes all configuration properties provided by the extension: -| Property | Description | Default Value | -|---------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------| -| `otel.exporter.cloud-logging.compression` | The compression algorithm to use when exporting logs. | `gzip` | -| `otel.exporter.cloud-logging.timeout` | The maximum duration to wait for Cloud Logging when exporting data. | `10000 (from OTel SDK) | +| Property | Description | Default Value | +|---------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| +| `otel.exporter.cloud-logging.compression` | The compression algorithm to use when exporting logs. | `gzip` | +| `otel.exporter.cloud-logging.timeout` | The maximum duration to wait for Cloud Logging when exporting data. | `10000 (from OTel SDK) | | `otel.exporter.cloud-logging.logs.compression` | The compression algorithm to use when exporting logs. Falls back to `otel.exporter.cloud-logging.compression` if not set. | `gzip` (from `otel.exporter.cloud-logging.compression`) | -| `otel.exporter.cloud-logging.logs.timeout` | The maximum duration to wait for Cloud Logging when exporting logs. Falls back to `otel.exporter.cloud-logging.timeout` if not set. | `10000` (from OTel SDK) | +| `otel.exporter.cloud-logging.logs.timeout` | The maximum duration to wait for Cloud Logging when exporting logs. Falls back to `otel.exporter.cloud-logging.timeout` if not set. | `10000` (from OTel SDK) | | `otel.exporter.cloud-logging.metrics.compression` | The compression algorithm to use when exporting metrics. Falls back to `otel.exporter.cloud-logging.compression` if not set. | `gzip` (from `otel.exporter.cloud-logging.compression`) | -| `otel.exporter.cloud-logging.metrics.default.histogram.aggregation` | The default histogram aggregation for metrics exported to Cloud Logging. Delegates to the underlying OTLP exporter, supporting all its configurations. | `EXPLICIT_BUCKET_HISTOGRAM` (from OTel SDK) | -| `otel.exporter.cloud-logging.metrics.exclude.names` | A comma-separated list of metric name patterns to be excluded when exporting metrics to Cloud Logging. Wildcard "\*" is only supported at the end of the name. If not set, no metrics are excluded. | | -| `otel.exporter.cloud-logging.metrics.include.names` | A comma-separated list of metric name patterns to be included when exporting metrics to Cloud Logging. Wildcard "\*" is only supported at the end of the name. If not set, all metrics are exported. | | -| `otel.exporter.cloud-logging.metrics.temporality.preference` | The preferred aggregation temporality for metrics exported to Cloud Logging. Can be either `cumulative`, `delta`, or `lowmemory`. | `cumulative` | -| `otel.exporter.cloud-logging.metrics.timeout` | The maximum duration to wait for Cloud Logging when exporting metrics. Falls back to `otel.exporter.cloud-logging.timeout` if not set. | `10000` (from OTel SDK) | +| `otel.exporter.cloud-logging.metrics.default.histogram.aggregation` | The default histogram aggregation for metrics exported to Cloud Logging. Delegates to the underlying OTLP exporter, supporting all its configurations. | `EXPLICIT_BUCKET_HISTOGRAM` (from OTel SDK) | +| `otel.exporter.cloud-logging.metrics.exclude.names` | A comma-separated list of metric name patterns to be excluded when exporting metrics to Cloud Logging. Wildcard "\*" is only supported at the end of the name. If not set, no metrics are excluded. | | +| `otel.exporter.cloud-logging.metrics.include.names` | A comma-separated list of metric name patterns to be included when exporting metrics to Cloud Logging. Wildcard "\*" is only supported at the end of the name. If not set, all metrics are exported. | | +| `otel.exporter.cloud-logging.metrics.temporality.preference` | The preferred aggregation temporality for metrics exported to Cloud Logging. Can be either `cumulative`, `delta`, or `lowmemory`. | `cumulative` | +| `otel.exporter.cloud-logging.metrics.timeout` | The maximum duration to wait for Cloud Logging when exporting metrics. Falls back to `otel.exporter.cloud-logging.timeout` if not set. | `10000` (from OTel SDK) | | `otel.exporter.cloud-logging.traces.compression` | The compression algorithm to use when exporting traces. Falls back to `otel.exporter.cloud-logging.compression` if not set. | `gzip` (from `otel.exporter.cloud-logging.compression`) | -| `otel.exporter.cloud-logging.traces.timeout` | The maximum duration to wait for Cloud Logging when exporting traces. Falls back to `otel.exporter.cloud-logging.timeout` if not set. | `10000` (from OTel SDK) | -| `otel.exporter.dynatrace.metrics.compression` | The compression algorithm to use when exporting metrics. | `gzip` | -| `otel.exporter.dynatrace.metrics.default.histogram.aggregation` | The default histogram aggregation for metrics exported to Dynatrace. Delegates to the underlying OTLP exporter, supporting all its configurations. | `EXPLICIT_BUCKET_HISTOGRAM` (from OTel SDK) | -| `otel.exporter.dynatrace.metrics.exclude.names` | A comma-separated list of metric name patterns to be excluded when exporting metrics to Dynatrace. Wildcard "\*" is only supported at the end of the name. If not set, no metrics are excluded. | | -| `otel.exporter.dynatrace.metrics.include.names` | A comma-separated list of metric name patterns to be included when exporting metrics to Dynatrace. Wildcard "\*" is only supported at the end of the name. If not set, all metrics are exported. | | -| `otel.exporter.dynatrace.metrics.temporality.preference` | The default histogram aggregation for metrics exported to Dynatrace. Delegates to the underlying OTLP exporter, supporting all its configurations. The Dynatrace metrics exporter provides an additional option `always_delta` which always uses delta aggregation temporality. This is also the default behavior if the property is not set. | `always_delta` | -| `otel.exporter.dynatrace.metrics.timeout` | The maximum duration to wait for Dynatrace when exporting metrics. | `10000` (from OTel SDK) | -| `sap.cf.integration.otel.extension.sanitizer.enabled` | Enables or disables the sanitizer. | `true` | -| `sap.cloudfoundry.otel.resources.enabled` | Should Cloud Foundry resource attributes be added to the OpenTelemetry resource? | `true` | -| `sap.cloudfoundry.otel.resources.format` | Determines the semantic convention used for Cloud Foundry resource attributes names. `SAP` - use SAP specific attribute names (default). `OTEL` - use OpenTelemetry semantic convention attribute names. | `SAP` | -| `sap.cloud-logging.cf.binding.label.value` | The label value used to identify managed Cloud Logging service bindings. | `cloud-logging` | -| `sap.cloud-logging.cf.binding.tag.value` | The tag value used to identify managed Cloud Logging service bindings. | `Cloud Logging` | -| `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. | | +| `otel.exporter.cloud-logging.traces.timeout` | The maximum duration to wait for Cloud Logging when exporting traces. Falls back to `otel.exporter.cloud-logging.timeout` if not set. | `10000` (from OTel SDK) | +| `otel.exporter.dynatrace.metrics.compression` | The compression algorithm to use when exporting metrics. | `gzip` | +| `otel.exporter.dynatrace.metrics.default.histogram.aggregation` | The default histogram aggregation for metrics exported to Dynatrace. Delegates to the underlying OTLP exporter, supporting all its configurations. | `EXPLICIT_BUCKET_HISTOGRAM` (from OTel SDK) | +| `otel.exporter.dynatrace.metrics.exclude.names` | A comma-separated list of metric name patterns to be excluded when exporting metrics to Dynatrace. Wildcard "\*" is only supported at the end of the name. If not set, no metrics are excluded. | | +| `otel.exporter.dynatrace.metrics.include.names` | A comma-separated list of metric name patterns to be included when exporting metrics to Dynatrace. Wildcard "\*" is only supported at the end of the name. If not set, all metrics are exported. | | +| `otel.exporter.dynatrace.metrics.temporality.preference` | The default histogram aggregation for metrics exported to Dynatrace. Delegates to the underlying OTLP exporter, supporting all its configurations. The Dynatrace metrics exporter provides an additional option `always_delta` which always uses delta aggregation temporality. This is also the default behavior if the property is not set. | `always_delta` | +| `otel.exporter.dynatrace.metrics.timeout` | The maximum duration to wait for Dynatrace when exporting metrics. | `10000` (from OTel SDK) | +| `sap.cf.integration.otel.extension.sanitizer.enabled` | Enables or disables the sanitizer. | `true` | +| `sap.cloudfoundry.otel.resources.enabled` | Should Cloud Foundry resource attributes be added to the OpenTelemetry resource? | `true` | +| `sap.cloudfoundry.otel.resources.format` | Determines the semantic convention used for Cloud Foundry resource attributes names. `SAP` - use SAP specific attribute names (default). `OTEL` - use OpenTelemetry semantic convention attribute names. | `SAP` | +| `sap.cloud-logging.cf.binding.label.value` | The label value used to identify managed Cloud Logging service bindings. | `cloud-logging` | +| `sap.cloud-logging.cf.binding.tag.value` | The tag value used to identify managed Cloud Logging service bindings. | `Cloud Logging` | +| `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. | | ## Using User-Provided Service Instances ### SAP Cloud Logging The extension provides support not only for managed service instance of [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) but also for user-provided service instances. -This helps to fine-tune the configuration, e.g. leave out or reconfigure the syslog drain. +This helps to fine-tune the configuration, e.g., leave out or reconfigure the syslog drain. Furthermore, this helps on sharing service instances across CF orgs or landscapes. The extension requires four fields in the user-provided service credentials and needs to be tagged with the `otel.javaagent.extension.sap.cf.binding.cloud-logging.tag` (default: `Cloud Logging`) documented in section [Configuration](#configuration). @@ -261,6 +249,6 @@ The `cloud-logging` exporter provided by this extension is a facade for the `Otl The difference is just during the bootstrapping phase. The main differences are: -* The `cloud-logging` exporter will send data to all found bindings to SAP Cloud Logging. +* The `cloud-logging` exporter will send data to all found Cloud Logging bindings, while the `otlp` exporter uses the first CaaS or Cloud Logging binding found. * The `otlp` configuration will write the required certificates and keys to temporary files, which are deleted when the JVM is shut down. The `cloud-logging` exporter will keep the secrets in memory. * The `cloud-logging` exporter needs to be configured explicitly. 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 99e33288..01ab267b 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 @@ -1,21 +1,32 @@ package com.sap.hcf.cf.logging.opentelemetry.agent.ext; +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.exporter.SanitizeSpanExporterCustomizer; import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; import java.util.logging.Logger; +import static com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.DefaultOtelBackendPropertiesSupplier.builder; + public class CloudLoggingConfigurationCustomizerProvider implements AutoConfigurationCustomizerProvider { private static final Logger LOG = Logger.getLogger(CloudLoggingConfigurationCustomizerProvider.class.getName()); private static final String VERSION = "4.0.0"; + private static DefaultOtelBackendPropertiesSupplier getDefaultOtelBackendPropertiesSupplier() { + return builder() // + .add(new CaasBindingPropertiesSupplier()) // this has priority + .add(new CloudLoggingBindingPropertiesSupplier()) // look for Cloud Logging as fallback and backward compatibility + .build(); + } + @Override public void customize(AutoConfigurationCustomizer autoConfiguration) { LOG.info("Initializing SAP BTP Observability extension " + VERSION); - autoConfiguration.addPropertiesSupplier(new CloudLoggingBindingPropertiesSupplier()); + autoConfiguration.addPropertiesSupplier(getDefaultOtelBackendPropertiesSupplier()); autoConfiguration.addSpanExporterCustomizer(new SanitizeSpanExporterCustomizer()); } 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 new file mode 100644 index 00000000..370cafb7 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplier.java @@ -0,0 +1,106 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.PemFileCreator; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.ServerCertificateDownloader; +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; +import java.util.function.Supplier; +import java.util.logging.Logger; + +import static java.util.Collections.emptyMap; + +public class CaasBindingPropertiesSupplier implements Supplier> { + + private static final String CAAS_CLIENT_KEY = "tls.key"; + private static final String CAAS_CLIENT_CERT = "tls.crt"; + private static final String CAAS_ENDPOINT = "http-url"; + private static final String CAAS_PORT_PLACEHOLDER = ""; + private static final String PORT_OTLP_HTTP = "4318"; + + private static final Logger LOG = Logger.getLogger(CaasBindingPropertiesSupplier.class.getName()); + + private final CaasServiceProvider serviceProvider; + private final PemFileCreator pemFileCreator; + private final ServerCertificateDownloader serverCertificateDownloader; + + public CaasBindingPropertiesSupplier() { + this(new CaasServiceProvider(getDefaultConfigProperties()), new PemFileCreator(), + new ServerCertificateDownloader()); + } + + CaasBindingPropertiesSupplier(CaasServiceProvider serviceProvider, PemFileCreator pemFileCreator, + ServerCertificateDownloader serverCertificateDownloader) { + this.serviceProvider = serviceProvider; + this.pemFileCreator = pemFileCreator; + this.serverCertificateDownloader = serverCertificateDownloader; + } + + private static DefaultConfigProperties getDefaultConfigProperties() { + ComponentLoader componentLoader = + ComponentLoader.forClassLoader(DefaultConfigProperties.class.getClassLoader()); + return DefaultConfigProperties.create(emptyMap(), componentLoader); + } + + private static void putCaasDefaultProperties(Map properties) { + properties.put("otel.exporter.otlp.protocol", "http/protobuf"); + properties.put("otel.exporter.otlp.compression", "gzip"); + } + + @Override + public Map get() { + CloudFoundryServiceInstance serviceInstance = serviceProvider.get(); + if (serviceInstance == null) { + LOG.config("No CaaS service instance found."); + return emptyMap(); + } + CloudFoundryCredentials credentials = serviceInstance.getCredentials(); + if (credentials == null) { + LOG.warning(() -> "CaaS service instance '" + serviceInstance.getName() + "' has no credentials."); + return emptyMap(); + } + String endpointUrl = credentials.getString(CAAS_ENDPOINT); + if (endpointUrl == null || endpointUrl.isBlank()) { + LOG.warning(() -> "CaaS service instance '" + serviceInstance.getName() + "' has no endpoint URL."); + return emptyMap(); + } + + Map properties = new HashMap<>(); + endpointUrl = endpointUrl.replace(CAAS_PORT_PLACEHOLDER, PORT_OTLP_HTTP); + LOG.config("Using CaaS OTLP endpoint URL: " + endpointUrl); + properties.put("otel.exporter.otlp.endpoint", endpointUrl); + + putCaasDefaultProperties(properties); + + String clientCert = credentials.getString(CAAS_CLIENT_CERT); + String clientKey = credentials.getString(CAAS_CLIENT_KEY); + if (clientCert != null && clientKey != null) { + 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()); + + } catch (IOException e) { + LOG.warning( + () -> "Failed to create PEM files for CaaS service instance '" + serviceInstance.getName() + "': " + e.getMessage()); + } + } else { + LOG.warning( + () -> "CaaS service instance '" + serviceInstance.getName() + "' is missing client certificate or key."); + } + return properties; + } + +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasServiceProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasServiceProvider.java new file mode 100644 index 00000000..dea34776 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasServiceProvider.java @@ -0,0 +1,27 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; + +import java.util.Collections; +import java.util.function.Supplier; + +public class CaasServiceProvider implements Supplier { + + private final CloudFoundryServiceInstance service; + + public CaasServiceProvider(ConfigProperties config) { + this(config, new CloudFoundryServicesAdapter()); + } + + CaasServiceProvider(ConfigProperties config, CloudFoundryServicesAdapter adapter) { + String label = ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.CAAS.LABEL.getValue(config); + this.service = + adapter.stream(Collections.singletonList(label), Collections.emptyList()).findFirst().orElse(null); + } + + @Override + public CloudFoundryServiceInstance get() { + return service; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudLoggingBindingPropertiesSupplier.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudLoggingBindingPropertiesSupplier.java index 35689399..5304061d 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudLoggingBindingPropertiesSupplier.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudLoggingBindingPropertiesSupplier.java @@ -1,12 +1,12 @@ package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.DEPRECATED; +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.File; -import java.io.FileWriter; import java.io.IOException; import java.util.Collections; import java.util.HashMap; @@ -24,13 +24,17 @@ public class CloudLoggingBindingPropertiesSupplier implements Supplier createEndpointConfiguration(CloudFoundryServiceInsta } try { - File clientKeyFile = writeFile("cloud-logging-client", ".key", clientKey); - File clientCertFile = writeFile("cloud-logging-client", ".cert", clientCert); - File serverCertFile = writeFile("cloud-logging-server", ".cert", serverCert); + File clientKeyFile = pemFileCreator.writeFile("cloud-logging-client", ".key", clientKey); + File clientCertFile = pemFileCreator.writeFile("cloud-logging-client", ".cert", clientCert); + File serverCertFile = pemFileCreator.writeFile("cloud-logging-server", ".cert", serverCert); HashMap properties = new HashMap<>(); properties.put("otel.exporter.otlp.endpoint", "https://" + endpoint); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/DefaultOtelBackendPropertiesSupplier.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/DefaultOtelBackendPropertiesSupplier.java new file mode 100644 index 00000000..673f4ad0 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/DefaultOtelBackendPropertiesSupplier.java @@ -0,0 +1,48 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.logging.Logger; + +import static java.util.Collections.emptyMap; +import static java.util.function.Predicate.not; + +public class DefaultOtelBackendPropertiesSupplier implements Supplier> { + + private static final Logger LOG = Logger.getLogger(DefaultOtelBackendPropertiesSupplier.class.getName()); + + private final List>> suppliers; + + private DefaultOtelBackendPropertiesSupplier(Builder builder) { + this.suppliers = builder.suppliers; + } + + @Override + public Map get() { + if (suppliers.isEmpty()) { + LOG.config("No OpenTelemetry backend properties suppliers configured."); + return emptyMap(); + } + return suppliers.stream().map(Supplier::get).filter(not(Map::isEmpty)).findFirst().orElse(emptyMap()); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private final List>> suppliers = new ArrayList<>(); + + public Builder add(Supplier> supplier) { + suppliers.add(supplier); + return this; + } + + public DefaultOtelBackendPropertiesSupplier build() { + return new DefaultOtelBackendPropertiesSupplier(this); + } + } +} 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 c17ebb10..58a562f2 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 @@ -213,6 +213,16 @@ interface CLOUD_FOUNDRY { interface RUNTIME { interface CLOUD_FOUNDRY { interface SERVICE { + interface CAAS { + /** + *

Parses {@code sap.caas.cf.binding.label.value}.

+ *

The label value used to identify managed CaaS service bindings. Default is + * {@code "caas-service"}.

+ */ + ConfigProperty LABEL = + stringValued("sap.caas.cf.binding.label.value").withDefaultValue("caas-service").build(); + } + interface CLOUD_LOGGING { /** *

Parses {@code sap.cloud-logging.cf.binding.label.value}.

diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemFileCreator.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemFileCreator.java new file mode 100644 index 00000000..52c872b9 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemFileCreator.java @@ -0,0 +1,21 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.logging.Logger; + +public class PemFileCreator { + + private static final Logger LOG = Logger.getLogger(PemFileCreator.class.getName()); + + public File writeFile(String prefix, String suffix, String content) throws IOException { + File file = File.createTempFile(prefix, suffix); + file.deleteOnExit(); + try (FileWriter writer = new FileWriter(file)) { + writer.append(content); + LOG.fine("Created temporary file " + file.getAbsolutePath()); + } + return file; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloader.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloader.java new file mode 100644 index 00000000..be8880a5 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloader.java @@ -0,0 +1,93 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import javax.net.ssl.*; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class ServerCertificateDownloader { + + private static final Logger LOG = Logger.getLogger(ServerCertificateDownloader.class.getName()); + private static final byte[] LINE_SEPARATOR = "\n".getBytes(StandardCharsets.UTF_8); + private static final Base64.Encoder BASE64_ENCODER = Base64.getMimeEncoder(64, LINE_SEPARATOR); + + private final SSLSocketFactory sslSocketFactory; + + public ServerCertificateDownloader() { + this(createDefaultSSLSocketFactory()); + } + + ServerCertificateDownloader(SSLSocketFactory sslSocketFactory) { + this.sslSocketFactory = sslSocketFactory; + } + + private static SSLSocketFactory createDefaultSSLSocketFactory() { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAllX509(), new SecureRandom()); + return sslContext.getSocketFactory(); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + LOG.log(Level.WARNING, e, () -> "Failed to create default SSLSocketFactory"); + return null; + } + } + + private static TrustManager[] trustAllX509() { + return new TrustManager[] { new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } }; + } + + public String download(String endpointUrl) { + try { + if (sslSocketFactory == null) { + LOG.warning( + () -> "SSLSocketFactory is not initialized, cannot download server certificate from " + endpointUrl); + return null; + } + URL url = new URL(endpointUrl); + String host = url.getHost(); + int port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort(); + + try (SSLSocket socket = (SSLSocket) sslSocketFactory.createSocket(host, port)) { + socket.startHandshake(); + Certificate[] serverCertificates = socket.getSession().getPeerCertificates(); + + if (serverCertificates.length == 0) { + LOG.warning(() -> "No server certificates found when connecting to " + endpointUrl); + return null; + } + + X509Certificate x509Cert = (X509Certificate) serverCertificates[0]; + byte[] encoded = x509Cert.getEncoded(); + return "-----BEGIN CERTIFICATE-----\n" // + + BASE64_ENCODER.encodeToString(encoded) // + + "\n-----END CERTIFICATE-----\n"; + + } + } catch (CertificateEncodingException | IOException e) { + LOG.log(Level.WARNING, e, () -> "Failed to download server certificate from " + endpointUrl); + return null; + } + } +} 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 new file mode 100644 index 00000000..873992e6 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CaasBindingPropertiesSupplierTest.java @@ -0,0 +1,226 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.PemFileCreator; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.ServerCertificateDownloader; +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 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 CaasBindingPropertiesSupplierTest { + + @Mock + private CaasServiceProvider serviceProvider; + + @Mock + private PemFileCreator pemFileCreator; + + @Mock + private ServerCertificateDownloader serverCertificateDownloader; + + @Mock + private CloudFoundryServiceInstance serviceInstance; + + @Mock + private CloudFoundryCredentials credentials; + + @Mock + private File serverCertFile; + + @Mock + private File clientCertFile; + + @Mock + private File clientKeyFile; + + private CaasBindingPropertiesSupplier supplier; + + @BeforeEach + void setUp() throws IOException { + lenient().when(serviceProvider.get()).thenReturn(serviceInstance); + lenient().when(serviceInstance.getCredentials()).thenReturn(credentials); + lenient().when(serviceInstance.getName()).thenReturn("test-caas-service"); + lenient().when(serverCertFile.getAbsolutePath()).thenReturn("/tmp/server.crt"); + lenient().when(clientCertFile.getAbsolutePath()).thenReturn("/tmp/client.crt"); + lenient().when(clientKeyFile.getAbsolutePath()).thenReturn("/tmp/client.key"); + + supplier = new CaasBindingPropertiesSupplier(serviceProvider, pemFileCreator, serverCertificateDownloader); + } + + @Test + void shouldReturnEmptyMapWhenNoServiceInstanceFound() { + when(serviceProvider.get()).thenReturn(null); + + Map result = supplier.get(); + + assertThat(result).isEmpty(); + } + + @Test + void shouldReturnEmptyMapWhenServiceHasNoCredentials() { + when(serviceInstance.getCredentials()).thenReturn(null); + + Map result = supplier.get(); + + assertThat(result).isEmpty(); + } + + @Test + void shouldReturnEmptyMapWhenEndpointUrlIsNull() { + when(credentials.getString("http-url")).thenReturn(null); + + Map result = supplier.get(); + + assertThat(result).isEmpty(); + } + + @Test + void shouldReturnEmptyMapWhenEndpointUrlIsBlank() { + when(credentials.getString("http-url")).thenReturn(" "); + + Map result = supplier.get(); + + assertThat(result).isEmpty(); + } + + @Test + void shouldReturnBasicPropertiesWithoutTlsWhenClientCertMissing() { + when(credentials.getString("http-url")).thenReturn("https://caas.example.com:4318"); + when(credentials.getString("tls.crt")).thenReturn(null); + when(credentials.getString("tls.key")).thenReturn("client-key"); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://caas.example.com:4318") + .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.key"); + } + + @Test + void shouldReturnBasicPropertiesWithoutTlsWhenClientKeyMissing() { + 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(null); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://caas.example.com:4318") + .containsEntry("otel.exporter.otlp.protocol", "http/protobuf") + .containsEntry("otel.exporter.otlp.compression", "gzip") + .doesNotContainKey("otel.exporter.otlp.certificate"); + } + + @Test + void shouldReturnBasicPropertiesWhenServerCertDownloadFails() { + 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(null); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://caas.example.com:4318") + .containsEntry("otel.exporter.otlp.protocol", "http/protobuf") + .containsEntry("otel.exporter.otlp.compression", "gzip") + .doesNotContainKey("otel.exporter.otlp.certificate"); + } + + @Test + void shouldReturnBasicPropertiesWhenServerCertIsBlank() { + 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(" "); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://caas.example.com:4318") + .doesNotContainKey("otel.exporter.otlp.certificate"); + } + + @Test + void shouldReturnBasicPropertiesWhenPemFileCreationFails() 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"); + when(pemFileCreator.writeFile(anyString(), anyString(), anyString())).thenThrow( + new IOException("Failed to write file")); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://caas.example.com:4318") + .containsEntry("otel.exporter.otlp.protocol", "http/protobuf") + .containsEntry("otel.exporter.otlp.compression", "gzip") + .doesNotContainKey("otel.exporter.otlp.certificate"); + } + + @Test + void shouldReturnFullPropertiesWithTlsConfiguration() throws IOException { + when(credentials.getString("http-url")).thenReturn("https://caas.example.com:4318"); + when(credentials.getString("tls.crt")).thenReturn("client-cert-content"); + when(credentials.getString("tls.key")).thenReturn("client-key-content"); + when(serverCertificateDownloader.download("https://caas.example.com:4318")).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-content"))).thenReturn( + clientCertFile); + when(pemFileCreator.writeFile(eq("caas-client-key-"), eq(".key"), eq("client-key-content"))).thenReturn( + clientKeyFile); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://caas.example.com:4318") + .containsEntry("otel.exporter.otlp.protocol", "http/protobuf") + .containsEntry("otel.exporter.otlp.compression", "gzip") + .containsEntry("otel.exporter.otlp.certificate", "/tmp/server.crt") + .containsEntry("otel.exporter.otlp.client.certificate", "/tmp/client.crt") + .containsEntry("otel.exporter.otlp.client.key", "/tmp/client.key"); + } + + @Test + void shouldReplacePlaceholderInEndpointUrl() { + when(credentials.getString("http-url")).thenReturn("https://caas.example.com:"); + + Map result = supplier.get(); + + assertThat(result).containsEntry("otel.exporter.otlp.endpoint", "https://caas.example.com:4318"); + } + + @Test + void shouldNotDownloadServerCertWhenClientCredentialsMissing() { + when(credentials.getString("http-url")).thenReturn("https://caas.example.com:4318"); + when(credentials.getString("tls.crt")).thenReturn(null); + when(credentials.getString("tls.key")).thenReturn(null); + + supplier.get(); + + verify(serverCertificateDownloader, never()).download(anyString()); + } + + @Test + void shouldNotCreatePemFilesWhenServerCertDownloadFails() 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(null); + + supplier.get(); + + verify(pemFileCreator, never()).writeFile(anyString(), anyString(), anyString()); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudLoggingBindingPropertiesSupplierTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudLoggingBindingPropertiesSupplierTest.java index a6073ee9..b0d4cdb0 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudLoggingBindingPropertiesSupplierTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudLoggingBindingPropertiesSupplierTest.java @@ -1,22 +1,21 @@ package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; -import org.assertj.core.api.AbstractStringAssert; -import org.jetbrains.annotations.NotNull; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.PemFileCreator; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; +import java.io.File; import java.util.List; import java.util.Map; import java.util.stream.Stream; import static java.util.Map.entry; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -33,13 +32,15 @@ public class CloudLoggingBindingPropertiesSupplierTest { @Mock private CloudLoggingServicesProvider servicesProvider; + @Mock + private PemFileCreator pemFileCreator; + @InjectMocks private CloudLoggingBindingPropertiesSupplier propertiesSupplier; - @NotNull - private static AbstractStringAssert assertFileContent(String filename) throws IOException { - List lines = Files.readAllLines(Paths.get(filename)); - return assertThat(String.join("\n", lines)); + @AfterEach + void assertNoUnexpectedInteractions() { + verifyNoMoreInteractions(servicesProvider, pemFileCreator); } @Test @@ -57,8 +58,14 @@ void extractsBinding() throws Exception { .add("ingest-otlp-cert", "test-client-cert").add("server-ca", "test-server-cert") .build(); when(servicesProvider.get()).thenReturn(Stream.of(defaultInstance().credentials(credentials).build())); + when(pemFileCreator.writeFile("cloud-logging-client", ".key", "test-client-key")).thenReturn( + new File("client-key-file")); + when(pemFileCreator.writeFile("cloud-logging-client", ".cert", "test-client-cert")).thenReturn( + new File("client-cert-file")); + when(pemFileCreator.writeFile("cloud-logging-server", ".cert", "test-server-cert")).thenReturn( + new File("server-cert-file")); CloudLoggingBindingPropertiesSupplier propertiesSupplier = - new CloudLoggingBindingPropertiesSupplier(servicesProvider); + new CloudLoggingBindingPropertiesSupplier(servicesProvider, pemFileCreator); Map properties = propertiesSupplier.get(); @@ -66,9 +73,6 @@ void extractsBinding() throws Exception { .containsKey("otel.exporter.otlp.client.key") .containsKey("otel.exporter.otlp.client.certificate") .containsKey("otel.exporter.otlp.certificate"); - assertFileContent(properties.get("otel.exporter.otlp.client.key")).isEqualTo("test-client-key"); - assertFileContent(properties.get("otel.exporter.otlp.client.certificate")).isEqualTo("test-client-cert"); - assertFileContent(properties.get("otel.exporter.otlp.certificate")).isEqualTo("test-server-cert"); } private static CloudFoundryServiceInstance.Builder defaultInstance() { @@ -83,7 +87,7 @@ void emptyWithoutEndpoint() { .build(); when(servicesProvider.get()).thenReturn(Stream.of(defaultInstance().credentials(credentials).build())); CloudLoggingBindingPropertiesSupplier propertiesSupplier = - new CloudLoggingBindingPropertiesSupplier(servicesProvider); + new CloudLoggingBindingPropertiesSupplier(servicesProvider, pemFileCreator); Map properties = propertiesSupplier.get(); @@ -98,7 +102,7 @@ void emptyWithoutClientCert() { .build(); when(servicesProvider.get()).thenReturn(Stream.of(defaultInstance().credentials(credentials).build())); CloudLoggingBindingPropertiesSupplier propertiesSupplier = - new CloudLoggingBindingPropertiesSupplier(servicesProvider); + new CloudLoggingBindingPropertiesSupplier(servicesProvider, pemFileCreator); Map properties = propertiesSupplier.get(); @@ -113,7 +117,7 @@ void emptyWithoutClientKey() { .build(); when(servicesProvider.get()).thenReturn(Stream.of(defaultInstance().credentials(credentials).build())); CloudLoggingBindingPropertiesSupplier propertiesSupplier = - new CloudLoggingBindingPropertiesSupplier(servicesProvider); + new CloudLoggingBindingPropertiesSupplier(servicesProvider, pemFileCreator); Map properties = propertiesSupplier.get(); @@ -128,7 +132,7 @@ void emptyWithoutServerCert() { .add("ingest-otlp-cert", "test-client-cert").build(); when(servicesProvider.get()).thenReturn(Stream.of(defaultInstance().credentials(credentials).build())); CloudLoggingBindingPropertiesSupplier propertiesSupplier = - new CloudLoggingBindingPropertiesSupplier(servicesProvider); + new CloudLoggingBindingPropertiesSupplier(servicesProvider, pemFileCreator); Map properties = propertiesSupplier.get(); diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/DefaultOtelBackendPropertiesSupplierTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/DefaultOtelBackendPropertiesSupplierTest.java new file mode 100644 index 00000000..cf8d0239 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/DefaultOtelBackendPropertiesSupplierTest.java @@ -0,0 +1,37 @@ +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.DefaultOtelBackendPropertiesSupplier; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static java.util.Collections.emptyMap; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class DefaultOtelBackendPropertiesSupplierTest { + + @Test + void shouldReturnEmptyMapWhenNoSuppliersConfigured() { + DefaultOtelBackendPropertiesSupplier supplier = DefaultOtelBackendPropertiesSupplier.builder().build(); + + assertThat(supplier.get()).isEmpty(); + } + + @Test + void shouldReturnFirstNonEmptyMap() { + DefaultOtelBackendPropertiesSupplier supplier = + DefaultOtelBackendPropertiesSupplier.builder().add(() -> emptyMap()).add(() -> Map.of("key1", "value1")) + .add(() -> Map.of("key2", "value2")).build(); + + assertThat(supplier.get()).containsExactlyEntriesOf(Map.of("key1", "value1")); + } + + @Test + void shouldReturnEmptyMapWhenAllSuppliersReturnEmpty() { + DefaultOtelBackendPropertiesSupplier supplier = + DefaultOtelBackendPropertiesSupplier.builder().add(() -> emptyMap()).add(() -> emptyMap()).build(); + + assertThat(supplier.get()).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 33cc8b9d..8ae309d5 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 @@ -48,6 +48,8 @@ private static Stream provideStringProperties() { "sap.cloudfoundry.otel.resources.format", "OTEL"), of(ExtensionConfigurations.RESOURCE.CLOUD_FOUNDRY.FORMAT, "otel.javaagent.extension.sap.cf.resource.format", "OTEL"), + of(ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.CAAS.LABEL, + "sap.caas.cf.binding.label.value", "caas"), of(ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.CLOUD_LOGGING.LABEL, "sap.cloud-logging.cf.binding.label.value", "cls"), of(ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.CLOUD_LOGGING.LABEL, diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemFileCreatorTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemFileCreatorTest.java new file mode 100644 index 00000000..22458df6 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/PemFileCreatorTest.java @@ -0,0 +1,62 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PemFileCreatorTest { + + @Test + void shouldCreateFileWithValidContent() throws IOException { + PemFileCreator creator = new PemFileCreator(); + String content = "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"; + + File file = creator.writeFile("test-cert", ".pem", content); + + assertThat(file).isNotNull().exists().isFile().hasName(file.getName()).content().isEqualTo(content); + assertThat(file.getName()).startsWith("test-cert").endsWith(".pem"); + } + + @Test + void shouldPreserveContentExactly() throws IOException { + PemFileCreator creator = new PemFileCreator(); + String content = "Line1\nLine2\nLine3\n"; + + File file = creator.writeFile("test", ".txt", content); + + assertThat(file).content().isEqualTo(content); + } + + @Test + void shouldCreateEmptyFile() throws IOException { + PemFileCreator creator = new PemFileCreator(); + + File file = creator.writeFile("empty", ".pem", ""); + + assertThat(file).exists().isEmpty().canRead(); + } + + @Test + void shouldCreateMultipleFiles() throws IOException { + PemFileCreator creator = new PemFileCreator(); + + File file1 = creator.writeFile("test1", ".pem", "content1"); + File file2 = creator.writeFile("test2", ".pem", "content2"); + + assertThat(file1.getAbsolutePath()).isNotEqualTo(file2.getAbsolutePath()); + assertThat(file1).content().isEqualTo("content1"); + assertThat(file2).content().isEqualTo("content2"); + } + + @Test + void shouldPropagateIOException() { + PemFileCreator creator = new PemFileCreator(); + + assertThatThrownBy(() -> creator.writeFile("\0invalid", ".pem", "content")).isInstanceOf(IOException.class); + } + +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloaderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloaderTest.java new file mode 100644 index 00000000..9bba4c75 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/tls/ServerCertificateDownloaderTest.java @@ -0,0 +1,132 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls; + +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 javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ServerCertificateDownloaderTest { + + private static final byte[] CERT_BYTES = "test-certificate-data".getBytes(StandardCharsets.UTF_8); + + @Mock + private SSLSocketFactory sslSocketFactory; + + @Mock + private SSLSocket sslSocket; + + @Mock + private SSLSession sslSession; + + @Mock + private X509Certificate certificate; + + private ServerCertificateDownloader downloader; + + @BeforeEach + void setUp() throws Exception { + // Common mock setup for happy path scenarios + lenient().when(certificate.getEncoded()).thenReturn(CERT_BYTES); + lenient().when(sslSession.getPeerCertificates()).thenReturn(new Certificate[] { certificate }); + lenient().when(sslSocket.getSession()).thenReturn(sslSession); + lenient().when(sslSocketFactory.createSocket(anyString(), anyInt())).thenReturn(sslSocket); + + downloader = new ServerCertificateDownloader(sslSocketFactory); + } + + @Test + void shouldDownloadCertificateSuccessfully() throws Exception { + String result = downloader.download("https://example.com:443"); + + assertThat(result).isNotNull().startsWith("-----BEGIN CERTIFICATE-----\n") + .endsWith("-----END CERTIFICATE-----\n").contains("\n"); + verify(sslSocket).startHandshake(); + verify(sslSocket).close(); + } + + @Test + void shouldUseDefaultPortWhenNotSpecified() throws Exception { + String result = downloader.download("https://example.com"); + + assertThat(result).isNotNull(); + verify(sslSocketFactory).createSocket("example.com", 443); + } + + @Test + void shouldReturnNullWhenNoCertificatesFound() throws Exception { + when(sslSession.getPeerCertificates()).thenReturn(new Certificate[0]); + + String result = downloader.download("https://example.com:443"); + + assertThat(result).isNull(); + } + + @Test + void shouldReturnNullWhenSSLSocketFactoryIsNull() { + downloader = new ServerCertificateDownloader(null); + + String result = downloader.download("https://example.com:443"); + + assertThat(result).isNull(); + } + + @Test + void shouldReturnNullOnIOException() throws Exception { + when(sslSocketFactory.createSocket(anyString(), anyInt())).thenThrow(new IOException("Connection failed")); + + String result = downloader.download("https://example.com:443"); + + assertThat(result).isNull(); + } + + @Test + void shouldReturnNullOnCertificateEncodingException() throws Exception { + when(certificate.getEncoded()).thenThrow(new CertificateEncodingException("Encoding failed")); + + String result = downloader.download("https://example.com:443"); + + assertThat(result).isNull(); + } + + @Test + void shouldReturnNullForInvalidUrl() { + String result = downloader.download("not-a-valid-url"); + + assertThat(result).isNull(); + } + + @Test + void shouldHandleCustomPort() throws IOException { + String result = downloader.download("https://example.com:4318"); + + assertThat(result).isNotNull(); + verify(sslSocketFactory).createSocket("example.com", 4318); + } + + @Test + void shouldEncodeCertificateInBase64WithLineBreaks() throws Exception { + byte[] largeCertBytes = "A".repeat(100).getBytes(); + when(certificate.getEncoded()).thenReturn(largeCertBytes); + + String result = downloader.download("https://example.com"); + + assertThat(result).isNotNull().contains("\n").matches( + "-----BEGIN CERTIFICATE-----\\n([A-Za-z0-9+/=\\n]{1,64}\\n)+-----END CERTIFICATE-----\\n"); + } +}