EntandoDatabaseServiceController.java

/*
 *
 * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved.
 *
 * This library is free software; you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation; either version 2.1 of the License, or (at your option)
 * any later version.
 *
 *  This library is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 *
 */

package org.entando.kubernetes.controller.databaseservice;

import static java.lang.String.format;
import static java.util.Optional.ofNullable;
import static org.entando.kubernetes.controller.databaseservice.EntandoDatabaseServiceHelper.strategyFor;

import io.fabric8.kubernetes.api.model.ObjectMeta;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.inject.Inject;
import org.entando.kubernetes.controller.spi.capability.CapabilityProvider;
import org.entando.kubernetes.controller.spi.client.KubernetesClientForControllers;
import org.entando.kubernetes.controller.spi.command.DeploymentProcessor;
import org.entando.kubernetes.controller.spi.common.DbmsVendorConfig;
import org.entando.kubernetes.controller.spi.common.EntandoControllerException;
import org.entando.kubernetes.controller.spi.common.EntandoOperatorSpiConfig;
import org.entando.kubernetes.controller.spi.common.NameUtils;
import org.entando.kubernetes.controller.spi.common.ResourceUtils;
import org.entando.kubernetes.controller.spi.container.ProvidedDatabaseCapability;
import org.entando.kubernetes.model.capability.CapabilityProvisioningStrategy;
import org.entando.kubernetes.model.capability.CapabilityRequirement;
import org.entando.kubernetes.model.capability.CapabilityScope;
import org.entando.kubernetes.model.capability.ExternallyProvidedService;
import org.entando.kubernetes.model.capability.NestedCapabilityRequirementFluent;
import org.entando.kubernetes.model.capability.ProvidedCapability;
import org.entando.kubernetes.model.capability.ProvidedCapabilityBuilder;
import org.entando.kubernetes.model.capability.StandardCapability;
import org.entando.kubernetes.model.capability.StandardCapabilityImplementation;
import org.entando.kubernetes.model.common.DbmsVendor;
import org.entando.kubernetes.model.common.EntandoCustomResource;
import org.entando.kubernetes.model.common.ServerStatus;
import org.entando.kubernetes.model.externaldatabase.EntandoDatabaseService;
import org.entando.kubernetes.model.externaldatabase.EntandoDatabaseServiceBuilder;
import org.entando.kubernetes.model.externaldatabase.EntandoDatabaseServiceSpec;
import picocli.CommandLine;

@CommandLine.Command()
public class EntandoDatabaseServiceController implements Runnable {

    private static final String SECRET_KIND = "Secret";
    public static final int DATABASE_DEPLOYMENT_TIME = EntandoOperatorSpiConfig.getPodCompletionTimeoutSeconds();
    private final KubernetesClientForControllers k8sClient;
    private final DeploymentProcessor deploymentProcessor;
    private static final Collection<Class<? extends EntandoCustomResource>> SUPPORTED_RESOURCE_KINDS = Arrays
            .asList(EntandoDatabaseService.class, ProvidedCapability.class);
    private EntandoDatabaseService entandoDatabaseService;
    private ProvidedCapability providedCapability;

    @Inject
    public EntandoDatabaseServiceController(KubernetesClientForControllers k8sClient, DeploymentProcessor deploymentProcessor) {
        this.k8sClient = k8sClient;
        this.deploymentProcessor = deploymentProcessor;
    }

