EntandoKeycloakServerController.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.keycloakserver;

import static java.lang.String.format;
import static java.util.Optional.ofNullable;

import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.api.model.Secret;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Objects;
import java.util.concurrent.TimeoutException;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.inject.Inject;
import org.entando.kubernetes.controller.spi.capability.CapabilityProvider;
import org.entando.kubernetes.controller.spi.capability.CapabilityProvisioningResult;
import org.entando.kubernetes.controller.spi.client.ExecutionResult;
import org.entando.kubernetes.controller.spi.client.KubernetesClientForControllers;
import org.entando.kubernetes.controller.spi.command.DeploymentProcessor;
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.KeycloakName;
import org.entando.kubernetes.controller.spi.container.ProvidedDatabaseCapability;
import org.entando.kubernetes.controller.spi.container.ProvidedSsoCapability;
import org.entando.kubernetes.controller.spi.deployable.SsoConnectionInfo;
import org.entando.kubernetes.controller.spi.result.DatabaseConnectionInfo;
import org.entando.kubernetes.controller.support.client.SimpleKeycloakClient;
import org.entando.kubernetes.model.capability.CapabilityProvisioningStrategy;
import org.entando.kubernetes.model.capability.CapabilityRequirement;
import org.entando.kubernetes.model.capability.CapabilityRequirementBuilder;
import org.entando.kubernetes.model.capability.CapabilityScope;
import org.entando.kubernetes.model.capability.ExternallyProvidedService;
import org.entando.kubernetes.model.capability.ExternallyProvidedServiceFluent;
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.EntandoCustomResourceStatus;
import org.entando.kubernetes.model.common.EntandoDeploymentPhase;
import org.entando.kubernetes.model.common.ServerStatus;
import org.entando.kubernetes.model.keycloakserver.EntandoKeycloakServer;
import org.entando.kubernetes.model.keycloakserver.EntandoKeycloakServerBuilder;
import org.entando.kubernetes.model.keycloakserver.EntandoKeycloakServerSpec;
import org.entando.kubernetes.model.keycloakserver.StandardKeycloakImage;
import picocli.CommandLine;

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

    public static final String SECRET_KIND = "Secret";
    public static final int KEYCLOAK_DEPLOYMENT_TIME = EntandoOperatorSpiConfig.getPodCompletionTimeoutSeconds();
    public static final int DATABASE_DEPLOYMENT_TIME = EntandoOperatorSpiConfig.getPodCompletionTimeoutSeconds();
    private final Logger logger = Logger.getLogger(EntandoKeycloakServerController.class.getName());
    private final KubernetesClientForControllers k8sClient;
    private final CapabilityProvider capabilityProvider;
    private final SimpleKeycloakClient keycloakClient;
    private final DeploymentProcessor deploymentProcessor;
    private static final Collection<Class<? extends EntandoCustomResource>> SUPPORTED_RESOURCE_KINDS = Arrays
            .asList(EntandoKeycloakServer.class, ProvidedCapability.class);
    private EntandoKeycloakServer keycloakServer;
    private ProvidedCapability providedCapability;

    @Inject
    public EntandoKeycloakServerController(KubernetesClientForControllers k8sClient, DeploymentProcessor deploymentProcessor,
            CapabilityProvider capabilityProvider, SimpleKeycloakClient keycloakClient) {
        this.k8sClient = k8sClient;
        this.capabilityProvider = capabilityProvider;
        this.keycloakClient = keycloakClient;
        this.deploymentProcessor = deploymentProcessor;
    }

    @Override
    public void run() {
        EntandoCustomResource resourceToProcess = startProcessingResource();
        try {
            //No need to update the resource being synced to. It will be ignored by ControllerCoordinator
            if (resourceToProcess instanceof EntandoKeycloakServer) {
                //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.keycloakServer = (EntandoKeycloakServer) resourceToProcess;
                this.providedCapability = this.k8sClient.createOrPatchEntandoResource(toCapability(this.keycloakServer));
                validateExternalServiceRequirements(keycloakServer);
            } 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.keycloakServer = this.k8sClient.createOrPatchEntandoResource(toKeycloakServer(this.providedCapability));
                validateExternalServiceRequirements(this.providedCapability);
                if (ResourceUtils.addCapabilityLabels(this.providedCapability)) {
                    this.providedCapability = this.k8sClient.createOrPatchEntandoResource(this.providedCapability);
                }

            }
            keycloakServer = k8sClient.deploymentStarted(keycloakServer);
            providedCapability = k8sClient.deploymentStarted(providedCapability);
            KeycloakDeployable deployable = new KeycloakDeployable(keycloakServer, provideDatabaseIfRequired(), resolveCaSecret());
            KeycloakDeploymentResult result = deploymentProcessor.processDeployable(deployable, KEYCLOAK_DEPLOYMENT_TIME);
            providedCapability = k8sClient.updateStatus(providedCapability, result.getStatus()
                    .withOriginatingCustomResource(providedCapability));
            keycloakServer = k8sClient.updateStatus(keycloakServer, result.getStatus()
                    .withOriginatingCustomResource(providedCapability));
            if (!result.getStatus().hasFailed()) {
                if (EntandoKeycloakHelper.provisioningStrategyOf(keycloakServer)
                        != CapabilityProvisioningStrategy.USE_EXTERNAL) {
                    ensureHttpAccess(result);
                }
                final ServerStatus mainServerStatus = providedCapability.getStatus().getServerStatus(NameUtils.MAIN_QUALIFIER)
                        .orElseThrow(IllegalStateException::new);
                ensureKeycloakRealm(new ProvidedSsoCapability(k8sClient.loadCapabilityProvisioningResult(mainServerStatus)));
            }
            keycloakServer = k8sClient.deploymentEnded(keycloakServer);
            providedCapability = k8sClient.deploymentEnded(providedCapability);
        } catch (Exception e) {
            logger.log(Level.SEVERE, e.getMessage(), e);
            keycloakServer = k8sClient.deploymentFailed(keycloakServer, e, NameUtils.MAIN_QUALIFIER);
            providedCapability = k8sClient.deploymentFailed(providedCapability, e, NameUtils.MAIN_QUALIFIER);
        }
        keycloakServer.getStatus().findFailedServerStatus().flatMap(ServerStatus::getEntandoControllerFailure).ifPresent(ss -> {
            throw new CommandLine.ExecutionException(new CommandLine(this), ss.getDetailMessage());
        });
    }

    private EntandoCustomResource startProcessingResource() {
        return k8sClient.updatePhase(k8sClient.resolveCustomResourceToProcess(SUPPORTED_RESOURCE_KINDS), EntandoDeploymentPhase.STARTED);
    }

    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 SSO 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 SSO 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 SSO service you intend to connect to using the "
                                + "ProvidedCapability.spec.externallyProvisionedService.host property.");
            }
        }
    }

    private void validateExternalServiceRequirements(EntandoKeycloakServer entandoKeycloakServer) {
        if (entandoKeycloakServer.getSpec().getProvisioningStrategy().map(CapabilityProvisioningStrategy.USE_EXTERNAL::equals)
                .orElse(false)) {
            if (entandoKeycloakServer.getSpec().getFrontEndUrl().isEmpty()) {
                throw new EntandoControllerException(
                        "Please provide the base URL of the SSO service you intend to connect to using the "
                                + "EntandoKeycloakServer.spec.frontEndUrl property.");
            }
            String adminSecretName = entandoKeycloakServer.getSpec().getAdminSecretName()
                    .orElseThrow(() -> new EntandoControllerException(
                            "Please provide the name of the secret containing the admin credentials for the SSO service you intend to "
                                    + "connect to "
                                    + "using the "
                                    + "EntandoKeycloakServer.spec.adminSecretName property."));
            if (ofNullable(k8sClient.loadStandardResource(SECRET_KIND, entandoKeycloakServer.getMetadata().getNamespace(), adminSecretName))
                    .isEmpty()) {
                throw new EntandoControllerException(format(
                        "Please ensure that a secret with the name '%s' exists in the requested namespace %s", adminSecretName,
                        entandoKeycloakServer.getMetadata().getName()));
            }
        }
    }

    private EntandoKeycloakServer toKeycloakServer(ProvidedCapability providedCapability) {
        final EntandoKeycloakServer keycloakServerWithoutDefaults = new EntandoKeycloakServerBuilder(
                Objects.requireNonNullElseGet(k8sClient
                                .load(EntandoKeycloakServer.class, providedCapability.getMetadata().getNamespace(),
                                        providedCapability.getMetadata().getName()),
                        () -> new EntandoKeycloakServer(new EntandoKeycloakServerSpec())))
                .editMetadata()
                .withLabels(providedCapability.getMetadata().getLabels())
                .withNamespace(providedCapability.getMetadata().getNamespace())
                .withName(providedCapability.getMetadata().getName())
                .endMetadata()
                .editSpec()
                .withProvisioningStrategy(EntandoKeycloakHelper.provisioningStrategyOf(providedCapability))
                .withDefault(providedCapability.getSpec().getResolutionScopePreference().indexOf(CapabilityScope.CLUSTER) == 0)
                .withDbms(providedCapability.getSpec().getPreferredDbms().orElse(null))
                .withIngressHostName(providedCapability.getSpec().getPreferredIngressHostName().orElse(null))
                .withDefaultRealm(providedCapability.getSpec().getCapabilityParameters().get(ProvidedSsoCapability.DEFAULT_REALM_PARAMETER))
                .withTlsSecretName(providedCapability.getSpec().getPreferredTlsSecretName().orElse(null))
                .withAdminSecretName(
                        providedCapability.getSpec().getExternallyProvisionedService().map(ExternallyProvidedService::getAdminSecretName)
                                .orElse(null))
                .withFrontEndUrl(providedCapability.getSpec().getExternallyProvisionedService()
                        .map(s -> EntandoKeycloakHelper.deriveFrontEndUrl(providedCapability)).orElse(null)).endSpec().build();

        final EntandoKeycloakServer entandoKeycloakServerWithDefaults = new EntandoKeycloakServerBuilder(keycloakServerWithoutDefaults)
                .editSpec()
                .withDbms(EntandoKeycloakHelper.determineDbmsVendor(keycloakServerWithoutDefaults))
                .withStandardImage(EntandoKeycloakHelper.determineStandardImage(keycloakServerWithoutDefaults))
                .endSpec()
                .build();
        if (!ResourceUtils.customResourceOwns(providedCapability, entandoKeycloakServerWithDefaults)) {
            entandoKeycloakServerWithDefaults.getMetadata().getOwnerReferences().add(ResourceUtils.buildOwnerReference(providedCapability));
        }
        return entandoKeycloakServerWithDefaults;
    }

    private ProvidedCapability toCapability(EntandoKeycloakServer resourceToProcess) {
        ExternallyProvidedService externalService = resourceToProcess.getSpec().getFrontEndUrl()
                .map(ExternalKeycloakService::new)
                .map(s -> new ExternallyProvidedServiceFluent<>().withPort(s.getPort()).withHost(s.getHost())
                        .withPath(s.getPath())
                        .withAdminSecretName(resourceToProcess.getSpec().getAdminSecretName().orElse(null))
                        .build()).orElse(null);

        final ProvidedCapability capabilityToSyncTo = new ProvidedCapabilityBuilder(
                Objects.requireNonNullElseGet(k8sClient.load(ProvidedCapability.class, resourceToProcess.getMetadata().getNamespace(),
                        resourceToProcess.getMetadata().getName()),
                        () -> new ProvidedCapability(new ObjectMeta(), new CapabilityRequirement())))
                .editMetadata()
                .withNamespace(resourceToProcess.getMetadata().getNamespace())
                .withLabels(resourceToProcess.getMetadata().getLabels())
                .withName(resourceToProcess.getMetadata().getName())
                .endMetadata()
                .editSpec()
                .withCapability(StandardCapability.SSO)
                .withImplementation(determineImplementation(resourceToProcess))
                .withProvisioningStrategy(EntandoKeycloakHelper.provisioningStrategyOf(resourceToProcess))
                .withResolutionScopePreference(
                        resourceToProcess.getSpec().isDefault() ? CapabilityScope.CLUSTER : CapabilityScope.NAMESPACE)
                .withExternallyProvidedService(externalService)
                .withPreferredIngressHostName(resourceToProcess.getSpec().getIngressHostName().orElse(null))
                .withPreferredTlsSecretName(resourceToProcess.getSpec().getTlsSecretName().orElse(null))
                .addAllToCapabilityParameters(resourceToProcess.getSpec().getDefaultRealm()
                        .map(r -> Collections.singletonMap(ProvidedSsoCapability.DEFAULT_REALM_PARAMETER, r))
                        .orElse(Collections.emptyMap()))
                .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;
    }

    private StandardCapabilityImplementation determineImplementation(EntandoKeycloakServer resourceToProcess) {
        StandardKeycloakImage keycloakImage = EntandoKeycloakHelper.determineStandardImage(resourceToProcess);
        if (resourceToProcess.getSpec().getProvisioningStrategy().map(CapabilityProvisioningStrategy.USE_EXTERNAL::equals).orElse(false)) {
            //Allow it to be overridden if it is an existing external SSO
            keycloakImage = resourceToProcess.getSpec().getStandardImage().orElse(keycloakImage);
        }
        return StandardCapabilityImplementation.valueOf(keycloakImage.name());
    }

    private Secret resolveCaSecret() {
        return EntandoOperatorSpiConfig.getCertificateAuthoritySecretName()
                .map(n -> (Secret) k8sClient.loadStandardResource(SECRET_KIND, k8sClient.getNamespace(), n)).orElse(null);
    }

    private void ensureHttpAccess(KeycloakDeploymentResult serviceDeploymentResult) throws TimeoutException {
        //Give the operator access over http for cluster.local calls
        final ExecutionResult result = k8sClient.executeOnPod(serviceDeploymentResult.getPod(), "server-container", 30,
                "cd \"${KEYCLOAK_HOME}/bin\"",
                "./kcadm.sh config credentials --server http://localhost:8080/auth --realm master "
                        + "--user  \"${KEYCLOAK_USER:-${SSO_ADMIN_USERNAME}}\" "
                        + "--password \"${KEYCLOAK_PASSWORD:-${SSO_ADMIN_PASSWORD}}\"",
                "./kcadm.sh update realms/master -s sslRequired=NONE"
        );
        if (result.hasFailed()) {
            throw new EntandoControllerException("Could not disable Keycloak HTTPS requirement:" + String
                    .join("\n", result.getOutputLines()));
        }
    }

    private DatabaseConnectionInfo provideDatabaseIfRequired() throws TimeoutException {
        // Create database for Keycloak
        final DbmsVendor dbmsVendor = EntandoKeycloakHelper.determineDbmsVendor(keycloakServer);
        if (dbmsVendor == DbmsVendor.EMBEDDED || EntandoKeycloakHelper.provisioningStrategyOf(keycloakServer)
                != CapabilityProvisioningStrategy.DEPLOY_DIRECTLY) {
            return null;
        } else {
            final CapabilityProvisioningResult databaseCapability = this.capabilityProvider
                    .provideCapability(keycloakServer, new CapabilityRequirementBuilder()
                            .withCapability(StandardCapability.DBMS)
                            .withImplementation(StandardCapabilityImplementation
                                    .valueOf(dbmsVendor.name()))
                            .withResolutionScopePreference(CapabilityScope.NAMESPACE, CapabilityScope.DEDICATED, CapabilityScope.CLUSTER)
                            .withProvisioningStrategy(CapabilityProvisioningStrategy.DEPLOY_DIRECTLY)
                            .build(), DATABASE_DEPLOYMENT_TIME);
            final ProvidedCapability dbmsCapability = databaseCapability.getProvidedCapability();
            final EntandoCustomResourceStatus dbStatus = dbmsCapability.getStatus();
            dbStatus.getServerStatus(NameUtils.MAIN_QUALIFIER).ifPresent(s ->
                    this.keycloakServer = k8sClient.updateStatus(keycloakServer, new ServerStatus(NameUtils.DB_QUALIFIER, s)));
            databaseCapability.getControllerFailure().ifPresent(f -> {
                throw new EntandoControllerException(dbmsCapability,
                        format("Could not prepare a DBMS capability for SSO %s/%s. Please inspect the ProvidedCapability"
                                        + " %s/%s, "
                                        + "address the "
                                        + "deployment failure and force a redeployment using the annotation value 'entando"
                                        + ".org/processing-instruction: force. The following message was received:%n %s",
                                keycloakServer.getMetadata().getNamespace(),
                                keycloakServer.getMetadata().getName(),
                                dbmsCapability.getMetadata().getNamespace(),
                                dbmsCapability.getMetadata().getName(),
                                f.getDetailMessage()));
            });
            return new ProvidedDatabaseCapability(databaseCapability);
        }
    }

    private void ensureKeycloakRealm(SsoConnectionInfo ssoConnectionInfo) {
        logger.severe(() -> format("Attempting to log into Keycloak at %s", ssoConnectionInfo.getBaseUrlToUse()));
        keycloakClient.login(ssoConnectionInfo.getBaseUrlToUse(), ssoConnectionInfo.getUsername(),
                ssoConnectionInfo.getPassword());
        keycloakClient.ensureRealm(KeycloakName.ENTANDO_DEFAULT_KEYCLOAK_REALM);
    }

}