diff --git a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/v2/ResourceType.java b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/v2/ResourceType.java index 0846c93b82..70d6e00990 100644 --- a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/v2/ResourceType.java +++ b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/cf/v2/ResourceType.java @@ -9,7 +9,9 @@ public enum ResourceType { MANAGED_SERVICE("managed-service", SupportedParameters.SERVICE, SupportedParameters.SERVICE_PLAN), USER_PROVIDED_SERVICE( - "user-provided-service"), EXISTING_SERVICE("existing-service"), EXISTING_SERVICE_KEY("existing-service-key"); + "user-provided-service"), EXISTING_SERVICE("existing-service"), EXISTING_SERVICE_KEY( + "existing-service-key"), EXTERNAL_LOGGING_SERVICE( + "external-logging-service", SupportedParameters.SERVICE_KEY_NAME); private final String name; private final Set requiredParameters = new HashSet<>(); diff --git a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/model/ExternalLoggingServiceConfiguration.java b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/model/ExternalLoggingServiceConfiguration.java new file mode 100644 index 0000000000..38371ce9fa --- /dev/null +++ b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/model/ExternalLoggingServiceConfiguration.java @@ -0,0 +1,22 @@ +package org.cloudfoundry.multiapps.controller.core.model; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.cloudfoundry.multiapps.common.Nullable; +import org.immutables.value.Value; + +@Value.Immutable +@JsonSerialize(as = ImmutableExternalLoggingServiceConfiguration.class) +@JsonDeserialize(as = ImmutableExternalLoggingServiceConfiguration.class) +public interface ExternalLoggingServiceConfiguration { + + String getServiceInstanceName(); + + String getServiceKeyName(); + + @Nullable + String getTargetOrg(); + + @Nullable + String getTargetSpace(); +} diff --git a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/model/SupportedParameters.java b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/model/SupportedParameters.java index c3b90dad68..d8edfc9ba8 100644 --- a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/model/SupportedParameters.java +++ b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/model/SupportedParameters.java @@ -209,7 +209,8 @@ public class SupportedParameters { SERVICE_KEY_NAME, SERVICE_NAME, SERVICE_PLAN, SERVICE_TAGS, SERVICE_BROKER, SKIP_SERVICE_UPDATES, TYPE, PROVIDER_ID, PROVIDER_NID, TARGET, SERVICE_CONFIG_PATH, FILTER, MANAGED, VERSION, PATH, MEMORY, - FAIL_ON_SERVICE_UPDATE, SERVICE_PROVIDER, SERVICE_VERSION); + FAIL_ON_SERVICE_UPDATE, SERVICE_PROVIDER, SERVICE_VERSION, + ORGANIZATION_NAME, SPACE_NAME); public static final Set GLOBAL_PARAMETERS = Set.of(KEEP_EXISTING_ROUTES, APPS_UPLOAD_TIMEOUT, APPS_TASK_EXECUTION_TIMEOUT, APPS_START_TIMEOUT, APPS_STAGE_TIMEOUT, APPLY_NAMESPACE, ENABLE_PARALLEL_DEPLOYMENTS, DEPLOY_MODE); diff --git a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/util/CloudModelBuilderUtil.java b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/util/CloudModelBuilderUtil.java index 2f399c74f1..f3b77c31d5 100644 --- a/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/util/CloudModelBuilderUtil.java +++ b/multiapps-controller-core/src/main/java/org/cloudfoundry/multiapps/controller/core/util/CloudModelBuilderUtil.java @@ -82,7 +82,7 @@ public static ResourceType getResourceType(Resource resource) { return ResourceType.get(type); } - private static String getServiceName(Resource resource) { + public static String getServiceName(Resource resource) { var serviceName = (String) resource.getParameters() .get(SupportedParameters.SERVICE_NAME); return serviceName == null ? resource.getName() : serviceName; diff --git a/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/model/ExternalOperationLogEntry.java b/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/model/ExternalOperationLogEntry.java new file mode 100644 index 0000000000..32496eb806 --- /dev/null +++ b/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/model/ExternalOperationLogEntry.java @@ -0,0 +1,24 @@ +package org.cloudfoundry.multiapps.controller.persistence.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.cloudfoundry.multiapps.common.Nullable; +import org.immutables.value.Value; + +@Value.Immutable +@JsonSerialize(as = ImmutableExternalOperationLogEntry.class) +@JsonDeserialize(as = ImmutableExternalOperationLogEntry.class) +public interface ExternalOperationLogEntry { + + @JsonProperty("msg") + String getMessage(); + + @JsonProperty("date") + String getTimestamp(); + + @JsonProperty("correlation_id") + @Nullable + String getCorrelationId(); + +} diff --git a/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/query/providers/SqlOperationLogQueryProvider.java b/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/query/providers/SqlOperationLogQueryProvider.java index a438a7e574..e5f127408d 100644 --- a/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/query/providers/SqlOperationLogQueryProvider.java +++ b/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/query/providers/SqlOperationLogQueryProvider.java @@ -20,11 +20,11 @@ public class SqlOperationLogQueryProvider { private static final String ID_COLUMN_LABEL = "id"; private static final String OPERATION_LOG_COLUMN_LABEL = "operation_log"; private static final String OPERATION_LOG_NAME_COLUMN_LABEL = "operation_log_name"; - private static final String SELECT_LOGS_BY_SPACE_ID_OPERATION_ID_AND_OPERATION_LOG_NAME = "SELECT ID, OPERATION_LOG, OPERATION_LOG_NAME FROM %s WHERE SPACE=? AND OPERATION_ID=? AND OPERATION_LOG_NAME=? ORDER BY MODIFIED ASC"; + private static final String OPERATION_LOG_MODIFIED_COLUMN_LABEL = "modified"; + private static final String SELECT_LOGS_BY_SPACE_ID_OPERATION_ID_AND_OPERATION_LOG_NAME = "SELECT ID, OPERATION_LOG, OPERATION_LOG_NAME, MODIFIED FROM %s WHERE SPACE=? AND OPERATION_ID=? AND OPERATION_LOG_NAME=? ORDER BY MODIFIED ASC"; private static final String SELECT_LOGS_BY_SPACE_ID_AND_NAME = "SELECT DISTINCT ID, OPERATION_LOG, OPERATION_LOG_NAME, MODIFIED FROM %s WHERE SPACE=? AND OPERATION_ID=? ORDER BY MODIFIED ASC"; private final String tableName; - public SqlOperationLogQueryProvider(String tableName) { this.tableName = tableName; } @@ -115,6 +115,8 @@ private OperationLogEntry getOperationLogEntry(ResultSet resultSet) throws SQLEx .id(resultSet.getString(ID_COLUMN_LABEL)) .operationLog(resultSet.getString(OPERATION_LOG_COLUMN_LABEL)) .operationLogName(resultSet.getString(OPERATION_LOG_NAME_COLUMN_LABEL)) + .modified(resultSet.getTimestamp(OPERATION_LOG_MODIFIED_COLUMN_LABEL) + .toLocalDateTime()) .build(); } } diff --git a/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/services/ProcessLoggerPersister.java b/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/services/ProcessLoggerPersister.java index 66474371a5..23afcea620 100644 --- a/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/services/ProcessLoggerPersister.java +++ b/multiapps-controller-persistence/src/main/java/org/cloudfoundry/multiapps/controller/persistence/services/ProcessLoggerPersister.java @@ -1,17 +1,21 @@ package org.cloudfoundry.multiapps.controller.persistence.services; -import jakarta.inject.Inject; -import jakarta.inject.Named; -import org.cloudfoundry.multiapps.controller.persistence.model.ImmutableOperationLogEntry; -import org.cloudfoundry.multiapps.controller.persistence.model.OperationLogEntry; -import org.springframework.scheduling.annotation.Async; - import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import org.cloudfoundry.multiapps.controller.persistence.model.ExternalOperationLogEntry; +import org.cloudfoundry.multiapps.controller.persistence.model.ImmutableExternalOperationLogEntry; +import org.cloudfoundry.multiapps.controller.persistence.model.ImmutableOperationLogEntry; +import org.cloudfoundry.multiapps.controller.persistence.model.OperationLogEntry; +import org.springframework.scheduling.annotation.Async; + @Named("processLoggerPersister") public class ProcessLoggerPersister { @@ -29,6 +33,7 @@ public ProcessLoggerPersister(ProcessLoggerProvider processLoggerProvider, public void persistLogs(String correlationId, String taskId) { List processLoggers = processLoggerProvider.getExistingLoggers(correlationId, taskId); Map processLogsMessages = new HashMap<>(); + List externalOperationLogEntries = new ArrayList<>(); if (processLoggers.isEmpty()) { return; @@ -61,6 +66,14 @@ public void persistLogs(String correlationId, String taskId) { .withOperationLog(processLogsMessage.getValue() .toString()) .withModified(LocalDateTime.now()); + externalOperationLogEntries.add(ImmutableExternalOperationLogEntry.builder() + .timestamp(String.valueOf(LocalDateTime.now() + .toEpochSecond( + ZoneOffset.UTC))) + .correlationId(correlationId) + .message(processLogsMessage.getValue() + .toString()) + .build()); processLogsPersistenceService.persistLog(operationLogEntry); } } diff --git a/multiapps-controller-process/src/main/java/module-info.java b/multiapps-controller-process/src/main/java/module-info.java index 231bedb9e5..625a2ffc16 100644 --- a/multiapps-controller-process/src/main/java/module-info.java +++ b/multiapps-controller-process/src/main/java/module-info.java @@ -56,5 +56,9 @@ requires static java.compiler; requires static org.immutables.value; + requires spring.webflux; + requires annotations; + requires reactor.netty.core; + requires reactor.netty.http; } \ No newline at end of file diff --git a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/listeners/EndProcessListener.java b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/listeners/EndProcessListener.java index 574ba2cfc7..e321642551 100644 --- a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/listeners/EndProcessListener.java +++ b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/listeners/EndProcessListener.java @@ -2,7 +2,6 @@ import jakarta.inject.Inject; import jakarta.inject.Named; - import org.cloudfoundry.multiapps.controller.api.model.Operation; import org.cloudfoundry.multiapps.controller.api.model.ProcessType; import org.cloudfoundry.multiapps.controller.core.util.ApplicationConfiguration; @@ -67,4 +66,5 @@ private void publishDynatraceEvent(DelegateExecution execution, ProcessType proc .build(); dynatracePublisher.publishProcessEvent(finishedEvent, getLogger()); } + } \ No newline at end of file diff --git a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/steps/BuildCloudDeployModelStep.java b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/steps/BuildCloudDeployModelStep.java index 8057ba4423..3bf70f11e9 100644 --- a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/steps/BuildCloudDeployModelStep.java +++ b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/steps/BuildCloudDeployModelStep.java @@ -28,12 +28,14 @@ import org.cloudfoundry.multiapps.controller.core.helpers.ModuleToDeployHelper; import org.cloudfoundry.multiapps.controller.core.model.DeployedMta; import org.cloudfoundry.multiapps.controller.core.model.DeployedMtaApplication; +import org.cloudfoundry.multiapps.controller.core.model.ExternalLoggingServiceConfiguration; import org.cloudfoundry.multiapps.controller.core.model.SupportedParameters; import org.cloudfoundry.multiapps.controller.core.security.serialization.SecureSerialization; import org.cloudfoundry.multiapps.controller.core.util.CloudModelBuilderUtil; import org.cloudfoundry.multiapps.controller.core.util.NameUtil; import org.cloudfoundry.multiapps.controller.process.Messages; import org.cloudfoundry.multiapps.controller.process.util.DeprecatedBuildpackChecker; +import org.cloudfoundry.multiapps.controller.process.util.ExternalLoggingServiceConfigurationsCalculator; import org.cloudfoundry.multiapps.controller.process.util.ProcessTypeParser; import org.cloudfoundry.multiapps.controller.process.variables.Variables; import org.cloudfoundry.multiapps.mta.builders.v2.ParametersChainBuilder; @@ -130,6 +132,13 @@ protected StepPhase executeStep(ProcessContext context) { List> batchesToProcess = getResourceBatches(context, resourcesForDeployment); context.setVariable(Variables.BATCHES_TO_PROCESS, batchesToProcess); getStepLogger().debug(Messages.CALCULATING_RESOURCE_BATCHES_COMPLETE); + + List externalLoggingServiceConfigurations = calculateExternalLoggingServiceConfigurations( + context, deploymentDescriptor); + context.setVariable(Variables.EXTERNAL_LOGGING_SERVICE_CONFIGURATIONS, externalLoggingServiceConfigurations); + getStepLogger().debug("External logging service configurations: {0}", + SecureSerialization.toJson(externalLoggingServiceConfigurations)); + getStepLogger().debug(Messages.CLOUD_MODEL_BUILT); return StepPhase.DONE; } @@ -331,4 +340,11 @@ private List getDomainsFromApps(ProcessContext context, DeploymentDescri return new ArrayList<>(domains); } + private List calculateExternalLoggingServiceConfigurations(ProcessContext context, + DeploymentDescriptor deploymentDescriptor) { + ExternalLoggingServiceConfigurationsCalculator calculator = new ExternalLoggingServiceConfigurationsCalculator( + context.getControllerClient()); + return calculator.calculateExternalLoggingServiceConfigurations(deploymentDescriptor.getResources()); + } + } diff --git a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/steps/ExportLogs.java b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/steps/ExportLogs.java new file mode 100644 index 0000000000..1b4594654f --- /dev/null +++ b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/steps/ExportLogs.java @@ -0,0 +1,143 @@ +package org.cloudfoundry.multiapps.controller.process.steps; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import javax.net.ssl.SSLException; + +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import org.cloudfoundry.multiapps.common.SLException; +import org.cloudfoundry.multiapps.controller.client.facade.CloudControllerClient; +import org.cloudfoundry.multiapps.controller.client.facade.domain.CloudServiceKey; +import org.cloudfoundry.multiapps.controller.core.cf.CloudControllerClientFactory; +import org.cloudfoundry.multiapps.controller.core.model.ExternalLoggingServiceConfiguration; +import org.cloudfoundry.multiapps.controller.core.security.token.TokenService; +import org.cloudfoundry.multiapps.controller.persistence.services.ProcessLogsPersistenceService; +import org.cloudfoundry.multiapps.controller.process.util.OperationLogsExporter; +import org.cloudfoundry.multiapps.controller.process.variables.Variables; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Scope; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +@Named("exportLogs") +@Scope(BeanDefinition.SCOPE_PROTOTYPE) +public class ExportLogs extends SyncFlowableStep { + + private static final Logger LOGGER = LoggerFactory.getLogger(ExportLogs.class); + + private ProcessLogsPersistenceService processLogsPersistenceService; + private CloudControllerClientFactory clientFactory; + private TokenService tokenService; + + @Inject + public ExportLogs(ProcessLogsPersistenceService processLogsPersistenceService, CloudControllerClientFactory clientFactory, + TokenService tokenService) { + this.processLogsPersistenceService = processLogsPersistenceService; + this.clientFactory = clientFactory; + this.tokenService = tokenService; + } + + @Override + protected StepPhase executeStep(ProcessContext context) throws Exception { + getStepLogger().debug("Prepare to export operation logs to external logging service."); + CloudControllerClient client = context.getControllerClient(); + List externalLoggingServiceConfigurations = context.getVariable( + Variables.EXTERNAL_LOGGING_SERVICE_CONFIGURATIONS); + String currentTargetOrg = context.getVariable(Variables.ORGANIZATION_NAME); + String currentTargetSpace = context.getVariable(Variables.SPACE_NAME); + for (var externalLoggingServiceConfiguration : externalLoggingServiceConfigurations) { + String serviceInstanceName = externalLoggingServiceConfiguration.getServiceInstanceName(); + String serviceKeyName = externalLoggingServiceConfiguration.getServiceKeyName(); + String targetOrg = externalLoggingServiceConfiguration.getTargetOrg() == null + ? currentTargetOrg + : externalLoggingServiceConfiguration.getTargetOrg(); + String targetSpace = externalLoggingServiceConfiguration.getTargetSpace() == null + ? currentTargetSpace + : externalLoggingServiceConfiguration.getTargetSpace(); + + if (!targetOrg.equals(currentTargetOrg) || !targetSpace.equals(currentTargetSpace)) { + client = clientFactory.createClient(tokenService.getToken(null, context.getVariable(Variables.USER_GUID)), targetOrg, + targetSpace, context.getVariable(Variables.CORRELATION_ID)); + } + exportOperationLogsToExternalSystem(context, client, serviceInstanceName, serviceKeyName); + } + return StepPhase.DONE; + } + + private void exportOperationLogsToExternalSystem(ProcessContext context, CloudControllerClient client, String serviceInstanceName, + String serviceKeyName) { + String correlationId = context.getVariable(Variables.CORRELATION_ID); + String spaceId = context.getVariable(Variables.SPACE_GUID); + CloudServiceKey loggingServiceKey = client.getServiceKey(serviceInstanceName, serviceKeyName); + if (loggingServiceKey == null) { + getStepLogger().warn("No logging service key found for operation {0}, skipping log export", correlationId); + return; + } + LOGGER.info("Exporting operation logs to external system using service key: {}", loggingServiceKey.getName()); + Map credentials = loggingServiceKey.getCredentials(); + String endpoint = (String) credentials.get("ingest-mtls-endpoint"); + String serverCa = (String) credentials.get("server-ca"); + String ingestMtlsCert = (String) credentials.get("ingest-mtls-cert"); + String ingestMtlsKey = (String) credentials.get("ingest-mtls-key"); + + // Validate that all required credentials are present + if (endpoint == null || serverCa == null || ingestMtlsCert == null || ingestMtlsKey == null) { + getStepLogger().warn( + "Missing required credentials for SAP Cloud Logging export. Required: endpoint, server-ca, ingest-mtls-cert, ingest-mtls-key"); + return; + } + try { + OperationLogsExporter exporter = new OperationLogsExporter(processLogsPersistenceService, + createWebClientWithMtls(endpoint, serverCa, ingestMtlsCert, + ingestMtlsKey)); + exporter.exportLogs(spaceId, correlationId, endpoint, serverCa, ingestMtlsCert, ingestMtlsKey); + getStepLogger().info("Export of operation logs to external service instance \"{0}\" was successful", serviceInstanceName); + } catch (Exception e) { + getStepLogger().warn(e, "Export of operation logs to external service instance \"{0}\" failed: {1}", serviceInstanceName, + e.getMessage()); + throw new SLException(e, e.getMessage()); + } + + } + + private WebClient createWebClientWithMtls(String endpointUrl, String serverCa, String clientCert, String clientKey) + throws SSLException { + LOGGER.debug("Creating WebClient with mTLS configuration for endpoint: {}", endpointUrl); + + // Convert PEM strings to InputStreams + InputStream serverCaStream = new ByteArrayInputStream(serverCa.getBytes(StandardCharsets.UTF_8)); + InputStream clientCertStream = new ByteArrayInputStream(clientCert.getBytes(StandardCharsets.UTF_8)); + InputStream clientKeyStream = new ByteArrayInputStream(clientKey.getBytes(StandardCharsets.UTF_8)); + + // Create SSL context with client certificate and server CA + SslContext sslContext = SslContextBuilder.forClient() + .keyManager(clientCertStream, + clientKeyStream) // Client certificate and private key for mTLS + .trustManager(serverCaStream) // Server CA certificate for trust + .build(); + + // Create HTTP client with custom SSL context + HttpClient httpClient = HttpClient.create() + .secure(sslSpec -> sslSpec.sslContext(sslContext)); + + // Build WebClient with the custom HTTP client + return WebClient.builder() + .baseUrl(endpointUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); + } + + @Override + protected String getStepErrorMessage(ProcessContext context) { + return "Failure during export logs to external logging service."; + } +} diff --git a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/ExternalLoggingServiceConfigurationsCalculator.java b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/ExternalLoggingServiceConfigurationsCalculator.java new file mode 100644 index 0000000000..f3c3889dea --- /dev/null +++ b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/ExternalLoggingServiceConfigurationsCalculator.java @@ -0,0 +1,51 @@ +package org.cloudfoundry.multiapps.controller.process.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.cloudfoundry.multiapps.controller.client.facade.CloudControllerClient; +import org.cloudfoundry.multiapps.controller.core.cf.v2.ResourceType; +import org.cloudfoundry.multiapps.controller.core.model.ExternalLoggingServiceConfiguration; +import org.cloudfoundry.multiapps.controller.core.model.ImmutableExternalLoggingServiceConfiguration; +import org.cloudfoundry.multiapps.controller.core.model.SupportedParameters; +import org.cloudfoundry.multiapps.controller.core.util.CloudModelBuilderUtil; +import org.cloudfoundry.multiapps.controller.core.util.SpecialResourceTypesRequiredParametersUtil; +import org.cloudfoundry.multiapps.mta.model.Resource; + +public class ExternalLoggingServiceConfigurationsCalculator { + + private final CloudControllerClient client; + + public ExternalLoggingServiceConfigurationsCalculator(CloudControllerClient client) { + this.client = client; + } + + public List calculateExternalLoggingServiceConfigurations(List resources) { + var externalLoggingServices = resources.stream() + .filter(resource -> ResourceType.get(resource.getType()) + == ResourceType.EXTERNAL_LOGGING_SERVICE) + .toList(); + List externalLoggingServiceConfigurations = new ArrayList<>(); + for (Resource resource : externalLoggingServices) { + Map resourceParameters = resource.getParameters(); + SpecialResourceTypesRequiredParametersUtil.checkRequiredParameters(resource.getName(), ResourceType.EXTERNAL_LOGGING_SERVICE, + resourceParameters); + externalLoggingServiceConfigurations.add(ImmutableExternalLoggingServiceConfiguration.builder() + .serviceInstanceName( + CloudModelBuilderUtil.getServiceName( + resource)) + .serviceKeyName( + (String) resourceParameters.get( + SupportedParameters.SERVICE_KEY_NAME)) + .targetOrg((String) resourceParameters.get( + SupportedParameters.ORGANIZATION_NAME)) + .targetSpace( + (String) resourceParameters.get( + SupportedParameters.SPACE_NAME)) + .build()); + } + return externalLoggingServiceConfigurations; + } + +} diff --git a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/OperationInFinalStateHandler.java b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/OperationInFinalStateHandler.java index b9edeac622..d92ab9aee6 100644 --- a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/OperationInFinalStateHandler.java +++ b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/OperationInFinalStateHandler.java @@ -1,12 +1,18 @@ package org.cloudfoundry.multiapps.controller.process.util; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.text.MessageFormat; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import javax.net.ssl.SSLException; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; import jakarta.inject.Inject; import jakarta.inject.Named; import org.cloudfoundry.client.v3.Metadata; @@ -14,7 +20,9 @@ import org.cloudfoundry.multiapps.controller.api.model.Operation; import org.cloudfoundry.multiapps.controller.api.model.Operation.State; import org.cloudfoundry.multiapps.controller.api.model.ProcessType; +import org.cloudfoundry.multiapps.controller.client.facade.CloudControllerClient; import org.cloudfoundry.multiapps.controller.client.facade.domain.CloudApplication; +import org.cloudfoundry.multiapps.controller.client.facade.domain.CloudServiceKey; import org.cloudfoundry.multiapps.controller.core.cf.CloudControllerClientProvider; import org.cloudfoundry.multiapps.controller.core.cf.metadata.MtaMetadataAnnotations; import org.cloudfoundry.multiapps.controller.core.model.DeployedMta; @@ -28,6 +36,7 @@ import org.cloudfoundry.multiapps.controller.persistence.services.FileStorageException; import org.cloudfoundry.multiapps.controller.persistence.services.HistoricOperationEventService; import org.cloudfoundry.multiapps.controller.persistence.services.OperationService; +import org.cloudfoundry.multiapps.controller.persistence.services.ProcessLogsPersistenceService; import org.cloudfoundry.multiapps.controller.process.Messages; import org.cloudfoundry.multiapps.controller.process.dynatrace.DynatraceProcessDuration; import org.cloudfoundry.multiapps.controller.process.dynatrace.DynatracePublisher; @@ -38,6 +47,9 @@ import org.flowable.engine.delegate.DelegateExecution; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; import static java.text.MessageFormat.format; @@ -60,6 +72,9 @@ public class OperationInFinalStateHandler { private OperationTimeAggregator operationTimeAggregator; @Inject private DynatracePublisher dynatracePublisher; + @Inject + private ProcessLogsPersistenceService processLogsPersistenceService; + private final SafeExecutor safeExecutor = new SafeExecutor(); public void handle(DelegateExecution execution, ProcessType processType, Operation.State state) { @@ -74,6 +89,7 @@ private void handleInternal(DelegateExecution execution, ProcessType processType safeExecutor.execute(() -> setOperationState(correlationId, state)); safeExecutor.execute(() -> deletePreviousBackupDescriptors(execution, processType, state)); safeExecutor.execute(() -> trackOperationDuration(correlationId, execution, processType, state)); + // safeExecutor.execute(() -> exportOperationLogsToExternalSystem(execution)); } protected void deleteDeploymentFiles(String correlationId, DelegateExecution execution) throws FileStorageException { @@ -238,4 +254,65 @@ private void logProcessTime(String correlationId, String processId, ProcessTime processTime.getProcessDuration(), processTime.getDelayBetweenSteps())); } + private void exportOperationLogsToExternalSystem(DelegateExecution execution) { + String correlationId = VariableHandling.get(execution, Variables.CORRELATION_ID); + String spaceId = VariableHandling.get(execution, Variables.SPACE_GUID); + String userName = StepsUtil.determineCurrentUser(execution); + String userGuid = StepsUtil.determineCurrentUserGuid(execution); + CloudControllerClient client = clientProvider.getControllerClient(userName, userGuid, spaceId, correlationId); + CloudServiceKey loggingServiceKey = client.getServiceKey("test-logging", "test-key"); + if (loggingServiceKey != null) { + LOGGER.info("Exporting operation logs to external system using service key: {}", loggingServiceKey.getName()); + Map credentials = loggingServiceKey.getCredentials(); + String endpoint = (String) credentials.get("ingest-mtls-endpoint"); + String serverCa = (String) credentials.get("server-ca"); + String ingestMtlsCert = (String) credentials.get("ingest-mtls-cert"); + String ingestMtlsKey = (String) credentials.get("ingest-mtls-key"); + + // Validate that all required credentials are present + if (endpoint != null && serverCa != null && ingestMtlsCert != null && ingestMtlsKey != null) { + try { + OperationLogsExporter exporter = new OperationLogsExporter(processLogsPersistenceService, + createWebClientWithMtls(endpoint, serverCa, ingestMtlsCert, + ingestMtlsKey)); + exporter.exportLogs(spaceId, correlationId, endpoint, serverCa, ingestMtlsCert, ingestMtlsKey); + } catch (Exception e) { + LOGGER.error("Failure during export logs", e); + } + } else { + LOGGER.warn( + "Missing required credentials for SAP Cloud Logging export. Required: endpoint, server-ca, ingest-mtls-cert, ingest-mtls-key"); + } + } else { + LOGGER.debug("No logging service key found for operation {}, skipping log export", correlationId); + } + } + + private WebClient createWebClientWithMtls(String endpointUrl, String serverCa, String clientCert, String clientKey) + throws SSLException { + LOGGER.debug("Creating WebClient with mTLS configuration for endpoint: {}", endpointUrl); + + // Convert PEM strings to InputStreams + InputStream serverCaStream = new ByteArrayInputStream(serverCa.getBytes(StandardCharsets.UTF_8)); + InputStream clientCertStream = new ByteArrayInputStream(clientCert.getBytes(StandardCharsets.UTF_8)); + InputStream clientKeyStream = new ByteArrayInputStream(clientKey.getBytes(StandardCharsets.UTF_8)); + + // Create SSL context with client certificate and server CA + SslContext sslContext = SslContextBuilder.forClient() + .keyManager(clientCertStream, + clientKeyStream) // Client certificate and private key for mTLS + .trustManager(serverCaStream) // Server CA certificate for trust + .build(); + + // Create HTTP client with custom SSL context + HttpClient httpClient = HttpClient.create() + .secure(sslSpec -> sslSpec.sslContext(sslContext)); + + // Build WebClient with the custom HTTP client + return WebClient.builder() + .baseUrl(endpointUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); + } + } diff --git a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/OperationLogsExporter.java b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/OperationLogsExporter.java new file mode 100644 index 0000000000..fe496b4e31 --- /dev/null +++ b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/util/OperationLogsExporter.java @@ -0,0 +1,93 @@ +package org.cloudfoundry.multiapps.controller.process.util; + +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.cloudfoundry.multiapps.common.util.JsonUtil; +import org.cloudfoundry.multiapps.controller.persistence.model.ExternalOperationLogEntry; +import org.cloudfoundry.multiapps.controller.persistence.model.ImmutableExternalOperationLogEntry; +import org.cloudfoundry.multiapps.controller.persistence.model.OperationLogEntry; +import org.cloudfoundry.multiapps.controller.persistence.services.FileStorageException; +import org.cloudfoundry.multiapps.controller.persistence.services.ProcessLogsPersistenceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.reactive.function.client.WebClient; + +public class OperationLogsExporter { + + private static final Logger LOGGER = LoggerFactory.getLogger(OperationLogsExporter.class); + private static final String CONTENT_TYPE_JSON = "application/json"; + private static final long MAX_LIMIT_REQUEST_SIZE_BYTES = 3 * 1024 * 1024 + 512 * 1024; // 3.5MB + + private final ProcessLogsPersistenceService processLogsPersistenceService; + private final WebClient webClient; + + public OperationLogsExporter(ProcessLogsPersistenceService processLogsPersistenceService, WebClient webClient) { + this.processLogsPersistenceService = processLogsPersistenceService; + this.webClient = webClient; + } + + public void exportLogs(String spaceId, String operationId, String endpoint, String serverCa, String clientCert, String clientKey) + throws FileStorageException { + LOGGER.info("Export logs for operation {} in space {}", operationId, spaceId); + List externalLogEntries = getExternalLogEntries(spaceId, operationId); + List> logEntryBatches = getLogEntryBatches(externalLogEntries); + LOGGER.info("Exporting {} log entries into {} batches", externalLogEntries.size(), logEntryBatches.size()); + for (List logEntryBatch : logEntryBatches) { + webClient.post() + .header("Content-Type", CONTENT_TYPE_JSON) + .bodyValue(JsonUtil.toJson(logEntryBatch)) + .retrieve() + .bodyToMono(Void.class) + .block(); + } + + } + + private List getExternalLogEntries(String spaceId, String operationId) throws FileStorageException { + LOGGER.debug("Retrieving log entries for operation {} in space {}", operationId, spaceId); + + List operationLogEntries = processLogsPersistenceService.listOperationLogsBySpaceAndOperationId(spaceId, + operationId); + + return operationLogEntries.stream() + .map(logEntry -> convertToExternalLogEntry(operationId, logEntry)) + .collect(Collectors.toList()); + } + + private ExternalOperationLogEntry convertToExternalLogEntry(String operationId, OperationLogEntry logEntry) { + return ImmutableExternalOperationLogEntry.builder() + .timestamp(String.valueOf(logEntry.getModified() + .atOffset(ZoneOffset.UTC))) + .message(logEntry.getOperationLog()) + .correlationId(operationId) + .build(); + } + + private List> getLogEntryBatches(List externalLogEntries) { + List> batches = new ArrayList<>(); + List currentBatch = new ArrayList<>(); + long currentChunkSize = 0L; + + for (ExternalOperationLogEntry entry : externalLogEntries) { + String entryJson = JsonUtil.toJson(entry); + int entrySize = entryJson.getBytes().length; + + if (currentChunkSize + entrySize > MAX_LIMIT_REQUEST_SIZE_BYTES && !currentBatch.isEmpty()) { + batches.add(new ArrayList<>(currentBatch)); + currentBatch.clear(); + currentChunkSize = 0L; + } + + currentBatch.add(entry); + currentChunkSize += entrySize; + } + if (!currentBatch.isEmpty()) { + batches.add(currentBatch); + } + return batches; + } + +} diff --git a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/variables/Variables.java b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/variables/Variables.java index 5f8e987e7c..38140a6da2 100644 --- a/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/variables/Variables.java +++ b/multiapps-controller-process/src/main/java/org/cloudfoundry/multiapps/controller/process/variables/Variables.java @@ -29,6 +29,7 @@ import org.cloudfoundry.multiapps.controller.core.model.DeployedMtaServiceKey; import org.cloudfoundry.multiapps.controller.core.model.DynamicResolvableParameter; import org.cloudfoundry.multiapps.controller.core.model.ErrorType; +import org.cloudfoundry.multiapps.controller.core.model.ExternalLoggingServiceConfiguration; import org.cloudfoundry.multiapps.controller.core.model.IncrementalAppInstanceUpdateConfiguration; import org.cloudfoundry.multiapps.controller.core.model.Phase; import org.cloudfoundry.multiapps.controller.core.model.SubprocessPhase; @@ -912,4 +913,14 @@ public Serializer> getSerializer() { .name("processUserProvidedServices") .defaultValue(false) .build(); + + Variable> EXTERNAL_LOGGING_SERVICE_CONFIGURATIONS = ImmutableJsonStringListVariable. builder() + .name( + "externalLoggingServiceConfigurations") + .type( + Variable.typeReference( + ExternalLoggingServiceConfiguration.class)) + .defaultValue( + Collections.emptyList()) + .build(); } diff --git a/multiapps-controller-process/src/main/resources/org/cloudfoundry/multiapps/controller/process/xs2-bg-deploy.bpmn b/multiapps-controller-process/src/main/resources/org/cloudfoundry/multiapps/controller/process/xs2-bg-deploy.bpmn index 0764bbbfa7..41f3e4ba41 100644 --- a/multiapps-controller-process/src/main/resources/org/cloudfoundry/multiapps/controller/process/xs2-bg-deploy.bpmn +++ b/multiapps-controller-process/src/main/resources/org/cloudfoundry/multiapps/controller/process/xs2-bg-deploy.bpmn @@ -1,5 +1,5 @@ - + @@ -68,22 +68,20 @@ - - - - - - - + + + - + + + @@ -93,13 +91,19 @@ - + + + - + + + - + + + @@ -107,14 +111,18 @@ - + + + - + + + @@ -123,7 +131,9 @@ - + + + @@ -181,7 +191,9 @@ - + + + @@ -222,7 +234,9 @@ - + + + @@ -240,6 +254,19 @@ + + + + + + + + + + + + + @@ -319,7 +346,7 @@ - + @@ -432,347 +459,365 @@ - + + + + + + + - + - + - + - + - + + + + + - + - + - + - - - - + + + + - + - + - + - + - - - + + + - + - + - + - + - + - + - + - + - + - + - + - - - - + + + + - - - - + + + + - - - - - - + - + + + + + + - + - - - - + + + - + + + + + + - + - + - + - + - + + + + + - + - + - + - + - + - - - - + + + + - + - + - + - + - + - + - - - + + + - + - - - - + + + + - + - - - - + + + + - + - + - + - - - - + + + + - + - + - + - + - + - + - + - + - + - + - - - - + + + + - + - + - + - + diff --git a/multiapps-controller-process/src/main/resources/org/cloudfoundry/multiapps/controller/process/xs2-deploy.bpmn b/multiapps-controller-process/src/main/resources/org/cloudfoundry/multiapps/controller/process/xs2-deploy.bpmn index 0a7b944122..01a9222f21 100644 --- a/multiapps-controller-process/src/main/resources/org/cloudfoundry/multiapps/controller/process/xs2-deploy.bpmn +++ b/multiapps-controller-process/src/main/resources/org/cloudfoundry/multiapps/controller/process/xs2-deploy.bpmn @@ -1,5 +1,5 @@ - + @@ -46,13 +46,7 @@ - - - - - - @@ -75,10 +69,14 @@ - + + + - + + + @@ -95,13 +93,17 @@ - + + + - + + + @@ -113,7 +115,9 @@ - + + + @@ -162,7 +166,9 @@ - + + + @@ -171,6 +177,19 @@ + + + + + + + + + + + + + @@ -178,7 +197,7 @@ - + @@ -309,11 +328,17 @@ - + + + + + + + - + @@ -321,231 +346,243 @@ - + - + - + - - - + + + - + - + - + - + - + - + - + - + - + - + - - - + + + - + - - + + - + - + - - - + + + - + - + - + - + - - - - + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + - + - + - + - + - + - + - - - + + + - + - + - + + + + + - + - + - + + + + + - + - +