    @Override
    public void run() {
        k8sClient.prepareConfig();
        EntandoCustomResource resourceToProcess = k8sClient.resolveCustomResourceToProcess(SUPPORTED_RESOURCE_KINDS);
        //No need to update the resource being synced to. It will be ignored by ControllerCoordinator
        try {
            if (resourceToProcess instanceof EntandoDatabaseService) {
                //This event originated from the original EntandoDatabaseService, NOT a capability requirement expressed by means of a
                // ProvidedCapability
                //The ProvidedCapability is to be owned by the implementing CustomResource and will therefore be ignored by
                // ControllerCoordinator
                this.entandoDatabaseService = (EntandoDatabaseService) resourceToProcess;
                this.providedCapability = syncFromImplementingResourceToCapability(this.entandoDatabaseService);
                this.providedCapability = this.k8sClient.createOrPatchEntandoResource(this.providedCapability);
                k8sClient.deploymentStarted(resourceToProcess);
                validateExternalServiceRequirements(this.entandoDatabaseService);
            } else {
                //This event originated from the capability requirement, and we need to keep the implementing CustomResource in sync
                //The implementing CustomResource is to be owned by the ProvidedCapability and will therefore be ignored by
                // ControllerCoordinator
                this.providedCapability = (ProvidedCapability) resourceToProcess;
                this.entandoDatabaseService = syncFromCapabilityToImplementingCustomResource(this.providedCapability);
                this.entandoDatabaseService = this.k8sClient.createOrPatchEntandoResource(this.entandoDatabaseService);
                validateExternalServiceRequirements(this.providedCapability);
                if (ResourceUtils.addCapabilityLabels(this.providedCapability)) {
                    this.providedCapability = this.k8sClient.createOrPatchEntandoResource(this.providedCapability);
                }
            }
            this.providedCapability = this.k8sClient.deploymentStarted(this.providedCapability);
            this.entandoDatabaseService = this.k8sClient.deploymentStarted(this.entandoDatabaseService);
            DatabaseServiceDeployable deployable = new DatabaseServiceDeployable(entandoDatabaseService);
            DatabaseDeploymentResult result = deploymentProcessor.processDeployable(deployable, DATABASE_DEPLOYMENT_TIME);
            providedCapability = k8sClient.updateStatus(providedCapability,
                    result.getStatus().withOriginatingCustomResource(providedCapability));
            entandoDatabaseService = k8sClient.deploymentEnded(entandoDatabaseService);
            providedCapability = k8sClient.deploymentEnded(providedCapability);
        } catch (Exception e) {
            entandoDatabaseService = k8sClient.deploymentFailed(entandoDatabaseService, e, NameUtils.MAIN_QUALIFIER);
            providedCapability = k8sClient.deploymentFailed(providedCapability, e, NameUtils.MAIN_QUALIFIER);
        }
        entandoDatabaseService.getStatus().findFailedServerStatus().flatMap(ServerStatus::getEntandoControllerFailure).ifPresent(f -> {
            throw new CommandLine.ExecutionException(new CommandLine(this), f.getDetailMessage());
        });
    }

