EntandoBundleServiceImpl.java

/*
 * Copyright 2018-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.service.digitalexchange.component;

import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.apache.commons.compress.utils.Sets;
import org.apache.logging.log4j.util.Strings;
import org.entando.kubernetes.client.k8ssvc.K8SServiceClient;
import org.entando.kubernetes.exception.EntandoComponentManagerException;
import org.entando.kubernetes.exception.digitalexchange.BundleNotInstalledException;
import org.entando.kubernetes.model.bundle.BundleType;
import org.entando.kubernetes.model.bundle.ComponentType;
import org.entando.kubernetes.model.bundle.EntandoBundle;
import org.entando.kubernetes.model.bundle.EntandoBundleVersion;
import org.entando.kubernetes.model.debundle.EntandoDeBundle;
import org.entando.kubernetes.model.debundle.EntandoDeBundleDetails;
import org.entando.kubernetes.model.job.EntandoBundleComponentJobEntity;
import org.entando.kubernetes.model.job.EntandoBundleEntity;
import org.entando.kubernetes.model.job.EntandoBundleJob;
import org.entando.kubernetes.model.job.EntandoBundleJobEntity;
import org.entando.kubernetes.model.job.JobStatus;
import org.entando.kubernetes.model.web.request.PagedListRequest;
import org.entando.kubernetes.model.web.response.PagedMetadata;
import org.entando.kubernetes.repository.EntandoBundleComponentJobRepository;
import org.entando.kubernetes.repository.EntandoBundleJobRepository;
import org.entando.kubernetes.repository.InstalledEntandoBundleRepository;
import org.entando.kubernetes.service.digitalexchange.BundleUtilities;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class EntandoBundleServiceImpl implements EntandoBundleService {

    private final K8SServiceClient k8SServiceClient;
    private final List<String> accessibleDigitalExchanges;
    private final EntandoBundleJobRepository jobRepository;
    private final InstalledEntandoBundleRepository installedComponentRepo;
    private final EntandoBundleComponentJobRepository jobComponentRepository;

    public EntandoBundleServiceImpl(K8SServiceClient k8SServiceClient,
            @Value("${entando.component.repository.namespaces:}") List<String> accessibleDigitalExchanges,
            EntandoBundleJobRepository jobRepository,
            EntandoBundleComponentJobRepository jobComponentRepository,
            InstalledEntandoBundleRepository installedComponentRepo) {
        this.k8SServiceClient = k8SServiceClient;
        this.accessibleDigitalExchanges = Optional.ofNullable(accessibleDigitalExchanges).orElse(new ArrayList<>())
                .stream().filter(Strings::isNotBlank).collect(Collectors.toList());
        this.jobRepository = jobRepository;
        this.jobComponentRepository = jobComponentRepository;
        this.installedComponentRepo = installedComponentRepo;
    }

    @Override
    public PagedMetadata<EntandoBundle> listBundles() {
        return listBundles(new PagedListRequest());
    }

    @Override
    public PagedMetadata<EntandoBundle> listBundles(PagedListRequest request) {
        //TODO may generate performance issues if list of bundles is too big
        List<EntandoBundle> allBundles = listAllBundles();
        List<EntandoBundle> localFilteredList = new EntandoBundleListProcessor(request, allBundles)
                .filterAndSort().toList();
        List<EntandoBundle> sublist = request.getSublist(localFilteredList);

        return new PagedMetadata<>(request, sublist, localFilteredList.size());
    }

    private List<EntandoBundle> listAllBundles() {
        List<EntandoBundle> allComponents = new ArrayList<>();
        List<EntandoBundleEntity> installedBundles = installedComponentRepo.findAll();

        List<EntandoBundle> availableBundles = listBundlesFromEcr();
        List<EntandoBundle> installedButNotAvailableOnEcr = filterInstalledButNotAvailableOnEcr(availableBundles,
                installedBundles);

        allComponents.addAll(availableBundles);
        allComponents.addAll(installedButNotAvailableOnEcr);

        return updateCustomInstallationFlag(allComponents, installedBundles);
    }

    /**
     * for each installed bundle, update the field custom_installation in the allBundles list.
     * @param allBundles the list representing all the available EntandoBundle
     * @param installedBundles the list of installed EntandoBundleEntity
     * @return a new instance of a list of all bundles with customInstallation field updated
     */
    private List<EntandoBundle> updateCustomInstallationFlag(List<EntandoBundle> allBundles, List<EntandoBundleEntity> installedBundles) {

        // get installed bundles jobs id
        Set<UUID> installedBunblesJobIdSet = installedBundles.stream()
                .map(entandoBundleEntity -> entandoBundleEntity.getJob().getId())
                .collect(Collectors.toSet());

        // fetch EntandoBundleJobEntitis from DB for installed bundles
        Map<String, EntandoBundleJobEntity> installedBundleJobEntities =
                jobRepository.findEntandoBundleJobEntityByIdIn(installedBunblesJobIdSet)
                        .orElse(new ArrayList<>())
                        .stream()
                        .collect(Collectors.toMap(
                                EntandoBundleJobEntity::getComponentId,
                                entandoBundleEntity -> entandoBundleEntity));

        // populate and return customInstallation field
        return allBundles.stream().map(entandoBundle -> {
            if (installedBundleJobEntities.containsKey(entandoBundle.getCode())) {
                entandoBundle.setCustomInstallation(
                        installedBundleJobEntities.get(entandoBundle.getCode()).getCustomInstallation());
            }
            return entandoBundle;
        }).collect(Collectors.toList());
    }

    @Override
    public Optional<EntandoBundle> getInstalledBundle(String id) {
        return installedComponentRepo.findById(id)
                .map(this::convertToBundleFromEntity);
    }

    @Override
    public List<EntandoBundleComponentJobEntity> getBundleInstalledComponents(String id) {
        EntandoBundle bundle = getInstalledBundle(id)
                .orElseThrow(() -> new BundleNotInstalledException("Bundle " + id + " is not installed in the system"));
        if (bundle.getInstalledJob() != null && bundle.getInstalledJob().getStatus()
                .equals(JobStatus.INSTALL_COMPLETED)) {
            return jobComponentRepository.findAllByParentJobId(bundle.getInstalledJob().getId());
        } else {
            throw new EntandoComponentManagerException("Bundle " + id + " is not installed correctly");
        }
    }

    private List<EntandoBundle> filterInstalledButNotAvailableOnEcr(List<EntandoBundle> availableBundles,
            List<EntandoBundleEntity> installedBundles) {
        //TODO could be a problem if available bundles list is too big
        Set<String> keySet = availableBundles.stream().map(EntandoBundle::getCode).collect(Collectors.toSet());
        return installedBundles.stream()
                .filter(b -> !keySet.contains(b.getId()))
                .map(this::convertToBundleFromEntity)
                .collect(Collectors.toList());
    }

    private List<EntandoBundle> listBundlesFromEcr() {
        List<EntandoDeBundle> bundles;
        if (accessibleDigitalExchanges.isEmpty()) {
            bundles = k8SServiceClient.getBundlesInObservedNamespaces();
        } else {
            bundles = k8SServiceClient.getBundlesInNamespaces(accessibleDigitalExchanges);
        }

        return bundles.stream()
                .map(this::convertToBundleFromEcr)
                .collect(Collectors.toList());
    }

    @Override
    public EntandoBundle convertToBundleFromEntity(EntandoBundleEntity entity) {
        EntandoBundleJob installedJob = null;
        EntandoBundleJob lastJob = jobRepository.findFirstByComponentIdOrderByStartedAtDesc(entity.getId())
                .map(EntandoBundleJob::fromEntity)
                .orElse(null);

        if (installedComponentRepo.existsById(entity.getId())) {
            installedJob = jobRepository
                    .findFirstByComponentIdAndStatusOrderByStartedAtDesc(entity.getId(), JobStatus.INSTALL_COMPLETED)
                    .map(EntandoBundleJob::fromEntity)
                    .orElse(null);
        }

        return EntandoBundle.builder()
                .code(entity.getId())
                .title(entity.getName())
                .description(entity.getDescription())
                .thumbnail(entity.getImage())
                //.organization(entity.getOrganization())
                .componentTypes(entity.getType())
                .lastJob(lastJob)
                .installedJob(installedJob)
                //.versions() //DB entity shouldn't keep all available versions
                .build();
    }

    @Override
    public EntandoBundleEntity convertToEntityFromEcr(EntandoDeBundle bundle) {
        return convertToEntityFromBundle(convertToBundleFromEcr(bundle));
    }

    @Override
    public EntandoBundleEntity convertToEntityFromBundle(EntandoBundle bundle) {
        return EntandoBundleEntity.builder()
                .id(bundle.getCode())
                //.name(bundle.getTitle())
                .name(bundle.getCode())
                .description(bundle.getDescription())
                .image(bundle.getThumbnail())
                //.organization(entity.getOrganization())
                .type(bundle.getComponentTypes())
                .installed(bundle.isInstalled())
                .version(bundle.isInstalled() ? bundle.getInstalledJob().getComponentVersion() : null)
                .lastUpdate(bundle.isInstalled()
                        ? Date.from(bundle.getInstalledJob().getFinishedAt().atZone(ZoneOffset.UTC).toInstant()) : null)
                .build();
    }

    @Override
    public EntandoBundle convertToBundleFromEcr(EntandoDeBundle deBundle) {
        Set<String> bundleComponentTypes = Sets.newHashSet("bundle");
        BundleType bundleType = BundleType.STANDARD_BUNDLE;
        if (deBundle.getMetadata().getLabels() != null) {

            deBundle.getMetadata().getLabels()
                    .keySet().stream()
                    .filter(ComponentType::isValidType)
                    .forEach(bundleComponentTypes::add);

            bundleType = BundleUtilities.extractBundleTypeFromBundle(deBundle);
        }

        EntandoDeBundleDetails details = deBundle.getSpec().getDetails();
        String code = deBundle.getMetadata().getName();

        EntandoBundleJob installedJob = null;
        EntandoBundleJob lastJob = jobRepository.findFirstByComponentIdOrderByStartedAtDesc(code)
                .map(EntandoBundleJob::fromEntity)
                .orElse(null);

        if (installedComponentRepo.existsById(code)) {
            installedJob = jobRepository
                    .findFirstByComponentIdAndStatusOrderByStartedAtDesc(code, JobStatus.INSTALL_COMPLETED)
                    .map(EntandoBundleJob::fromEntity)
                    .orElse(null);
        }

        EntandoBundle bundle = EntandoBundle.builder()
                .code(code)
                .title(details.getName())
                .description(details.getDescription())
                .bundleType(bundleType)
                .componentTypes(bundleComponentTypes)
                .thumbnail(details.getThumbnail())
                .installedJob(installedJob)
                .lastJob(lastJob)
                .versions(deBundle.getSpec().getTags().stream()
                        .map(EntandoBundleVersion::fromEntity) //TODO how to read timestamp from k8s custom model?
                        .collect(Collectors.toList()))
                .build();

        EntandoBundleVersion latest;
        if (deBundle.getSpec().getDetails() != null && deBundle.getSpec().getDetails().getDistTags() != null && deBundle
                .getSpec().getDetails().getDistTags().containsKey(BundleUtilities.LATEST_VERSION)) {

            latest = new EntandoBundleVersion()
                    .setVersion(deBundle.getSpec().getDetails().getDistTags().get("latest").toString());
        } else {
            latest = BundleUtilities.composeLatestVersion(deBundle).orElse(null);
        }

        bundle.setLatestVersion(latest);

        return bundle;
    }

}