EntandoAppController.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.app;

import static java.lang.String.format;

import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
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.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.container.ProvidedDatabaseCapability;
import org.entando.kubernetes.controller.spi.container.ProvidedSsoCapability;
import org.entando.kubernetes.controller.spi.deployable.IngressingDeployable;
import org.entando.kubernetes.controller.spi.deployable.SsoConnectionInfo;
import org.entando.kubernetes.controller.spi.result.DatabaseConnectionInfo;
import org.entando.kubernetes.model.app.EntandoApp;
import org.entando.kubernetes.model.capability.CapabilityRequirementBuilder;
import org.entando.kubernetes.model.capability.CapabilityScope;
import org.entando.kubernetes.model.capability.ProvidedCapability;
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.ServerStatus;
import picocli.CommandLine;

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

    private static final Logger LOGGER = Logger.getLogger(EntandoAppController.class.getName());
    public static final String ENTANDO_K8S_SERVICE = "entando-k8s-service";
    private final KubernetesClientForControllers k8sClient;
    private final CapabilityProvider capabilityProvider;
    private final DeploymentProcessor deploymentProcessor;
    private final AtomicReference<EntandoApp> entandoApp = new AtomicReference<>();
    private final ExecutorService executor = Executors.newFixedThreadPool(3);

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

    //There is no point re-interrupting the thread when the VM is about to exit.
    @SuppressWarnings("java:S2142")
    @Override
    public void run() {
        this.entandoApp.set((EntandoApp) k8sClient.resolveCustomResourceToProcess(Collections.singletonList(EntandoApp.class)));
        try {
            entandoApp.set(k8sClient.deploymentStarted(entandoApp.get()));
            final DatabaseConnectionInfo dbConnectionInfo = provideDatabaseIfRequired();
            final SsoConnectionInfo ssoConnectionInfo = provideSso();
            final int timeoutForDbAware = calculateDbAwareTimeout();
            queueDeployable(new EntandoAppServerDeployable(entandoApp.get(), ssoConnectionInfo, dbConnectionInfo), timeoutForDbAware);
            final int timeoutForNonDbAware = EntandoOperatorSpiConfig.getPodReadinessTimeoutSeconds();
            queueDeployable(new AppBuilderDeployable(entandoApp.get()), timeoutForNonDbAware);
            EntandoK8SService k8sService = new EntandoK8SService(k8sClient.loadControllerService(EntandoAppController.ENTANDO_K8S_SERVICE));
            queueDeployable(new ComponentManagerDeployable(entandoApp.get(), ssoConnectionInfo, k8sService, dbConnectionInfo),
                    timeoutForDbAware);
            executor.shutdown();
            final int totalTimeout = timeoutForDbAware * 2 + timeoutForNonDbAware;
            if (!executor.awaitTermination(totalTimeout, TimeUnit.SECONDS)) {
                throw new TimeoutException(format("Could not complete deployment of EntandoApp in %s seconds", totalTimeout));
            }
            entandoApp.updateAndGet(k8sClient::deploymentEnded);
        } catch (Exception e) {
            attachControllerFailure(e, EntandoAppController.class, NameUtils.MAIN_QUALIFIER);
        }
        entandoApp.get().getStatus().findFailedServerStatus().flatMap(ServerStatus::getEntandoControllerFailure).ifPresent(s -> {
            throw new CommandLine.ExecutionException(new CommandLine(this), s.getDetailMessage());
        });
    }

    private int calculateDbAwareTimeout() {
        final int timeoutForDbAware;
        if (requiresDbmsService(EntandoAppHelper.determineDbmsVendor(entandoApp.get()))) {
            timeoutForDbAware =
                    EntandoOperatorSpiConfig.getPodCompletionTimeoutSeconds() + EntandoOperatorSpiConfig.getPodReadinessTimeoutSeconds();
        } else {
            timeoutForDbAware = EntandoOperatorSpiConfig.getPodReadinessTimeoutSeconds();
        }
        return timeoutForDbAware;
    }

    private void queueDeployable(IngressingDeployable<EntandoAppDeploymentResult> deployable, long timeout) {
        executor.submit(() -> {
            try {
                EntandoAppDeploymentResult result = deploymentProcessor.processDeployable(deployable, (int) timeout);
                entandoApp.getAndUpdate(ea -> k8sClient.updateStatus(ea, result.getStatus()));
            } catch (Exception e) {
                attachControllerFailure(e, deployable.getClass(), deployable.getQualifier().orElse(NameUtils.MAIN_QUALIFIER));
            }
        });
    }

    private void attachControllerFailure(Exception e, Class<?> theClass, String qualifier) {
        entandoApp.updateAndGet(current -> k8sClient.deploymentFailed(current, e, qualifier));
        LOGGER.log(Level.SEVERE, e, () -> format("Processing the class %s failed.: %n%s", theClass.getSimpleName(),
                entandoApp.get().getStatus().getServerStatus(qualifier).flatMap(ServerStatus::getEntandoControllerFailure)
                        .orElseThrow(IllegalStateException::new).getDetailMessage()));
    }

    private ProvidedDatabaseCapability provideDatabaseIfRequired() throws TimeoutException {
        final DbmsVendor dbmsVendor = EntandoAppHelper.determineDbmsVendor(entandoApp.get());
        if (requiresDbmsService(dbmsVendor)) {
            final CapabilityProvisioningResult capabilityResult = capabilityProvider
                    .provideCapability(entandoApp.get(), new CapabilityRequirementBuilder()
                            .withCapability(StandardCapability.DBMS)
                            .withImplementation(StandardCapabilityImplementation.valueOf(dbmsVendor.name()))
                            .withResolutionScopePreference(CapabilityScope.NAMESPACE, CapabilityScope.DEDICATED, CapabilityScope.CLUSTER)
                            .build(), EntandoOperatorSpiConfig.getPodReadinessTimeoutSeconds());
            final ProvidedCapability dbmsCapability = capabilityResult.getProvidedCapability();
            dbmsCapability.getStatus().getServerStatus(NameUtils.MAIN_QUALIFIER).ifPresent(s ->
                    this.entandoApp.updateAndGet(a -> this.k8sClient.updateStatus(a, new ServerStatus(NameUtils.DB_QUALIFIER, s))));
            capabilityResult.getControllerFailure().ifPresent(f -> {
                throw new EntandoControllerException(dbmsCapability,
                        format("Could not prepare DBMS  capability for EntandoApp %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",
                                entandoApp.get().getMetadata().getNamespace(),
                                entandoApp.get().getMetadata().getName(),
                                dbmsCapability.getMetadata().getNamespace(),
                                dbmsCapability.getMetadata().getName(),
                                f.getDetailMessage()));
            });
            return new ProvidedDatabaseCapability(capabilityResult);
        } else {
            return null;
        }
    }

    private boolean requiresDbmsService(DbmsVendor dbmsVendor) {
        return !Set.of(DbmsVendor.NONE, DbmsVendor.EMBEDDED).contains(dbmsVendor);
    }

    private ProvidedSsoCapability provideSso() throws TimeoutException {
        final CapabilityProvisioningResult capabilityResult = capabilityProvider
                .provideCapability(entandoApp.get(), new CapabilityRequirementBuilder()
                        .withCapability(StandardCapability.SSO)
                        .withPreferredDbms(determineDbmsForSso())
                        .withPreferredIngressHostName(entandoApp.get().getSpec().getIngressHostName().orElse(null))
                        .withPreferredTlsSecretName(entandoApp.get().getSpec().getTlsSecretName().orElse(null))
                        .withResolutionScopePreference(CapabilityScope.NAMESPACE, CapabilityScope.CLUSTER)
                        .build(), EntandoOperatorSpiConfig.getPodCompletionTimeoutSeconds() + EntandoOperatorSpiConfig
                        .getPodReadinessTimeoutSeconds());
        final ProvidedCapability ssoCapability = capabilityResult.getProvidedCapability();
        ssoCapability.getStatus().getServerStatus(NameUtils.MAIN_QUALIFIER).ifPresent(s ->
                this.entandoApp.updateAndGet(a -> this.k8sClient.updateStatus(a, new ServerStatus(NameUtils.SSO_QUALIFIER, s))));
        capabilityResult.getControllerFailure().ifPresent(f -> {
            throw new EntandoControllerException(ssoCapability,
                    format("Could not prepare SSO capability for EntandoApp %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",
                            entandoApp.get().getMetadata().getNamespace(),
                            entandoApp.get().getMetadata().getName(),
                            ssoCapability.getMetadata().getNamespace(),
                            ssoCapability.getMetadata().getName(),
                            f.getDetailMessage()));
        });
        return new ProvidedSsoCapability(capabilityResult);
    }

    private DbmsVendor determineDbmsForSso() {
        final DbmsVendor dbmsVendor = EntandoAppHelper.determineDbmsVendor(entandoApp.get());
        if (dbmsVendor == DbmsVendor.NONE) {
            return null;
        }
        return dbmsVendor;
    }

}