    private EntandoDatabaseService syncFromCapabilityToImplementingCustomResource(ProvidedCapability providedCapability) {
        final EntandoDatabaseService entandoDatabaseServiceToSyncTo = new EntandoDatabaseServiceBuilder(
                Objects.requireNonNullElseGet(k8sClient
                                .load(EntandoDatabaseService.class, providedCapability.getMetadata().getNamespace(),
                                        providedCapability.getMetadata().getName()),
                        () -> new EntandoDatabaseService(new EntandoDatabaseServiceSpec())))

                .editMetadata()
                .withNamespace(providedCapability.getMetadata().getNamespace())
                .withName(providedCapability.getMetadata().getName())
                .withLabels(providedCapability.getMetadata().getLabels())
                .endMetadata()
                .editSpec()
                .withDbms(DbmsVendor.valueOf(
                        providedCapability.getSpec().getImplementation().orElse(StandardCapabilityImplementation.POSTGRESQL).name()))
                .withCreateDeployment(
                        providedCapability.getSpec().getProvisioningStrategy().orElse(CapabilityProvisioningStrategy.DEPLOY_DIRECTLY)
                                == CapabilityProvisioningStrategy.DEPLOY_DIRECTLY)
                .withSecretName(providedCapability.getSpec().getExternallyProvisionedService()
                        .map(ExternallyProvidedService::getAdminSecretName).orElse(null))
                .withHost(providedCapability.getSpec().getExternallyProvisionedService()
                        .map(ExternallyProvidedService::getHost).orElse(null))
                .withPort(providedCapability.getSpec().getExternallyProvisionedService()
                        .flatMap(ExternallyProvidedService::getPort).orElse(null))
                .withTablespace(resolveParameterIfPresent(providedCapability, "tablespace"))
                .withDatabaseName(resolveParameterIfPresent(providedCapability, "databaseName"))
                .withProvidedCapabilityScope(determinePreferredScope(providedCapability))
                .withJdbcParameters(ofNullable(providedCapability.getSpec().getCapabilityParameters()).orElse(Collections.emptyMap())
                        .entrySet().stream()
                        .filter(entry -> entry.getKey().startsWith(ProvidedDatabaseCapability.JDBC_PARAMETER_PREFIX))
                        .map(entry -> Map.entry(entry.getKey().substring(ProvidedDatabaseCapability.JDBC_PARAMETER_PREFIX.length()),
                                entry.getValue()))
                        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)))
                .endSpec()
                .build();
        if (!ResourceUtils.customResourceOwns(providedCapability, entandoDatabaseServiceToSyncTo)) {
            entandoDatabaseServiceToSyncTo.getMetadata().getOwnerReferences().add(ResourceUtils.buildOwnerReference(providedCapability));
        }
        return entandoDatabaseServiceToSyncTo;
    }

    private CapabilityScope determinePreferredScope(ProvidedCapability providedCapability) {
        return providedCapability.getSpec().getResolutionScopePreference().stream().findFirst().orElse(CapabilityScope.NAMESPACE);
    }

    private void validateExternalServiceRequirements(EntandoDatabaseService entandoDatabaseService) {
        if (!EntandoDatabaseServiceHelper.deployDirectly(entandoDatabaseService)) {
            if (entandoDatabaseService.getSpec().getHost().isEmpty()) {
                throw new EntandoControllerException(
                        "Please provide the hostname of the database service you intend to connect to using the "
                                + "EntandoDatabaseService.spec.host property.");
            }
            if (strategyFor(entandoDatabaseService).getVendorConfig() != DbmsVendorConfig.MYSQL
                    && entandoDatabaseService.getSpec().getDatabaseName().isEmpty()) {
                throw new EntandoControllerException(
                        "Please provide the name of the database on the database service you intend to connect to using the "
                                + "EntandoDatabaseService.spec.databaseName property.");
            }
            String adminSecretName = entandoDatabaseService.getSpec().getSecretName()
                    .orElseThrow(() -> new EntandoControllerException(
                            "Please provide the name of the secret containing the admin credentials for the database service you intend "
                                    + "to connect to using the EntandoDatabaseService.spec.secretName property."));
            if (ofNullable(
                    k8sClient.loadStandardResource(SECRET_KIND, entandoDatabaseService.getMetadata().getNamespace(), adminSecretName))
                    .isEmpty()) {
                throw new EntandoControllerException(format(
                        "Please ensure that a secret with the name '%s' exists in the requested namespace %s", adminSecretName,
                        entandoDatabaseService.getMetadata().getName()));
            }
        }
    }

    private void validateExternalServiceRequirements(ProvidedCapability providedCapability) {
        if (providedCapability.getSpec().getProvisioningStrategy().map(CapabilityProvisioningStrategy.USE_EXTERNAL::equals)
                .orElse(false)) {
            final ExternallyProvidedService externallyProvidedService = providedCapability.getSpec().getExternallyProvisionedService()
                    .orElseThrow(() -> new EntandoControllerException(
                            "Please provide the connection information of the database service you intend to connect to using the "
                                    + "ProvidedCapability.spec.externallyProvisionedService object."));
            String adminSecretName = ofNullable(externallyProvidedService.getAdminSecretName())
                    .orElseThrow(() -> new EntandoControllerException(
                            "Please provide the name of the secret containing the admin credentials for the database service you intend "
                                    + "to connect to "
                                    + "using the "
                                    + "ProvidedCapability.spec.externallyProvisionedService.adminSecretName property."));
            if (ofNullable(k8sClient.loadStandardResource(SECRET_KIND, providedCapability.getMetadata().getNamespace(), adminSecretName))
                    .isEmpty()) {
                throw new EntandoControllerException(format(
                        "Please ensure that a secret with the name '%s' exists in the requested namespace %s", adminSecretName,
                        providedCapability.getMetadata().getName()));
            }
            if (ofNullable(externallyProvidedService.getHost()).isEmpty()) {
                throw new EntandoControllerException(
                        "Please provide the hostname of the database service you intend to connect to using the "
                                + "ProvidedCapability.spec.externallyProvisionedService.host property.");
            }
        }
    }

    private String resolveParameterIfPresent(ProvidedCapability providedCapability, String paramName) {
        return ofNullable(providedCapability.getSpec().getCapabilityParameters()).map(params -> params.get(paramName)).orElse(null);
    }

    private ProvidedCapability syncFromImplementingResourceToCapability(EntandoDatabaseService resourceToProcess) {
        ProvidedCapability capabilityToSyncTo = k8sClient.load(
                ProvidedCapability.class,
                resourceToProcess.getMetadata().getNamespace(),
                resourceToProcess.getMetadata().getName());
        final ProvidedCapabilityBuilder builder;
        builder = new ProvidedCapabilityBuilder(Objects.requireNonNullElseGet(capabilityToSyncTo,
                () -> new ProvidedCapability(new ObjectMeta(), new CapabilityRequirement())));
        final HashMap<String, String> parameters = new HashMap<>();
        resourceToProcess.getSpec().getDatabaseName().ifPresent(s -> parameters.put("databaseName", s));
        resourceToProcess.getSpec().getTablespace().ifPresent(s -> parameters.put("tablespace", s));
        resourceToProcess.getSpec().getJdbcParameters()
                .forEach((key, value) -> parameters.put(ProvidedDatabaseCapability.JDBC_PARAMETER_PREFIX + key, value));
        NestedCapabilityRequirementFluent<ProvidedCapabilityBuilder> specBuilder = builder
                .editMetadata()
                .withNamespace(resourceToProcess.getMetadata().getNamespace())
                .withName(resourceToProcess.getMetadata().getName())
                .withLabels(resourceToProcess.getMetadata().getLabels())
                .endMetadata()
                .editSpec()
                .withCapability(StandardCapability.DBMS)
                .withImplementation(StandardCapabilityImplementation
                        .valueOf(strategyFor(resourceToProcess).getVendorConfig().name()))
                .withSelector(resourceToProcess.getSpec().getProvidedCapabilityScope().filter(CapabilityScope.LABELED::equals)
                        .map(s -> resourceToProcess.getMetadata().getLabels()).orElse(null))
                .withResolutionScopePreference(resourceToProcess.getSpec().getProvidedCapabilityScope().orElse(CapabilityScope.NAMESPACE))
                .addAllToCapabilityParameters(parameters);
        if (EntandoDatabaseServiceHelper.deployDirectly(resourceToProcess)) {
            specBuilder = specBuilder.withProvisioningStrategy(CapabilityProvisioningStrategy.DEPLOY_DIRECTLY);
        } else {
            specBuilder = specBuilder.withProvisioningStrategy(CapabilityProvisioningStrategy.USE_EXTERNAL)
                    .withNewExternallyProvidedService()
                    .withHost(resourceToProcess.getSpec().getHost().orElse(null))
                    .withPort(resourceToProcess.getSpec().getPort().orElse(strategyFor(resourceToProcess).getPort()))
                    .withAdminSecretName(resourceToProcess.getSpec().getSecretName().orElse(null))
                    .endExternallyProvidedService();
        }
        capabilityToSyncTo = specBuilder.endSpec().build();
        if (!ResourceUtils.customResourceOwns(resourceToProcess, capabilityToSyncTo)) {
            //If we are here, it means one of two things:
            // 1. This is a new EntandoDatabaseService and we need to create a ProvidedCapability owned by it so that the
            // ControllerCoordinator won't process changes against the ProvidedCapability.
            // 2. the user has removed the ownerReference from the original EntandoDatabaseService, thus indicating
            //that he is taking control of it.  Now we change control over to the original EntandoDatabaseService, make it own the
            // ProvidedCapability so that only its events will be listened to.
            capabilityToSyncTo.getMetadata().getOwnerReferences().add(ResourceUtils.buildOwnerReference(resourceToProcess));
            ofNullable(capabilityToSyncTo.getMetadata().getAnnotations())
                    .ifPresent(m -> m.remove(CapabilityProvider.ORIGIN_UUID_ANNOTATION_NAME));
        }
        ResourceUtils.addCapabilityLabels(capabilityToSyncTo);
        return capabilityToSyncTo;
    }

}