From 7684a4a33ee1131e698daa5b1d45eed433f986e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Wed, 25 Mar 2026 12:08:47 +0100 Subject: [PATCH 01/10] argocd operator --- .../apps/argocd/argocd/Chart.lock | 6 - .../apps/argocd/argocd/Chart.yaml | 9 - ...s.ftl.yaml => argocd-helm-values.ftl.yaml} | 0 scripts/local/install-argocd-operator.sh | 2 +- .../GitopsPlaygroundCliMainScripted.groovy | 116 +- .../com/cloudogu/gitops/config/Config.groovy | 1362 ++++++++--------- .../gitops/features/argocd/ArgoCD.groovy | 640 ++++---- 7 files changed, 1049 insertions(+), 1086 deletions(-) delete mode 100644 argocd/cluster-resources/apps/argocd/argocd/Chart.lock delete mode 100644 argocd/cluster-resources/apps/argocd/argocd/Chart.yaml rename argocd/cluster-resources/apps/argocd/argocd/{values.ftl.yaml => argocd-helm-values.ftl.yaml} (100%) diff --git a/argocd/cluster-resources/apps/argocd/argocd/Chart.lock b/argocd/cluster-resources/apps/argocd/argocd/Chart.lock deleted file mode 100644 index f2ddb88ab..000000000 --- a/argocd/cluster-resources/apps/argocd/argocd/Chart.lock +++ /dev/null @@ -1,6 +0,0 @@ -dependencies: -- name: argo-cd - repository: https://argoproj.github.io/argo-helm - version: 8.3.9 -digest: sha256:40eff148a8501a7d534a71f1b6593fd845c75a73ffddfc487df0c43036443814 -generated: "2025-09-16T09:13:28.451562996+02:00" diff --git a/argocd/cluster-resources/apps/argocd/argocd/Chart.yaml b/argocd/cluster-resources/apps/argocd/argocd/Chart.yaml deleted file mode 100644 index b368f0181..000000000 --- a/argocd/cluster-resources/apps/argocd/argocd/Chart.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v2 -name: argocd -version: 1.0.0 -description: Wraps the upstream argo-cd helm chart -dependencies: - - name: argo-cd - version: 8.3.9 # run `helm dep update .` in this folder before changing and pushing version - repository: https://argoproj.github.io/argo-helm - # Information about helm chart here: https://artifacthub.io/packages/helm/argo/argo-cd \ No newline at end of file diff --git a/argocd/cluster-resources/apps/argocd/argocd/values.ftl.yaml b/argocd/cluster-resources/apps/argocd/argocd/argocd-helm-values.ftl.yaml similarity index 100% rename from argocd/cluster-resources/apps/argocd/argocd/values.ftl.yaml rename to argocd/cluster-resources/apps/argocd/argocd/argocd-helm-values.ftl.yaml diff --git a/scripts/local/install-argocd-operator.sh b/scripts/local/install-argocd-operator.sh index 387c84402..8b56e9e3e 100644 --- a/scripts/local/install-argocd-operator.sh +++ b/scripts/local/install-argocd-operator.sh @@ -1,6 +1,6 @@ git clone https://github.com/argoproj-labs/argocd-operator && \ cd argocd-operator && \ -git checkout release-0.16 && \ +git checkout release-0.17 && \ make deploy IMG=quay.io/argoprojlabs/argocd-operator:v0.17.0 rm -Rf ../argocd-operator/ diff --git a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy index e45b2d0a7..65a2b2490 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy @@ -17,11 +17,16 @@ import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.jenkins.* import com.cloudogu.gitops.kubernetes.api.HelmClient import com.cloudogu.gitops.kubernetes.api.K8sClient -import com.cloudogu.gitops.utils.* -import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j +import com.cloudogu.gitops.utils.AirGappedUtils +import com.cloudogu.gitops.utils.CommandExecutor +import com.cloudogu.gitops.utils.FileSystemUtils +import com.cloudogu.gitops.utils.NetworkingUtils + import io.micronaut.context.ApplicationContext + import jakarta.inject.Provider +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j /** * Micronaut's dependency injection relies on statically compiled class files with seems incompatible with groovy @@ -30,71 +35,66 @@ import jakarta.inject.Provider * air-gapped customer envs. * * To make this work the dev image gets it's own main() method that explicitly creates instances of the groovy classes. - * Yes, redundant and not beautiful, but not using dependency injection is worse. - */ + * Yes, redundant and not beautiful, but not using dependency injection is worse.*/ @Slf4j @CompileStatic class GitopsPlaygroundCliMainScripted { - static void main(String[] args) throws Exception { - new GitopsPlaygroundCliMain().exec(args, GitopsPlaygroundCliScripted) - } + static void main(String[] args) throws Exception { + new GitopsPlaygroundCliMain().exec(args, GitopsPlaygroundCliScripted) + } - static class GitopsPlaygroundCliScripted extends GitopsPlaygroundCli { + static class GitopsPlaygroundCliScripted extends GitopsPlaygroundCli { - protected void register(Config config, ApplicationContext context) { - super.register(config, context) + protected void register(Config config, ApplicationContext context) { + super.register(config, context) - FileSystemUtils fileSystemUtils = new FileSystemUtils() - CommandExecutor executor = new CommandExecutor() - NetworkingUtils networkingUtils = new NetworkingUtils() + FileSystemUtils fileSystemUtils = new FileSystemUtils() + CommandExecutor executor = new CommandExecutor() + NetworkingUtils networkingUtils = new NetworkingUtils() - K8sClient k8sClient = new K8sClient(executor, fileSystemUtils, new Provider() { - @Override - Config get() { - return config - } - }) + K8sClient k8sClient = new K8sClient(executor, fileSystemUtils, new Provider() { + @Override + Config get() { + return config + } + }) - HelmClient helmClient = new HelmClient(executor) - HttpClientFactory httpClientFactory = new HttpClientFactory() - GitRepoFactory gitRepoFactory = new GitRepoFactory(config, fileSystemUtils) - HelmStrategy helmStrategy = new HelmStrategy(config, helmClient) - GitHandler gitHandler = new GitHandler(config, helmStrategy, fileSystemUtils, k8sClient, networkingUtils) + HelmClient helmClient = new HelmClient(executor) + HttpClientFactory httpClientFactory = new HttpClientFactory() + GitRepoFactory gitRepoFactory = new GitRepoFactory(config, fileSystemUtils) + HelmStrategy helmStrategy = new HelmStrategy(config, helmClient) + GitHandler gitHandler = new GitHandler(config, helmStrategy, fileSystemUtils, k8sClient, networkingUtils) - JenkinsApiClient jenkinsApiClient = new JenkinsApiClient(config, - httpClientFactory.okHttpClientJenkins(config)) + JenkinsApiClient jenkinsApiClient = new JenkinsApiClient(config, + httpClientFactory.okHttpClientJenkins(config)) - context.registerSingleton(k8sClient) + context.registerSingleton(k8sClient) - if (config.application.destroy) { - context.registerSingleton(new Destroyer([ - new ArgoCDDestructionHandler(config, k8sClient, gitRepoFactory, helmClient, fileSystemUtils, gitHandler), - new ScmmDestructionHandler(config), - new JenkinsDestructionHandler(new JobManager(jenkinsApiClient), config, new GlobalPropertyManager(jenkinsApiClient)), - ])) - } else { - Deployer deployer = new Deployer(config, new ArgoCdApplicationStrategy(config, fileSystemUtils, gitRepoFactory, gitHandler), helmStrategy) - AirGappedUtils airGappedUtils = new AirGappedUtils(config, gitRepoFactory, fileSystemUtils, helmClient, gitHandler) - Jenkins jenkins = new Jenkins(config, executor, fileSystemUtils, new GlobalPropertyManager(jenkinsApiClient), - new JobManager(jenkinsApiClient), new UserManager(jenkinsApiClient), - new PrometheusConfigurator(jenkinsApiClient), helmStrategy, k8sClient, networkingUtils, gitHandler) + if (config.application.destroy) { + context.registerSingleton(new Destroyer([new ArgoCDDestructionHandler(config, k8sClient, gitRepoFactory, helmClient, fileSystemUtils, gitHandler), + new ScmmDestructionHandler(config), + new JenkinsDestructionHandler(new JobManager(jenkinsApiClient), config, new GlobalPropertyManager(jenkinsApiClient)),])) + } else { + Deployer deployer = new Deployer(config, new ArgoCdApplicationStrategy(config, fileSystemUtils, gitRepoFactory, gitHandler), helmStrategy) + AirGappedUtils airGappedUtils = new AirGappedUtils(config, gitRepoFactory, fileSystemUtils, helmClient, gitHandler) + Jenkins jenkins = new Jenkins(config, executor, fileSystemUtils, new GlobalPropertyManager(jenkinsApiClient), + new JobManager(jenkinsApiClient), new UserManager(jenkinsApiClient), + new PrometheusConfigurator(jenkinsApiClient), helmStrategy, k8sClient, networkingUtils, gitHandler) - // make sure the order of features is in same order as the @Order values - context.registerSingleton(new Application(config, [ - new Registry(config, fileSystemUtils, k8sClient, helmStrategy), - gitHandler, - jenkins, - new ArgoCD(config, k8sClient, helmClient, fileSystemUtils, gitRepoFactory, gitHandler), - new Ingress(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), - new CertManager(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), - new Mail(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), - new Monitoring(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitRepoFactory, gitHandler), - new ExternalSecretsOperator(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), - new Vault(config, fileSystemUtils, k8sClient, deployer, airGappedUtils, gitHandler), - new ContentLoader(config, k8sClient, gitRepoFactory, jenkins, gitHandler), - ])) - } - } - } -} + // make sure the order of features is in same order as the @Order values + context.registerSingleton(new Application(config, [new Registry(config, fileSystemUtils, k8sClient, helmStrategy), + gitHandler, + jenkins, + new ArgoCD(config, k8sClient, helmClient, deployer, fileSystemUtils, gitRepoFactory, gitHandler), + new Ingress(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), + new CertManager(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), + new Mail(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), + new Monitoring(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitRepoFactory, gitHandler), + new ExternalSecretsOperator(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), + new Vault(config, fileSystemUtils, k8sClient, deployer, airGappedUtils, gitHandler), + new ContentLoader(config, k8sClient, gitRepoFactory, jenkins, gitHandler),])) + } + } + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/config/Config.groovy b/src/main/groovy/com/cloudogu/gitops/config/Config.groovy index 13f2a3eed..93c8c8371 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/Config.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/Config.groovy @@ -1,6 +1,14 @@ package com.cloudogu.gitops.config +import static com.cloudogu.gitops.config.ConfigConstants.* +import static picocli.CommandLine.ScopeType + import com.cloudogu.gitops.features.git.config.ScmTenantSchema + +import jakarta.inject.Singleton +import groovy.transform.CompileStatic +import groovy.transform.MapConstructor + import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonPropertyDescription import com.fasterxml.jackson.core.JsonGenerator @@ -9,16 +17,10 @@ import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.ser.BeanPropertyWriter import com.fasterxml.jackson.databind.ser.BeanSerializerModifier import com.fasterxml.jackson.dataformat.yaml.YAMLMapper -import groovy.transform.CompileStatic -import groovy.transform.MapConstructor -import jakarta.inject.Singleton import picocli.CommandLine.Command import picocli.CommandLine.Mixin import picocli.CommandLine.Option -import static com.cloudogu.gitops.config.ConfigConstants.* -import static picocli.CommandLine.ScopeType - /** * The global configuration object. * @@ -49,711 +51,701 @@ import static picocli.CommandLine.ScopeType @CompileStatic class Config { - // When updating please also update in Dockerfile - public static final String HELM_IMAGE = "ghcr.io/cloudogu/helm:3.16.4-1" - // When updating please also adapt in Dockerfile, vars.tf and init-cluster.sh - public static final String K8S_VERSION = "1.29" - public static final String DEFAULT_ADMIN_USER = 'admin' - public static final String DEFAULT_ADMIN_PW = 'admin' - public static final int DEFAULT_REGISTRY_PORT = 30000 - - @JsonPropertyDescription(REGISTRY_DESCRIPTION) - @Mixin - RegistrySchema registry = new RegistrySchema() + // When updating please also update in Dockerfile + public static final String HELM_IMAGE = "ghcr.io/cloudogu/helm:3.16.4-1" + // When updating please also adapt in Dockerfile, vars.tf and init-cluster.sh + public static final String K8S_VERSION = "1.29" + public static final String DEFAULT_ADMIN_USER = 'admin' + public static final String DEFAULT_ADMIN_PW = 'admin' + public static final int DEFAULT_REGISTRY_PORT = 30000 - @JsonPropertyDescription(JENKINS_DESCRIPTION) - @Mixin - JenkinsSchema jenkins = new JenkinsSchema() - - @JsonPropertyDescription(MULTITENANT_DESCRIPTION) - @Mixin - MultiTenantSchema multiTenant = new MultiTenantSchema() - - @JsonPropertyDescription(SCM_DESCRIPTION) - @Mixin - ScmTenantSchema scm = new ScmTenantSchema() - - @JsonPropertyDescription(APPLICATION_DESCRIPTION) - @Mixin - ApplicationSchema application = new ApplicationSchema() - - @JsonPropertyDescription(FEATURES_DESCRIPTION) - @Mixin - FeaturesSchema features = new FeaturesSchema() - - @JsonPropertyDescription(CONTENT_DESCRIPTION) - @Mixin - ContentSchema content = new ContentSchema() - - static class ContentSchema { - @Option(names = ['--content-examples'], description = CONTENT_EXAMPLES_DESCRIPTION) - @JsonPropertyDescription(CONTENT_EXAMPLES_DESCRIPTION) - Boolean examples = false - - @Option(names = ['--multi-tenancy-examples'], description = CONTENT_MULTI_TENANCY_EXAMPLES_DESCRIPTION) - @JsonPropertyDescription(CONTENT_MULTI_TENANCY_EXAMPLES_DESCRIPTION) - Boolean multitenancyExamples = false - - @JsonPropertyDescription(CONTENT_NAMESPACES_DESCRIPTION) - List namespaces = [] - - @JsonPropertyDescription(CONTENT_REPO_DESCRIPTION) - List repos = [] - - @JsonPropertyDescription(CONTENT_VARIABLES_DESCRIPTION) - Map variables = [:] - - @Option(names = ['--content-whitelist'], description = CONTENT_STATICSWHITELIST_ENABLED_DESCRIPTION) - @JsonPropertyDescription(CONTENT_STATICSWHITELIST_ENABLED_DESCRIPTION) - Boolean useWhitelist = false - - @JsonPropertyDescription(CONTENT_STATICSWHITELIST_DESCRIPTION) - Set allowedStaticsWhitelist = [ - 'java.lang.String', - 'java.lang.Integer', - 'java.lang.Long', - 'java.lang.Double', - 'java.lang.Float', - 'java.lang.Boolean', - 'java.lang.Math', - 'com.cloudogu.gitops.utils.DockerImageParser' - ] as Set - - static class ContentRepositorySchema { - static final String DEFAULT_PATH = '.' - // This is controversial. Forcing users to explicitly choose a type requires them to understand the concept - // of types. What would be a good default? The simplest use case ist MIRROR from url to target. - // COPY and FOLDER_BASED are more advanced use cases. So we choose MIRROR as the default. - static final ContentRepoType DEFAULT_TYPE = ContentRepoType.MIRROR - - @JsonPropertyDescription(CONTENT_REPO_URL_DESCRIPTION) - String url = '' - - @JsonPropertyDescription(CONTENT_REPO_PATH_DESCRIPTION) - String path = DEFAULT_PATH - - @JsonPropertyDescription(CONTENT_REPO_REF_DESCRIPTION) - String ref = '' - - @JsonPropertyDescription(CONTENT_REPO_TARGET_REF_DESCRIPTION) - String targetRef = '' - - @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) - Credentials credentials - - @JsonPropertyDescription(CONTENT_REPO_TEMPLATING_DESCRIPTION) - Boolean templating = false - - @JsonPropertyDescription(CONTENT_REPO_TYPE_DESCRIPTION) - ContentRepoType type = DEFAULT_TYPE - - @JsonPropertyDescription(CONTENT_REPO_TARGET_DESCRIPTION) - String target = '' - - @JsonPropertyDescription(CONTENT_REPO_TARGET_OVERWRITE_MODE_DESCRIPTION) - OverwriteMode overwriteMode = OverwriteMode.INIT - // Defensively use init to not override existing files by default - - @JsonPropertyDescription(CONTENT_REPO_CREATE_JENKINS_JOB_DESCRIPTION) - Boolean createJenkinsJob = false - - } - } - - static class HelmConfig { - @JsonPropertyDescription(HELM_CONFIG_CHART_DESCRIPTION) - String chart = '' - @JsonPropertyDescription(HELM_CONFIG_REPO_URL_DESCRIPTION) - String repoURL = '' - @JsonPropertyDescription(HELM_CONFIG_VERSION_DESCRIPTION) - String version = '' - } - - static class HelmConfigWithValues extends HelmConfig { - @JsonPropertyDescription(HELM_CONFIG_VALUES_DESCRIPTION) - Map values = [:] - } - - static class RegistrySchema { - Boolean internal = true - Boolean twoRegistries = false - - @Option(names = ['--registry'], description = REGISTRY_ENABLE_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_ENABLE_DESCRIPTION) - Boolean active = false - - @Option(names = ['--internal-registry-port'], description = REGISTRY_INTERNAL_PORT_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_INTERNAL_PORT_DESCRIPTION) - Integer internalPort = DEFAULT_REGISTRY_PORT - - @Option(names = ['--registry-url'], description = REGISTRY_URL_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_URL_DESCRIPTION) - String url = '' - - @Option(names = ['--registry-path'], description = REGISTRY_PATH_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_PATH_DESCRIPTION) - String path = '' - - @Option(names = ['--registry-username'], description = REGISTRY_USERNAME_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_USERNAME_DESCRIPTION) - String username = '' - - @Option(names = ['--registry-password'], description = REGISTRY_PASSWORD_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_PASSWORD_DESCRIPTION) - String password = '' - - // Alternative: Use different registries, e.g. in air-gapped envs - // "Proxy" registry for 3rd party images, e.g. base images - @Option(names = ['--registry-proxy-url'], description = REGISTRY_PROXY_URL_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_PROXY_URL_DESCRIPTION) - String proxyUrl = '' - - @Option(names = ['--registry-proxy-path'], description = REGISTRY_PROXY_PATH_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_PROXY_PATH_DESCRIPTION) - String proxyPath = '' - - @Option(names = ['--registry-proxy-username'], description = REGISTRY_PROXY_PASSWORD_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_PROXY_USERNAME_DESCRIPTION) - String proxyUsername = '' - - @Option(names = ['--registry-proxy-password'], description = 'Optional when --registry-proxy-url is set') - @JsonPropertyDescription(REGISTRY_PROXY_PASSWORD_DESCRIPTION) - String proxyPassword = '' - - // Alternative set of credentials for url, used only for image pull secrets - @Option(names = ['--registry-username-read-only'], description = REGISTRY_USERNAME_RO_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_USERNAME_RO_DESCRIPTION) - String readOnlyUsername = '' - - @Option(names = ['--registry-password-read-only'], description = REGISTRY_PASSWORD_RO_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_PASSWORD_RO_DESCRIPTION) - String readOnlyPassword = '' - - @Option(names = ['--create-image-pull-secrets'], description = REGISTRY_CREATE_IMAGE_PULL_SECRETS_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_CREATE_IMAGE_PULL_SECRETS_DESCRIPTION) - Boolean createImagePullSecrets = false - - @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) - HelmConfigWithValues helm = new HelmConfigWithValues( - chart: 'docker-registry', - repoURL: 'https://twuni.github.io/docker-registry.helm', - version: '3.0.0') - - } - - static class JenkinsSchema { - Boolean internal = true - /* When installing via Docker we have to distinguish jenkins.url (which is a local IP address) from - the Jenkins URL used by SCMM. - - This is the URL configured in SCMM inside the Jenkins Plugin, e.g. at http://scmm.localhost/scm/admin/settings/jenkins - See addJenkinsConfig() and the comment at scmm.urlForJenkins */ - String urlForScm = '' - String ingress = '' - // Bash image used with internal Jenkins only - String internalBashImage = 'bash:5' - /* Docker client image, downloaded on internal Jenkins only - For updating, delete pvc jenkins-docker-client - When updating, we should not use too recent version, to not break support for LTS distros like debian - https://docs.docker.com/engine/install/debian/#os-requirements -> oldstable - For example: - $ curl -s https://download.docker.com/linux/debian/dists/bullseye/stable/binary-amd64/Packages | grep -EA5 'Package\: docker-ce$' | grep Version | sort | uniq | tail -n1 - Version: 5:27.1.1-1~debian.11~bullseye */ - String internalDockerClientVersion = '27.1.2' - - @Option(names = ['--jenkins'], description = JENKINS_ENABLE_DESCRIPTION) - @JsonPropertyDescription(JENKINS_ENABLE_DESCRIPTION) - Boolean active = false - - @Option(names = ['--jenkins-skip-restart'], description = JENKINS_SKIP_RESTART_DESCRIPTION) - @JsonPropertyDescription(JENKINS_SKIP_RESTART_DESCRIPTION) - Boolean skipRestart = false - - @Option(names = ['--jenkins-skip-plugins'], description = JENKINS_SKIP_PLUGINS_DESCRIPTION) - @JsonPropertyDescription(JENKINS_SKIP_PLUGINS_DESCRIPTION) - Boolean skipPlugins = false - - @Option(names = ['--jenkins-url'], description = JENKINS_URL_DESCRIPTION) - @JsonPropertyDescription(JENKINS_URL_DESCRIPTION) - String url = '' - - @Option(names = ['--jenkins-username'], description = JENKINS_USERNAME_DESCRIPTION) - @JsonPropertyDescription(JENKINS_USERNAME_DESCRIPTION) - String username = DEFAULT_ADMIN_USER - - @Option(names = ['--jenkins-password'], description = JENKINS_PASSWORD_DESCRIPTION) - @JsonPropertyDescription(JENKINS_PASSWORD_DESCRIPTION) - String password = DEFAULT_ADMIN_PW - - @Option(names = ['--jenkins-metrics-username'], description = JENKINS_METRICS_USERNAME_DESCRIPTION) - @JsonPropertyDescription(JENKINS_METRICS_USERNAME_DESCRIPTION) - String metricsUsername = "metrics" - - @Option(names = ['--jenkins-metrics-password'], description = JENKINS_METRICS_PASSWORD_DESCRIPTION) - @JsonPropertyDescription(JENKINS_METRICS_PASSWORD_DESCRIPTION) - String metricsPassword = "metrics" - - @Option(names = ['--maven-central-mirror'], description = MAVEN_CENTRAL_MIRROR_DESCRIPTION) - @JsonPropertyDescription(MAVEN_CENTRAL_MIRROR_DESCRIPTION) - String mavenCentralMirror = '' - - @Option(names = ["--jenkins-additional-envs"], description = JENKINS_ADDITIONAL_ENVS_DESCRIPTION, split = ",", required = false) - @JsonPropertyDescription(JENKINS_ADDITIONAL_ENVS_DESCRIPTION) - Map additionalEnvs = [:] - - @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) - HelmConfigWithValues helm = new HelmConfigWithValues( - chart: 'jenkins', - repoURL: 'https://charts.jenkins.io', - version: '5.8.43') - } - - static class ApplicationSchema { - Boolean runningInsideK8s = false - String namePrefixForEnvVars = '' - String internalKubernetesApiUrl = '' - String localHelmChartFolder = System.getenv('LOCAL_HELM_CHART_FOLDER') - - NamespaceSchema namespaces = new NamespaceSchema() - - @Option(names = ['--config-file'], description = CONFIG_FILE_DESCRIPTION, split = ',') - List configFiles = [] - - @Option(names = ['--config-map'], description = CONFIG_MAP_DESCRIPTION, split = ',') - List configMaps = [] - - @Option(names = ['-d', '--debug'], description = DEBUG_DESCRIPTION, scope = ScopeType.INHERIT) - Boolean debug - - @Option(names = ['-x', '--trace'], description = TRACE_DESCRIPTION, scope = ScopeType.INHERIT) - Boolean trace - - @Option(names = ['--output-config-file'], description = OUTPUT_CONFIG_FILE_DESCRIPTION, help = true) - Boolean outputConfigFile = false - - @Option(names = ["-v", "--version"], help = true, description = "Display version and license info") - Boolean versionInfoRequested = false - - // We define or own --version, so we need to define our own help param. - // The param itself is not used, "usageHelp = true" leads to hel being printed - @Option(names = ["-h", "--help"], usageHelp = true, description = "Display this help message") - Boolean usageHelpRequested = false - - @Option(names = ['--insecure'], description = INSECURE_DESCRIPTION) - @JsonPropertyDescription(INSECURE_DESCRIPTION) - Boolean insecure = false - - @Option(names = ['--openshift'], description = OPENSHIFT_DESCRIPTION) - @JsonPropertyDescription(OPENSHIFT_DESCRIPTION) - Boolean openshift = false - - @Option(names = ['--username'], description = USERNAME_DESCRIPTION) - @JsonPropertyDescription(USERNAME_DESCRIPTION) - String username = DEFAULT_ADMIN_USER - - @Option(names = ['--password'], description = PASSWORD_DESCRIPTION) - @JsonPropertyDescription(PASSWORD_DESCRIPTION) - String password = DEFAULT_ADMIN_PW + @JsonPropertyDescription(REGISTRY_DESCRIPTION) + @Mixin + RegistrySchema registry = new RegistrySchema() - @Option(names = ['-y', '--yes'], description = PIPE_YES_DESCRIPTION) - @JsonPropertyDescription(PIPE_YES_DESCRIPTION) - Boolean yes = false + @JsonPropertyDescription(JENKINS_DESCRIPTION) + @Mixin + JenkinsSchema jenkins = new JenkinsSchema() + + @JsonPropertyDescription(MULTITENANT_DESCRIPTION) + @Mixin + MultiTenantSchema multiTenant = new MultiTenantSchema() + + @JsonPropertyDescription(SCM_DESCRIPTION) + @Mixin + ScmTenantSchema scm = new ScmTenantSchema() + + @JsonPropertyDescription(APPLICATION_DESCRIPTION) + @Mixin + ApplicationSchema application = new ApplicationSchema() + + @JsonPropertyDescription(FEATURES_DESCRIPTION) + @Mixin + FeaturesSchema features = new FeaturesSchema() + + @JsonPropertyDescription(CONTENT_DESCRIPTION) + @Mixin + ContentSchema content = new ContentSchema() + + static class ContentSchema { + @Option(names = ['--content-examples'], description = CONTENT_EXAMPLES_DESCRIPTION) + @JsonPropertyDescription(CONTENT_EXAMPLES_DESCRIPTION) + Boolean examples = false + + @Option(names = ['--multi-tenancy-examples'], description = CONTENT_MULTI_TENANCY_EXAMPLES_DESCRIPTION) + @JsonPropertyDescription(CONTENT_MULTI_TENANCY_EXAMPLES_DESCRIPTION) + Boolean multitenancyExamples = false + + @JsonPropertyDescription(CONTENT_NAMESPACES_DESCRIPTION) + List namespaces = [] + + @JsonPropertyDescription(CONTENT_REPO_DESCRIPTION) + List repos = [] + + @JsonPropertyDescription(CONTENT_VARIABLES_DESCRIPTION) + Map variables = [:] + + @Option(names = ['--content-whitelist'], description = CONTENT_STATICSWHITELIST_ENABLED_DESCRIPTION) + @JsonPropertyDescription(CONTENT_STATICSWHITELIST_ENABLED_DESCRIPTION) + Boolean useWhitelist = false + + @JsonPropertyDescription(CONTENT_STATICSWHITELIST_DESCRIPTION) + Set allowedStaticsWhitelist = ['java.lang.String', + 'java.lang.Integer', + 'java.lang.Long', + 'java.lang.Double', + 'java.lang.Float', + 'java.lang.Boolean', + 'java.lang.Math', + 'com.cloudogu.gitops.utils.DockerImageParser'] as Set + + static class ContentRepositorySchema { + static final String DEFAULT_PATH = '.' + // This is controversial. Forcing users to explicitly choose a type requires them to understand the concept + // of types. What would be a good default? The simplest use case ist MIRROR from url to target. + // COPY and FOLDER_BASED are more advanced use cases. So we choose MIRROR as the default. + static final ContentRepoType DEFAULT_TYPE = ContentRepoType.MIRROR + + @JsonPropertyDescription(CONTENT_REPO_URL_DESCRIPTION) + String url = '' + + @JsonPropertyDescription(CONTENT_REPO_PATH_DESCRIPTION) + String path = DEFAULT_PATH + + @JsonPropertyDescription(CONTENT_REPO_REF_DESCRIPTION) + String ref = '' + + @JsonPropertyDescription(CONTENT_REPO_TARGET_REF_DESCRIPTION) + String targetRef = '' + + @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) + Credentials credentials + + @JsonPropertyDescription(CONTENT_REPO_TEMPLATING_DESCRIPTION) + Boolean templating = false + + @JsonPropertyDescription(CONTENT_REPO_TYPE_DESCRIPTION) + ContentRepoType type = DEFAULT_TYPE + + @JsonPropertyDescription(CONTENT_REPO_TARGET_DESCRIPTION) + String target = '' + + @JsonPropertyDescription(CONTENT_REPO_TARGET_OVERWRITE_MODE_DESCRIPTION) + OverwriteMode overwriteMode = OverwriteMode.INIT + // Defensively use init to not override existing files by default + + @JsonPropertyDescription(CONTENT_REPO_CREATE_JENKINS_JOB_DESCRIPTION) + Boolean createJenkinsJob = false + + } + } + + static class HelmConfig { + @JsonPropertyDescription(HELM_CONFIG_CHART_DESCRIPTION) + String chart = '' + @JsonPropertyDescription(HELM_CONFIG_REPO_URL_DESCRIPTION) + String repoURL = '' + @JsonPropertyDescription(HELM_CONFIG_VERSION_DESCRIPTION) + String version = '' + } + + static class HelmConfigWithValues extends HelmConfig { + @JsonPropertyDescription(HELM_CONFIG_VALUES_DESCRIPTION) + Map values = [:] + } + + static class RegistrySchema { + Boolean internal = true + Boolean twoRegistries = false + + @Option(names = ['--registry'], description = REGISTRY_ENABLE_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_ENABLE_DESCRIPTION) + Boolean active = false + + @Option(names = ['--internal-registry-port'], description = REGISTRY_INTERNAL_PORT_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_INTERNAL_PORT_DESCRIPTION) + Integer internalPort = DEFAULT_REGISTRY_PORT + + @Option(names = ['--registry-url'], description = REGISTRY_URL_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_URL_DESCRIPTION) + String url = '' + + @Option(names = ['--registry-path'], description = REGISTRY_PATH_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_PATH_DESCRIPTION) + String path = '' + + @Option(names = ['--registry-username'], description = REGISTRY_USERNAME_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_USERNAME_DESCRIPTION) + String username = '' + + @Option(names = ['--registry-password'], description = REGISTRY_PASSWORD_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_PASSWORD_DESCRIPTION) + String password = '' + + // Alternative: Use different registries, e.g. in air-gapped envs + // "Proxy" registry for 3rd party images, e.g. base images + @Option(names = ['--registry-proxy-url'], description = REGISTRY_PROXY_URL_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_PROXY_URL_DESCRIPTION) + String proxyUrl = '' + + @Option(names = ['--registry-proxy-path'], description = REGISTRY_PROXY_PATH_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_PROXY_PATH_DESCRIPTION) + String proxyPath = '' + + @Option(names = ['--registry-proxy-username'], description = REGISTRY_PROXY_PASSWORD_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_PROXY_USERNAME_DESCRIPTION) + String proxyUsername = '' + + @Option(names = ['--registry-proxy-password'], description = 'Optional when --registry-proxy-url is set') + @JsonPropertyDescription(REGISTRY_PROXY_PASSWORD_DESCRIPTION) + String proxyPassword = '' + + // Alternative set of credentials for url, used only for image pull secrets + @Option(names = ['--registry-username-read-only'], description = REGISTRY_USERNAME_RO_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_USERNAME_RO_DESCRIPTION) + String readOnlyUsername = '' + + @Option(names = ['--registry-password-read-only'], description = REGISTRY_PASSWORD_RO_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_PASSWORD_RO_DESCRIPTION) + String readOnlyPassword = '' + + @Option(names = ['--create-image-pull-secrets'], description = REGISTRY_CREATE_IMAGE_PULL_SECRETS_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_CREATE_IMAGE_PULL_SECRETS_DESCRIPTION) + Boolean createImagePullSecrets = false + + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + HelmConfigWithValues helm = new HelmConfigWithValues(chart: 'docker-registry', + repoURL: 'https://twuni.github.io/docker-registry.helm', + version: '3.0.0') + + } + + static class JenkinsSchema { + Boolean internal = true + /* When installing via Docker we have to distinguish jenkins.url (which is a local IP address) from + the Jenkins URL used by SCMM. + + This is the URL configured in SCMM inside the Jenkins Plugin, e.g. at http://scmm.localhost/scm/admin/settings/jenkins + See addJenkinsConfig() and the comment at scmm.urlForJenkins */ + String urlForScm = '' + String ingress = '' + // Bash image used with internal Jenkins only + String internalBashImage = 'bash:5' + /* Docker client image, downloaded on internal Jenkins only + For updating, delete pvc jenkins-docker-client + When updating, we should not use too recent version, to not break support for LTS distros like debian + https://docs.docker.com/engine/install/debian/#os-requirements -> oldstable + For example: + $ curl -s https://download.docker.com/linux/debian/dists/bullseye/stable/binary-amd64/Packages | grep -EA5 'Package\: docker-ce$' | grep Version | sort | uniq | tail -n1 + Version: 5:27.1.1-1~debian.11~bullseye */ + String internalDockerClientVersion = '27.1.2' + + @Option(names = ['--jenkins'], description = JENKINS_ENABLE_DESCRIPTION) + @JsonPropertyDescription(JENKINS_ENABLE_DESCRIPTION) + Boolean active = false + + @Option(names = ['--jenkins-skip-restart'], description = JENKINS_SKIP_RESTART_DESCRIPTION) + @JsonPropertyDescription(JENKINS_SKIP_RESTART_DESCRIPTION) + Boolean skipRestart = false + + @Option(names = ['--jenkins-skip-plugins'], description = JENKINS_SKIP_PLUGINS_DESCRIPTION) + @JsonPropertyDescription(JENKINS_SKIP_PLUGINS_DESCRIPTION) + Boolean skipPlugins = false + + @Option(names = ['--jenkins-url'], description = JENKINS_URL_DESCRIPTION) + @JsonPropertyDescription(JENKINS_URL_DESCRIPTION) + String url = '' + + @Option(names = ['--jenkins-username'], description = JENKINS_USERNAME_DESCRIPTION) + @JsonPropertyDescription(JENKINS_USERNAME_DESCRIPTION) + String username = DEFAULT_ADMIN_USER + + @Option(names = ['--jenkins-password'], description = JENKINS_PASSWORD_DESCRIPTION) + @JsonPropertyDescription(JENKINS_PASSWORD_DESCRIPTION) + String password = DEFAULT_ADMIN_PW + + @Option(names = ['--jenkins-metrics-username'], description = JENKINS_METRICS_USERNAME_DESCRIPTION) + @JsonPropertyDescription(JENKINS_METRICS_USERNAME_DESCRIPTION) + String metricsUsername = "metrics" + + @Option(names = ['--jenkins-metrics-password'], description = JENKINS_METRICS_PASSWORD_DESCRIPTION) + @JsonPropertyDescription(JENKINS_METRICS_PASSWORD_DESCRIPTION) + String metricsPassword = "metrics" + + @Option(names = ['--maven-central-mirror'], description = MAVEN_CENTRAL_MIRROR_DESCRIPTION) + @JsonPropertyDescription(MAVEN_CENTRAL_MIRROR_DESCRIPTION) + String mavenCentralMirror = '' + + @Option(names = ["--jenkins-additional-envs"], description = JENKINS_ADDITIONAL_ENVS_DESCRIPTION, split = ",", required = false) + @JsonPropertyDescription(JENKINS_ADDITIONAL_ENVS_DESCRIPTION) + Map additionalEnvs = [:] + + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + HelmConfigWithValues helm = new HelmConfigWithValues(chart: 'jenkins', + repoURL: 'https://charts.jenkins.io', + version: '5.8.43') + } + + static class ApplicationSchema { + Boolean runningInsideK8s = false + String namePrefixForEnvVars = '' + String internalKubernetesApiUrl = '' + String localHelmChartFolder = System.getenv('LOCAL_HELM_CHART_FOLDER') + + NamespaceSchema namespaces = new NamespaceSchema() + + @Option(names = ['--config-file'], description = CONFIG_FILE_DESCRIPTION, split = ',') + List configFiles = [] + + @Option(names = ['--config-map'], description = CONFIG_MAP_DESCRIPTION, split = ',') + List configMaps = [] + + @Option(names = ['-d', '--debug'], description = DEBUG_DESCRIPTION, scope = ScopeType.INHERIT) + Boolean debug + + @Option(names = ['-x', '--trace'], description = TRACE_DESCRIPTION, scope = ScopeType.INHERIT) + Boolean trace + + @Option(names = ['--output-config-file'], description = OUTPUT_CONFIG_FILE_DESCRIPTION, help = true) + Boolean outputConfigFile = false + + @Option(names = ["-v", "--version"], help = true, description = "Display version and license info") + Boolean versionInfoRequested = false + + // We define or own --version, so we need to define our own help param. + // The param itself is not used, "usageHelp = true" leads to hel being printed + @Option(names = ["-h", "--help"], usageHelp = true, description = "Display this help message") + Boolean usageHelpRequested = false + + @Option(names = ['--insecure'], description = INSECURE_DESCRIPTION) + @JsonPropertyDescription(INSECURE_DESCRIPTION) + Boolean insecure = false + + @Option(names = ['--openshift'], description = OPENSHIFT_DESCRIPTION) + @JsonPropertyDescription(OPENSHIFT_DESCRIPTION) + Boolean openshift = false + + @Option(names = ['--username'], description = USERNAME_DESCRIPTION) + @JsonPropertyDescription(USERNAME_DESCRIPTION) + String username = DEFAULT_ADMIN_USER + + @Option(names = ['--password'], description = PASSWORD_DESCRIPTION) + @JsonPropertyDescription(PASSWORD_DESCRIPTION) + String password = DEFAULT_ADMIN_PW - @Option(names = ['--name-prefix'], description = NAME_PREFIX_DESCRIPTION) - @JsonPropertyDescription(NAME_PREFIX_DESCRIPTION) - String namePrefix = '' + @Option(names = ['-y', '--yes'], description = PIPE_YES_DESCRIPTION) + @JsonPropertyDescription(PIPE_YES_DESCRIPTION) + Boolean yes = false - @Option(names = ['--destroy'], description = DESTROY_DESCRIPTION) - @JsonPropertyDescription(DESTROY_DESCRIPTION) - Boolean destroy = false + @Option(names = ['--name-prefix'], description = NAME_PREFIX_DESCRIPTION) + @JsonPropertyDescription(NAME_PREFIX_DESCRIPTION) + String namePrefix = '' - @Option(names = ['--pod-resources'], description = POD_RESOURCES_DESCRIPTION) - @JsonPropertyDescription(POD_RESOURCES_DESCRIPTION) - Boolean podResources = false + @Option(names = ['--destroy'], description = DESTROY_DESCRIPTION) + @JsonPropertyDescription(DESTROY_DESCRIPTION) + Boolean destroy = false - @Option(names = ['--git-name'], description = GIT_NAME_DESCRIPTION) - @JsonPropertyDescription(GIT_NAME_DESCRIPTION) - String gitName = 'Cloudogu' + @Option(names = ['--pod-resources'], description = POD_RESOURCES_DESCRIPTION) + @JsonPropertyDescription(POD_RESOURCES_DESCRIPTION) + Boolean podResources = false - @Option(names = ['--git-email'], description = GIT_EMAIL_DESCRIPTION) - @JsonPropertyDescription(GIT_EMAIL_DESCRIPTION) - String gitEmail = 'hello@cloudogu.com' + @Option(names = ['--git-name'], description = GIT_NAME_DESCRIPTION) + @JsonPropertyDescription(GIT_NAME_DESCRIPTION) + String gitName = 'Cloudogu' - @Option(names = ['--base-url'], description = BASE_URL_DESCRIPTION) - @JsonPropertyDescription(BASE_URL_DESCRIPTION) - String baseUrl = '' + @Option(names = ['--git-email'], description = GIT_EMAIL_DESCRIPTION) + @JsonPropertyDescription(GIT_EMAIL_DESCRIPTION) + String gitEmail = 'hello@cloudogu.com' - @Option(names = ['--url-separator-hyphen'], description = URL_SEPARATOR_HYPHEN_DESCRIPTION) - @JsonPropertyDescription(URL_SEPARATOR_HYPHEN_DESCRIPTION) - Boolean urlSeparatorHyphen = false + @Option(names = ['--base-url'], description = BASE_URL_DESCRIPTION) + @JsonPropertyDescription(BASE_URL_DESCRIPTION) + String baseUrl = '' - @Option(names = ['--mirror-repos'], description = MIRROR_REPOS_DESCRIPTION) - @JsonPropertyDescription(MIRROR_REPOS_DESCRIPTION) - Boolean mirrorRepos = false + @Option(names = ['--url-separator-hyphen'], description = URL_SEPARATOR_HYPHEN_DESCRIPTION) + @JsonPropertyDescription(URL_SEPARATOR_HYPHEN_DESCRIPTION) + Boolean urlSeparatorHyphen = false - @Option(names = ['--skip-crds'], description = SKIP_CRDS_DESCRIPTION) - @JsonPropertyDescription(SKIP_CRDS_DESCRIPTION) - Boolean skipCrds = false + @Option(names = ['--mirror-repos'], description = MIRROR_REPOS_DESCRIPTION) + @JsonPropertyDescription(MIRROR_REPOS_DESCRIPTION) + Boolean mirrorRepos = false - @Option(names = ['--namespace-isolation'], description = NAMESPACE_ISOLATION_DESCRIPTION) - @JsonPropertyDescription(NAMESPACE_ISOLATION_DESCRIPTION) - Boolean namespaceIsolation = false + @Option(names = ['--skip-crds'], description = SKIP_CRDS_DESCRIPTION) + @JsonPropertyDescription(SKIP_CRDS_DESCRIPTION) + Boolean skipCrds = false - @Option(names = ['--netpols'], description = NETPOLS_DESCRIPTION) - @JsonPropertyDescription(NETPOLS_DESCRIPTION) - Boolean netpols = false + @Option(names = ['--namespace-isolation'], description = NAMESPACE_ISOLATION_DESCRIPTION) + @JsonPropertyDescription(NAMESPACE_ISOLATION_DESCRIPTION) + Boolean namespaceIsolation = false - @Option(names = ['--cluster-admin'], description = CLUSTER_ADMIN_DESCRIPTION) - @JsonPropertyDescription(CLUSTER_ADMIN_DESCRIPTION) - Boolean clusterAdmin = false + @Option(names = ['--netpols'], description = NETPOLS_DESCRIPTION) + @JsonPropertyDescription(NETPOLS_DESCRIPTION) + Boolean netpols = false - @Option(names = ["-p", "--profile"], description = APPLICATION_PROFIL) - String profile + @Option(names = ['--cluster-admin'], description = CLUSTER_ADMIN_DESCRIPTION) + @JsonPropertyDescription(CLUSTER_ADMIN_DESCRIPTION) + Boolean clusterAdmin = false - static class NamespaceSchema { - LinkedHashSet dedicatedNamespaces = new LinkedHashSet<>() - LinkedHashSet tenantNamespaces = new LinkedHashSet<>() + @Option(names = ["-p", "--profile"], description = APPLICATION_PROFIL) + String profile - LinkedHashSet getActiveNamespaces() { - return new LinkedHashSet<>(dedicatedNamespaces + tenantNamespaces) - } - } + static class NamespaceSchema { + LinkedHashSet dedicatedNamespaces = new LinkedHashSet<>() + LinkedHashSet tenantNamespaces = new LinkedHashSet<>() - @JsonIgnore - String getTenantName() { - return namePrefix.replaceAll(/-$/, "") - } - } + LinkedHashSet getActiveNamespaces() { + return new LinkedHashSet<>(dedicatedNamespaces + tenantNamespaces) + } + } - static class FeaturesSchema { + @JsonIgnore + String getTenantName() { + return namePrefix.replaceAll(/-$/, "") + } + } - @Mixin - @JsonPropertyDescription(ARGOCD_DESCRIPTION) - ArgoCDSchema argocd = new ArgoCDSchema() + static class FeaturesSchema { - @Mixin - @JsonPropertyDescription(MAIL_DESCRIPTION) - MailSchema mail = new MailSchema() + @Mixin + @JsonPropertyDescription(ARGOCD_DESCRIPTION) + ArgoCDSchema argocd = new ArgoCDSchema() - @Mixin - @JsonPropertyDescription(MONITORING_DESCRIPTION) - MonitoringSchema monitoring = new MonitoringSchema() + @Mixin + @JsonPropertyDescription(MAIL_DESCRIPTION) + MailSchema mail = new MailSchema() - @Mixin - @JsonPropertyDescription(SECRETS_DESCRIPTION) - SecretsSchema secrets = new SecretsSchema() + @Mixin + @JsonPropertyDescription(MONITORING_DESCRIPTION) + MonitoringSchema monitoring = new MonitoringSchema() - @Mixin - @JsonPropertyDescription(INGRESS_DESCRIPTION) - IngressSchema ingress = new IngressSchema() + @Mixin + @JsonPropertyDescription(SECRETS_DESCRIPTION) + SecretsSchema secrets = new SecretsSchema() - @Mixin - @JsonPropertyDescription(CERTMANAGER_DESCRIPTION) - CertManagerSchema certManager = new CertManagerSchema() - } + @Mixin + @JsonPropertyDescription(INGRESS_DESCRIPTION) + IngressSchema ingress = new IngressSchema() - static class ArgoCDSchema { - Boolean configOnly = false + @Mixin + @JsonPropertyDescription(CERTMANAGER_DESCRIPTION) + CertManagerSchema certManager = new CertManagerSchema() + } + + static class ArgoCDSchema { + Boolean configOnly = false - @Option(names = ['--argocd'], description = ARGOCD_ENABLE_DESCRIPTION) - @JsonPropertyDescription(ARGOCD_ENABLE_DESCRIPTION) - Boolean active = false + @Option(names = ['--argocd'], description = ARGOCD_ENABLE_DESCRIPTION) + @JsonPropertyDescription(ARGOCD_ENABLE_DESCRIPTION) + Boolean active = false - @Option(names = ['--argocd-operator'], description = ARGOCD_OPERATOR_DESCRIPTION) - @JsonPropertyDescription(ARGOCD_OPERATOR_DESCRIPTION) - Boolean operator = false - - @Option(names = ['--argocd-url'], description = ARGOCD_URL_DESCRIPTION) - @JsonPropertyDescription(ARGOCD_URL_DESCRIPTION) - String url = '' - - @JsonPropertyDescription(ARGOCD_ENV_DESCRIPTION) - List> env - - @Option(names = ['--argocd-email-from'], description = ARGOCD_EMAIL_FROM_DESCRIPTION) - @JsonPropertyDescription(ARGOCD_EMAIL_FROM_DESCRIPTION) - String emailFrom = 'argocd@example.org' - - @Option(names = ['--argocd-email-to-user'], description = ARGOCD_EMAIL_TO_USER_DESCRIPTION) - @JsonPropertyDescription(ARGOCD_EMAIL_TO_USER_DESCRIPTION) - String emailToUser = 'app-team@example.org' - - @Option(names = ['--argocd-email-to-admin'], description = ARGOCD_EMAIL_TO_ADMIN_DESCRIPTION) - @JsonPropertyDescription(ARGOCD_EMAIL_TO_ADMIN_DESCRIPTION) - String emailToAdmin = 'infra@example.org' - - @Option(names = ['--argocd-resource-inclusions-cluster'], description = ARGOCD_RESOURCE_INCLUSIONS_CLUSTER) - @JsonPropertyDescription(ARGOCD_RESOURCE_INCLUSIONS_CLUSTER) - String resourceInclusionsCluster = '' - - @Option(names = ['--argocd-namespace'], description = ARGOCD_CUSTOM_NAMESPACE_DESCRIPTION) - @JsonPropertyDescription(ARGOCD_CUSTOM_NAMESPACE_DESCRIPTION) - String namespace = 'argocd' - - @JsonPropertyDescription(HELM_CONFIG_VALUES_DESCRIPTION) - Map values = [:] - } - - static class MailSchema { - - Boolean active = false - - @Option(names = ['--mail'], description = MAILSERVER_ENABLE_DESCRIPTION, scope = ScopeType.INHERIT) - @JsonPropertyDescription(MAILSERVER_ENABLE_DESCRIPTION) - Boolean mailServer = false - - - @Option(names = ['--mail-url'], description = MAIL_URL_DESCRIPTION) - @JsonPropertyDescription(MAIL_URL_DESCRIPTION) - String mailUrl = '' - - @Option(names = ['--smtp-address'], description = SMTP_ADDRESS_DESCRIPTION) - @JsonPropertyDescription(SMTP_ADDRESS_DESCRIPTION) - String smtpAddress = '' - - @Option(names = ['--smtp-port'], description = SMTP_PORT_DESCRIPTION) - @JsonPropertyDescription(SMTP_PORT_DESCRIPTION) - Integer smtpPort = null - - @Option(names = ['--smtp-user'], description = SMTP_USER_DESCRIPTION) - @JsonPropertyDescription(SMTP_USER_DESCRIPTION) - String smtpUser = '' - - @Option(names = ['--smtp-password'], description = SMTP_PASSWORD_DESCRIPTION) - @JsonPropertyDescription(SMTP_PASSWORD_DESCRIPTION) - String smtpPassword = '' - - @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) - @Mixin - MailHelmSchema helm = new MailHelmSchema( - chart: 'mailhog', - repoURL: 'https://codecentric.github.io/helm-charts', - version: '5.0.1') - - static class MailHelmSchema extends HelmConfigWithValues { - @Option(names = ['--mail-image'], description = HELM_CONFIG_IMAGE_DESCRIPTION) - @JsonPropertyDescription(HELM_CONFIG_IMAGE_DESCRIPTION) - String image = 'ghcr.io/cloudogu/mailhog:v1.0.1' - } - } - - static class MonitoringSchema { - @Option(names = ['--metrics', '--monitoring'], description = MONITORING_ENABLE_DESCRIPTION) - @JsonPropertyDescription(MONITORING_ENABLE_DESCRIPTION) - Boolean active = false - - @Option(names = ['--grafana-url'], description = GRAFANA_URL_DESCRIPTION) - @JsonPropertyDescription(GRAFANA_URL_DESCRIPTION) - String grafanaUrl = '' - - @Option(names = ['--grafana-email-from'], description = GRAFANA_EMAIL_FROM_DESCRIPTION) - @JsonPropertyDescription(GRAFANA_EMAIL_FROM_DESCRIPTION) - String grafanaEmailFrom = 'grafana@example.org' - - @Option(names = ['--grafana-email-to'], description = GRAFANA_EMAIL_TO_DESCRIPTION) - @JsonPropertyDescription(GRAFANA_EMAIL_TO_DESCRIPTION) - String grafanaEmailTo = 'infra@example.org' - - @Mixin - @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) - @SuppressWarnings('GroovyAssignabilityCheck') - // Because of values - MonitoringHelmSchema helm = new MonitoringHelmSchema( - chart: 'kube-prometheus-stack', - repoURL: 'https://prometheus-community.github.io/helm-charts', - /* When updating this make sure to also test if air-gapped mode still works */ - version: '80.2.2', - values: [:] // Otherwise values is null 🤷‍♂️ - ) - static class MonitoringHelmSchema extends HelmConfigWithValues { - @Option(names = ['--grafana-image'], description = GRAFANA_IMAGE_DESCRIPTION) - @JsonPropertyDescription(GRAFANA_IMAGE_DESCRIPTION) - String grafanaImage = '' - - @Option(names = ['--grafana-sidecar-image'], description = GRAFANA_SIDECAR_IMAGE_DESCRIPTION) - @JsonPropertyDescription(GRAFANA_SIDECAR_IMAGE_DESCRIPTION) - String grafanaSidecarImage = '' - - @Option(names = ['--prometheus-image'], description = PROMETHEUS_IMAGE_DESCRIPTION) - @JsonPropertyDescription(PROMETHEUS_IMAGE_DESCRIPTION) - String prometheusImage = '' - - @Option(names = ['--prometheus-operator-image'], description = PROMETHEUS_OPERATOR_IMAGE_DESCRIPTION) - @JsonPropertyDescription(PROMETHEUS_OPERATOR_IMAGE_DESCRIPTION) - String prometheusOperatorImage = '' - - @Option(names = ['--prometheus-config-reloader-image'], description = PROMETHEUS_CONFIG_RELOADER_IMAGE_DESCRIPTION) - @JsonPropertyDescription(PROMETHEUS_CONFIG_RELOADER_IMAGE_DESCRIPTION) - String prometheusConfigReloaderImage = '' - } - } - - static class SecretsSchema { - Boolean active = false - - @Mixin - @JsonPropertyDescription(ESO_DESCRIPTION) - ESOSchema externalSecrets = new ESOSchema() - - @Mixin - @JsonPropertyDescription(VAULT_DESCRIPTION) - VaultSchema vault = new VaultSchema() - - static class ESOSchema { - - @Mixin - @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) - ESOHelmSchema helm = new ESOHelmSchema( - chart: 'external-secrets', - repoURL: 'https://charts.external-secrets.io', - version: '0.9.16' - ) - static class ESOHelmSchema extends HelmConfigWithValues { - @Option(names = ['--external-secrets-image'], description = EXTERNAL_SECRETS_IMAGE_DESCRIPTION) - @JsonPropertyDescription(EXTERNAL_SECRETS_IMAGE_DESCRIPTION) - String image = '' - - @Option(names = ['--external-secrets-certcontroller-image'], description = EXTERNAL_SECRETS_CERT_CONTROLLER_IMAGE_DESCRIPTION) - @JsonPropertyDescription(EXTERNAL_SECRETS_CERT_CONTROLLER_IMAGE_DESCRIPTION) - String certControllerImage = '' - - @Option(names = ['--external-secrets-webhook-image'], description = EXTERNAL_SECRETS_WEBHOOK_IMAGE_DESCRIPTION) - @JsonPropertyDescription(EXTERNAL_SECRETS_WEBHOOK_IMAGE_DESCRIPTION) - String webhookImage = '' - } - } - - static class VaultSchema { - @Option(names = ['--vault'], description = VAULT_ENABLE_DESCRIPTION) - @JsonPropertyDescription(VAULT_ENABLE_DESCRIPTION) - VaultMode mode - - @Option(names = ['--vault-url'], description = VAULT_URL_DESCRIPTION) - @JsonPropertyDescription(VAULT_URL_DESCRIPTION) - String url = '' - - @Mixin - @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) - VaultHelmSchema helm = new VaultHelmSchema( - chart: 'vault', - repoURL: 'https://helm.releases.hashicorp.com', - version: '0.25.0' - ) - static class VaultHelmSchema extends HelmConfigWithValues { - @Option(names = ['--vault-image'], description = VAULT_IMAGE_DESCRIPTION) - @JsonPropertyDescription(VAULT_IMAGE_DESCRIPTION) - String image = '' - } - } - } - - static class IngressSchema { - - @Option(names = ['--ingress'], description = INGRESS_ENABLE_DESCRIPTION) - @JsonPropertyDescription(INGRESS_ENABLE_DESCRIPTION) - Boolean active = false - - @Mixin - @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) - IngressHelmSchema helm = new IngressHelmSchema( - chart: 'traefik', - repoURL: 'https://traefik.github.io/charts', - version: '39.0.0' - ) - static class IngressHelmSchema extends HelmConfigWithValues { - @Option(names = ['--ingress-image'], description = HELM_CONFIG_IMAGE_DESCRIPTION) - @JsonPropertyDescription(HELM_CONFIG_IMAGE_DESCRIPTION) - String image = '' - } - - String ingressNamespace = 'ingress' - } - - static class CertManagerSchema { - @Option(names = ['--cert-manager'], description = CERTMANAGER_ENABLE_DESCRIPTION) - @JsonPropertyDescription(CERTMANAGER_ENABLE_DESCRIPTION) - Boolean active = false - - @Mixin - @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) - CertManagerHelmSchema helm = new CertManagerHelmSchema( - chart: 'cert-manager', - repoURL: 'https://charts.jetstack.io', - version: '1.16.1' - ) - static class CertManagerHelmSchema extends HelmConfigWithValues { - - @Option(names = ['--cert-manager-image'], description = CERTMANAGER_IMAGE_DESCRIPTION) - @JsonPropertyDescription(CERTMANAGER_IMAGE_DESCRIPTION) - String image = '' - - @Option(names = ['--cert-manager-webhook-image'], description = CERTMANAGER_WEBHOOK_IMAGE_DESCRIPTION) - @JsonPropertyDescription(CERTMANAGER_WEBHOOK_IMAGE_DESCRIPTION) - String webhookImage = '' - - @Option(names = ['--cert-manager-cainjector-image'], description = CERTMANAGER_CAINJECTOR_IMAGE_DESCRIPTION) - @JsonPropertyDescription(CERTMANAGER_CAINJECTOR_IMAGE_DESCRIPTION) - String cainjectorImage = '' - - @Option(names = ['--cert-manager-acme-solver-image'], description = CERTMANAGER_ACME_SOLVER_IMAGE_DESCRIPTION) - @JsonPropertyDescription(CERTMANAGER_ACME_SOLVER_IMAGE_DESCRIPTION) - String acmeSolverImage = '' - - @Option(names = ['--cert-manager-startup-api-check-image'], description = CERTMANAGER_STARTUP_API_CHECK_IMAGE_DESCRIPTION) - @JsonPropertyDescription(CERTMANAGER_STARTUP_API_CHECK_IMAGE_DESCRIPTION) - String startupAPICheckImage = '' - - } - } - - static enum ContentRepoType { - FOLDER_BASED, COPY, MIRROR - } - - static enum VaultMode { - dev, prod - } - - /** - * This defines, how customer repos will be updated. - * See {@link ConfigConstants#CONTENT_REPO_TARGET_OVERWRITE_MODE_DESCRIPTION} - */ - static enum OverwriteMode { - INIT, RESET, UPGRADE - } - - private static final ObjectMapper objectMapper = new ObjectMapper() - .registerModule(new SimpleModule().addSerializer(GString, new JsonSerializer() { - @Override - void serialize(GString value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { - jsonGenerator.writeString(value.toString()) - } - })) - - static Config fromMap(Map map) { - objectMapper.convertValue(map, Config) - } - - Map toMap() { - objectMapper.convertValue(this, Map) - } - - String toYaml(boolean includeInternals) { - createYamlMapper(includeInternals) - .writeValueAsString(this) - } - - private static YAMLMapper createYamlMapper(boolean includeInternals) { - if (!includeInternals) { - new YAMLMapper() - .registerModule(new SimpleModule().setSerializerModifier(new BeanSerializerModifier() { - @Override - List changeProperties(SerializationConfig serializationConfig, BeanDescription beanDesc, List beanProperties) { - beanProperties.findAll { writer -> writer.getAnnotation(JsonPropertyDescription) != null } - } - })) as YAMLMapper - } else { - new YAMLMapper() - } - } + @Option(names = ['--argocd-operator'], description = ARGOCD_OPERATOR_DESCRIPTION) + @JsonPropertyDescription(ARGOCD_OPERATOR_DESCRIPTION) + Boolean operator = false + + @Option(names = ['--argocd-url'], description = ARGOCD_URL_DESCRIPTION) + @JsonPropertyDescription(ARGOCD_URL_DESCRIPTION) + String url = '' + + @JsonPropertyDescription(ARGOCD_ENV_DESCRIPTION) + List> env + + @Option(names = ['--argocd-email-from'], description = ARGOCD_EMAIL_FROM_DESCRIPTION) + @JsonPropertyDescription(ARGOCD_EMAIL_FROM_DESCRIPTION) + String emailFrom = 'argocd@example.org' + + @Option(names = ['--argocd-email-to-user'], description = ARGOCD_EMAIL_TO_USER_DESCRIPTION) + @JsonPropertyDescription(ARGOCD_EMAIL_TO_USER_DESCRIPTION) + String emailToUser = 'app-team@example.org' + + @Option(names = ['--argocd-email-to-admin'], description = ARGOCD_EMAIL_TO_ADMIN_DESCRIPTION) + @JsonPropertyDescription(ARGOCD_EMAIL_TO_ADMIN_DESCRIPTION) + String emailToAdmin = 'infra@example.org' + + @Option(names = ['--argocd-resource-inclusions-cluster'], description = ARGOCD_RESOURCE_INCLUSIONS_CLUSTER) + @JsonPropertyDescription(ARGOCD_RESOURCE_INCLUSIONS_CLUSTER) + String resourceInclusionsCluster = '' + + @Option(names = ['--argocd-namespace'], description = ARGOCD_CUSTOM_NAMESPACE_DESCRIPTION) + @JsonPropertyDescription(ARGOCD_CUSTOM_NAMESPACE_DESCRIPTION) + String namespace = 'argocd' + + @JsonPropertyDescription(HELM_CONFIG_VALUES_DESCRIPTION) + Map values = [:] + + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + HelmConfigWithValues helm = new HelmConfigWithValues(chart: 'argo-cd', + repoURL: 'https://argoproj.github.io/argo-helm', + version: '9.4.15') + } + + static class MailSchema { + + Boolean active = false + + @Option(names = ['--mail'], description = MAILSERVER_ENABLE_DESCRIPTION, scope = ScopeType.INHERIT) + @JsonPropertyDescription(MAILSERVER_ENABLE_DESCRIPTION) + Boolean mailServer = false + + @Option(names = ['--mail-url'], description = MAIL_URL_DESCRIPTION) + @JsonPropertyDescription(MAIL_URL_DESCRIPTION) + String mailUrl = '' + + @Option(names = ['--smtp-address'], description = SMTP_ADDRESS_DESCRIPTION) + @JsonPropertyDescription(SMTP_ADDRESS_DESCRIPTION) + String smtpAddress = '' + + @Option(names = ['--smtp-port'], description = SMTP_PORT_DESCRIPTION) + @JsonPropertyDescription(SMTP_PORT_DESCRIPTION) + Integer smtpPort = null + + @Option(names = ['--smtp-user'], description = SMTP_USER_DESCRIPTION) + @JsonPropertyDescription(SMTP_USER_DESCRIPTION) + String smtpUser = '' + + @Option(names = ['--smtp-password'], description = SMTP_PASSWORD_DESCRIPTION) + @JsonPropertyDescription(SMTP_PASSWORD_DESCRIPTION) + String smtpPassword = '' + + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + @Mixin + MailHelmSchema helm = new MailHelmSchema(chart: 'mailhog', + repoURL: 'https://codecentric.github.io/helm-charts', + version: '5.0.1') + + static class MailHelmSchema extends HelmConfigWithValues { + @Option(names = ['--mail-image'], description = HELM_CONFIG_IMAGE_DESCRIPTION) + @JsonPropertyDescription(HELM_CONFIG_IMAGE_DESCRIPTION) + String image = 'ghcr.io/cloudogu/mailhog:v1.0.1' + } + } + + static class MonitoringSchema { + @Option(names = ['--metrics', '--monitoring'], description = MONITORING_ENABLE_DESCRIPTION) + @JsonPropertyDescription(MONITORING_ENABLE_DESCRIPTION) + Boolean active = false + + @Option(names = ['--grafana-url'], description = GRAFANA_URL_DESCRIPTION) + @JsonPropertyDescription(GRAFANA_URL_DESCRIPTION) + String grafanaUrl = '' + + @Option(names = ['--grafana-email-from'], description = GRAFANA_EMAIL_FROM_DESCRIPTION) + @JsonPropertyDescription(GRAFANA_EMAIL_FROM_DESCRIPTION) + String grafanaEmailFrom = 'grafana@example.org' + + @Option(names = ['--grafana-email-to'], description = GRAFANA_EMAIL_TO_DESCRIPTION) + @JsonPropertyDescription(GRAFANA_EMAIL_TO_DESCRIPTION) + String grafanaEmailTo = 'infra@example.org' + + @Mixin + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + @SuppressWarnings('GroovyAssignabilityCheck') + // Because of values + MonitoringHelmSchema helm = new MonitoringHelmSchema(chart: 'kube-prometheus-stack', + repoURL: 'https://prometheus-community.github.io/helm-charts', + /* When updating this make sure to also test if air-gapped mode still works */ + version: '80.2.2', + values: [:] // Otherwise values is null 🤷‍♂️ + ) + static class MonitoringHelmSchema extends HelmConfigWithValues { + @Option(names = ['--grafana-image'], description = GRAFANA_IMAGE_DESCRIPTION) + @JsonPropertyDescription(GRAFANA_IMAGE_DESCRIPTION) + String grafanaImage = '' + + @Option(names = ['--grafana-sidecar-image'], description = GRAFANA_SIDECAR_IMAGE_DESCRIPTION) + @JsonPropertyDescription(GRAFANA_SIDECAR_IMAGE_DESCRIPTION) + String grafanaSidecarImage = '' + + @Option(names = ['--prometheus-image'], description = PROMETHEUS_IMAGE_DESCRIPTION) + @JsonPropertyDescription(PROMETHEUS_IMAGE_DESCRIPTION) + String prometheusImage = '' + + @Option(names = ['--prometheus-operator-image'], description = PROMETHEUS_OPERATOR_IMAGE_DESCRIPTION) + @JsonPropertyDescription(PROMETHEUS_OPERATOR_IMAGE_DESCRIPTION) + String prometheusOperatorImage = '' + + @Option(names = ['--prometheus-config-reloader-image'], description = PROMETHEUS_CONFIG_RELOADER_IMAGE_DESCRIPTION) + @JsonPropertyDescription(PROMETHEUS_CONFIG_RELOADER_IMAGE_DESCRIPTION) + String prometheusConfigReloaderImage = '' + } + } + + static class SecretsSchema { + Boolean active = false + + @Mixin + @JsonPropertyDescription(ESO_DESCRIPTION) + ESOSchema externalSecrets = new ESOSchema() + + @Mixin + @JsonPropertyDescription(VAULT_DESCRIPTION) + VaultSchema vault = new VaultSchema() + + static class ESOSchema { + + @Mixin + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + ESOHelmSchema helm = new ESOHelmSchema(chart: 'external-secrets', + repoURL: 'https://charts.external-secrets.io', + version: '0.9.16') + static class ESOHelmSchema extends HelmConfigWithValues { + @Option(names = ['--external-secrets-image'], description = EXTERNAL_SECRETS_IMAGE_DESCRIPTION) + @JsonPropertyDescription(EXTERNAL_SECRETS_IMAGE_DESCRIPTION) + String image = '' + + @Option(names = ['--external-secrets-certcontroller-image'], description = EXTERNAL_SECRETS_CERT_CONTROLLER_IMAGE_DESCRIPTION) + @JsonPropertyDescription(EXTERNAL_SECRETS_CERT_CONTROLLER_IMAGE_DESCRIPTION) + String certControllerImage = '' + + @Option(names = ['--external-secrets-webhook-image'], description = EXTERNAL_SECRETS_WEBHOOK_IMAGE_DESCRIPTION) + @JsonPropertyDescription(EXTERNAL_SECRETS_WEBHOOK_IMAGE_DESCRIPTION) + String webhookImage = '' + } + } + + static class VaultSchema { + @Option(names = ['--vault'], description = VAULT_ENABLE_DESCRIPTION) + @JsonPropertyDescription(VAULT_ENABLE_DESCRIPTION) + VaultMode mode + + @Option(names = ['--vault-url'], description = VAULT_URL_DESCRIPTION) + @JsonPropertyDescription(VAULT_URL_DESCRIPTION) + String url = '' + + @Mixin + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + VaultHelmSchema helm = new VaultHelmSchema(chart: 'vault', + repoURL: 'https://helm.releases.hashicorp.com', + version: '0.25.0') + static class VaultHelmSchema extends HelmConfigWithValues { + @Option(names = ['--vault-image'], description = VAULT_IMAGE_DESCRIPTION) + @JsonPropertyDescription(VAULT_IMAGE_DESCRIPTION) + String image = '' + } + } + } + + static class IngressSchema { + + @Option(names = ['--ingress'], description = INGRESS_ENABLE_DESCRIPTION) + @JsonPropertyDescription(INGRESS_ENABLE_DESCRIPTION) + Boolean active = false + + @Mixin + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + IngressHelmSchema helm = new IngressHelmSchema(chart: 'traefik', + repoURL: 'https://traefik.github.io/charts', + version: '39.0.0') + static class IngressHelmSchema extends HelmConfigWithValues { + @Option(names = ['--ingress-image'], description = HELM_CONFIG_IMAGE_DESCRIPTION) + @JsonPropertyDescription(HELM_CONFIG_IMAGE_DESCRIPTION) + String image = '' + } + + String ingressNamespace = 'ingress' + } + + static class CertManagerSchema { + @Option(names = ['--cert-manager'], description = CERTMANAGER_ENABLE_DESCRIPTION) + @JsonPropertyDescription(CERTMANAGER_ENABLE_DESCRIPTION) + Boolean active = false + + @Mixin + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + CertManagerHelmSchema helm = new CertManagerHelmSchema(chart: 'cert-manager', + repoURL: 'https://charts.jetstack.io', + version: '1.16.1') + static class CertManagerHelmSchema extends HelmConfigWithValues { + + @Option(names = ['--cert-manager-image'], description = CERTMANAGER_IMAGE_DESCRIPTION) + @JsonPropertyDescription(CERTMANAGER_IMAGE_DESCRIPTION) + String image = '' + + @Option(names = ['--cert-manager-webhook-image'], description = CERTMANAGER_WEBHOOK_IMAGE_DESCRIPTION) + @JsonPropertyDescription(CERTMANAGER_WEBHOOK_IMAGE_DESCRIPTION) + String webhookImage = '' + + @Option(names = ['--cert-manager-cainjector-image'], description = CERTMANAGER_CAINJECTOR_IMAGE_DESCRIPTION) + @JsonPropertyDescription(CERTMANAGER_CAINJECTOR_IMAGE_DESCRIPTION) + String cainjectorImage = '' + + @Option(names = ['--cert-manager-acme-solver-image'], description = CERTMANAGER_ACME_SOLVER_IMAGE_DESCRIPTION) + @JsonPropertyDescription(CERTMANAGER_ACME_SOLVER_IMAGE_DESCRIPTION) + String acmeSolverImage = '' + + @Option(names = ['--cert-manager-startup-api-check-image'], description = CERTMANAGER_STARTUP_API_CHECK_IMAGE_DESCRIPTION) + @JsonPropertyDescription(CERTMANAGER_STARTUP_API_CHECK_IMAGE_DESCRIPTION) + String startupAPICheckImage = '' + + } + } + + static enum ContentRepoType { + FOLDER_BASED, COPY, MIRROR + } + + static enum VaultMode { + dev, prod + } + + /** + * This defines, how customer repos will be updated. + * See {@link ConfigConstants#CONTENT_REPO_TARGET_OVERWRITE_MODE_DESCRIPTION} + */ + static enum OverwriteMode { + INIT, RESET, UPGRADE + } + + private static final ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new SimpleModule().addSerializer(GString, new JsonSerializer() { + @Override + void serialize(GString value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { + jsonGenerator.writeString(value.toString()) + } + })) + + static Config fromMap(Map map) { + objectMapper.convertValue(map, Config) + } + + Map toMap() { + objectMapper.convertValue(this, Map) + } + + String toYaml(boolean includeInternals) { + createYamlMapper(includeInternals) + .writeValueAsString(this) + } + + private static YAMLMapper createYamlMapper(boolean includeInternals) { + if (!includeInternals) { + new YAMLMapper() + .registerModule(new SimpleModule().setSerializerModifier(new BeanSerializerModifier() { + @Override + List changeProperties(SerializationConfig serializationConfig, BeanDescription beanDesc, List beanProperties) { + beanProperties.findAll { writer -> writer.getAnnotation(JsonPropertyDescription) != null } + } + })) as YAMLMapper + } else { + new YAMLMapper() + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy index f42edfc02..3157b2c25 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy @@ -2,348 +2,334 @@ package com.cloudogu.gitops.features.argocd import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.Config +import com.cloudogu.gitops.features.deployment.DeploymentStrategy import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepoFactory +import com.cloudogu.gitops.kubernetes.api.HelmClient +import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.kubernetes.rbac.RbacDefinition import com.cloudogu.gitops.kubernetes.rbac.Role import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.HelmClient -import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.MapUtils -import groovy.util.logging.Slf4j + import io.micronaut.core.annotation.Order + +import java.nio.file.Path import jakarta.inject.Singleton +import groovy.util.logging.Slf4j import org.springframework.security.crypto.bcrypt.BCrypt -import java.nio.file.Path - @Slf4j @Singleton @Order(100) class ArgoCD extends Feature { - private final String namespace - private final Config config - private final K8sClient k8sClient - private final HelmClient helmClient - private final FileSystemUtils fileSystemUtils - private final GitRepoFactory repoProvider - private final GitHandler gitHandler - private final String password - - private ArgoCDRepoSetup repoSetup - private RepoLayout clusterResourcesRepo - - - ArgoCD( - Config config, - K8sClient k8sClient, - HelmClient helmClient, - FileSystemUtils fileSystemUtils, - GitRepoFactory repoProvider, - GitHandler gitHandler - ) { - this.repoProvider = repoProvider - this.config = config - this.k8sClient = k8sClient - this.helmClient = helmClient - this.fileSystemUtils = fileSystemUtils - this.gitHandler = gitHandler - this.password = config.application.password - this.namespace = "${config.application.namePrefix}${config.features.argocd.namespace}" - } - - @Override - boolean isEnabled() { - config.features.argocd.active - } - - @Override - void postConfigInit(Config configToSet) { - // Exit early if not in operator mode or if env list is empty - if (!configToSet.features.argocd.operator || !configToSet.features.argocd.env) { - log.debug("Skipping features.argocd.env validation: operator mode is disabled or env list is empty.") - return - } - - List env = configToSet.features.argocd.env as List> - - log.info("Validating env list in features.argocd.env with {} entries.", env.size()) - - env.each { map -> - if (!(map instanceof Map) || !map.containsKey('name') || !map.containsKey('value')) { - throw new IllegalArgumentException("Each env variable in features.argocd.env must be a map with 'name' and 'value'. Invalid entry found: $map") - } - } - - log.info("Env list validation for features.argocd.env completed successfully.") - } - - @Override - void enable() { - this.repoSetup = ArgoCDRepoSetup.create(config, fileSystemUtils, repoProvider, gitHandler) - this.clusterResourcesRepo = repoSetup.clusterRepoLayout() - - log.debug('Cloning Repositories') - repoSetup.initLocalRepos() - repoSetup.prepareClusterResourcesRepo() - repoSetup.commitAndPushAll('Initial Commit') - - log.debug('Installing Argo CD') - installArgoCd() - } - - - private void installArgoCd() { - - log.debug("Creating namespaces") - k8sClient.createNamespaces(config.application.namespaces.activeNamespaces.toList()) - - createSCMCredentialsSecret() - - if (config.features.mail.smtpUser || config.features.mail.smtpPassword) { - k8sClient.createSecret( - 'generic', - 'argocd-notifications-secret', - namespace, - new Tuple2('email-username', config.features.mail.smtpUser), - new Tuple2('email-password', config.features.mail.smtpPassword) - ) - } - - if (config.features.argocd.operator) { - generateRBAC() - deployWithOperator() - } else { - if (this.config.features.argocd?.values) { - String argocdConfigPath = clusterResourcesRepo.helmValuesFile() - log.debug("extend Argocd values.yaml with ${this.config.features.argocd.values}") - def argocdYaml = fileSystemUtils.readYaml( - Path.of(argocdConfigPath)) - - def result = MapUtils.deepMerge(this.config.features.argocd.values, argocdYaml) - fileSystemUtils.writeYaml(result, new File (argocdConfigPath)) - log.debug("Argocd values.yaml contains ${result}") - } - deployWithHelm() - } - - if (config.multiTenant.useDedicatedInstance) { - //Bootstrapping dedicated instance - k8sClient.applyYaml(Path.of(clusterResourcesRepo.projectsDir(), "tenant.yaml").toString()) - k8sClient.applyYaml(Path.of(clusterResourcesRepo.applicationsDir(), "bootstrap.yaml").toString()) - - //Bootstrapping tenant Argocd projects - RepoLayout tenantRepoLayout = repoSetup.tenantRepoLayout() - k8sClient.applyYaml(Path.of(tenantRepoLayout.projectsDir(), "argocd.yaml").toString()) - k8sClient.applyYaml(Path.of(tenantRepoLayout.applicationsDir(), "bootstrap.yaml").toString()) - } else { - // Bootstrap root application - k8sClient.applyYaml(Path.of(clusterResourcesRepo.projectsDir(), "argocd.yaml").toString()) - k8sClient.applyYaml(Path.of(clusterResourcesRepo.applicationsDir(), "bootstrap.yaml").toString()) - } - - // Delete helm-argo secrets to decouple from helm. - // This does not delete Argo from the cluster, but you can no longer modify argo directly with helm - // For development keeping it in helm makes it easier (e.g. for helm uninstall). - k8sClient.delete('secret', namespace, - new Tuple2('owner', 'helm'), new Tuple2('name', 'argocd')) - } - - private void deployWithOperator() { - // Apply argocd yaml from operator folder - String argocdConfigPath = clusterResourcesRepo.operatorConfigFile() - if (this.config.features.argocd?.values) { - log.debug("extend Argocd.yaml with ${this.config.features.argocd.values}") - def argocdYaml = fileSystemUtils.readYaml( - Path.of(clusterResourcesRepo.operatorConfigFile())) - - def result = MapUtils.deepMerge(this.config.features.argocd.values, argocdYaml) - fileSystemUtils.writeYaml(result, new File (argocdConfigPath)) - log.debug("Argocd.yaml for operator contains ${result}") - // reload file - argocdConfigPath = clusterResourcesRepo.operatorConfigFile() - } - k8sClient.applyYaml(argocdConfigPath) - - // ArgoCD is not installed until the ArgoCD-Operator did his job. - // This can take some time, so we wait for the status of the custom resource to become "Available" - k8sClient.waitForResourcePhase("argocd", "argocd", namespace, "Available") - - log.debug("Setting new argocd admin password") - // Set admin password imperatively here instead of operator/argocd.yaml, because we don't want it to show in git repo - // The Operator uses an extra secret to store the admin Password, which is not bcrypted - k8sClient.patch('secret', 'argocd-cluster', namespace, - [stringData: ['admin.password': password]]) - - // In newer Versions ArgoCD Operator uses the password in argocd-cluster secret only as generated initial password - // but we want to set our own admin password so we set the password in both Secrets for consistency - String bcryptArgoCDPassword = BCrypt.hashpw(password, BCrypt.gensalt(4)) - k8sClient.patch('secret', 'argocd-secret', namespace, - [stringData: ['admin.password': bcryptArgoCDPassword]]) - - updatingArgoCDManagedNamespaces() - - log.debug("Apply RBAC permissions for ArgoCD in all managed namespaces imperatively") - // Apply rbac yamls from operator/rbac folder - String argocdRbacPath = clusterResourcesRepo.operatorRbacDir() - k8sClient.applyYaml("${argocdRbacPath} --recursive") - } - - - private void deployWithHelm() { - - // Install umbrella chart from argocd/argocd - String umbrellaChartPath = clusterResourcesRepo.helmDir() - // Even if the Chart.lock already contains the repo, we need to add it before resolving it - // See https://github.com/helm/helm/issues/8036#issuecomment-872502901 - List helmDependencies = fileSystemUtils.readYaml( - Path.of(clusterResourcesRepo.chartYaml()))['dependencies'] - helmClient.addRepo('argo', helmDependencies[0]['repository'] as String) - helmClient.dependencyBuild(umbrellaChartPath) - helmClient.upgrade('argocd', umbrellaChartPath, [namespace: namespace]) - - log.debug("Setting new argocd admin password") - // Set admin password imperatively here instead of values.yaml, because we don't want it to show in git repo - String bcryptArgoCDPassword = BCrypt.hashpw(password, BCrypt.gensalt(4)) - k8sClient.patch('secret', 'argocd-secret', namespace, - [stringData: ['admin.password': bcryptArgoCDPassword]]) - - } - - // The ArgoCD instance installed via an operator only manages its deployment namespace. - // To manage additional namespaces, we need to update the 'argocd-default-cluster-config' secret with all managed namespaces. - void updatingArgoCDManagedNamespaces() { - - log.debug("Updating managed namespaces in ArgoCD configuration secret.") - def namespaceList = !config.multiTenant.useDedicatedInstance ? - config.application.namespaces.activeNamespaces : - config.application.namespaces.tenantNamespaces - - k8sClient.patch('secret', 'argocd-default-cluster-config', namespace, - [stringData: ['namespaces': namespaceList.join(',')]]) - - if (config.multiTenant.useDedicatedInstance) { - // Append new namespaces to existing ones from the secret. - // `kubectl patch` can't merge list subfields, so we read, decode, merge, and update the secret. - // This ensures all centrally managed namespaces are preserved. - String base64Namespaces = k8sClient.getArgoCDNamespacesSecret('argocd-default-cluster-config', config.multiTenant.centralArgocdNamespace) - byte[] decodedBytes = Base64.decoder.decode(base64Namespaces) - String decoded = new String(decodedBytes, "UTF-8") - def decodedList = decoded?.split(',') as List ?: [] - def activeList = config.application.namespaces.activeNamespaces?.flatten() as List ?: [] - def merged = (decodedList + activeList).unique().join(',') - log.debug("Updating Central Argocd 'argocd-default-cluster-config' secret") - k8sClient.patch('secret', 'argocd-default-cluster-config', config.multiTenant.centralArgocdNamespace, - [stringData: ['namespaces': merged]]) - } - } - - private void generateRBAC() { - - log.debug("Generate RBAC permissions for ArgoCD in all managed namespaces") - - if (config.multiTenant.useDedicatedInstance) { - //Generating Tenant Namespace RBACs for Tenant Argocd - for (String ns : config.application.namespaces.tenantNamespaces) { - new RbacDefinition(Role.Variant.ARGOCD) - .withName("argocd") - .withNamespace(ns) - .withServiceAccountsFrom( - namespace, - ["argocd-argocd-server", "argocd-argocd-application-controller", "argocd-applicationset-controller"] - ) - .withConfig(config) - .withRepo(repoSetup.clusterResources.repo) - .withSubfolder(clusterResourcesRepo.operatorRbacTenantSubfolder()) - .generate() - } - - //Generating Central ArgoCD RBACs for managed namespaces - for (String ns : config.application.namespaces.activeNamespaces) { - log.debug("Generate RBAC permissions for centralized ArgoCD to access tenant ArgoCDs") - new RbacDefinition(Role.Variant.ARGOCD) - .withName('argocd-central') - .withNamespace(ns) - .withServiceAccountsFrom( - config.multiTenant.centralArgocdNamespace, - ["argocd-argocd-server", "argocd-argocd-application-controller", "argocd-applicationset-controller"] - ) - .withConfig(config) - .withRepo(repoSetup.clusterResources.repo) - .withSubfolder(clusterResourcesRepo.operatorRbacSubfolder()) - .generate() - } - } else { - for (String ns : config.application.namespaces.activeNamespaces) { - new RbacDefinition(Role.Variant.ARGOCD) - .withName("argocd") - .withNamespace(ns) - .withServiceAccountsFrom( - namespace, - ["argocd-argocd-server", "argocd-argocd-application-controller", "argocd-applicationset-controller"] - ) - .withConfig(config) - .withRepo(repoSetup.clusterResources.repo) - .withSubfolder(clusterResourcesRepo.operatorRbacSubfolder()) - .generate() - } - - if (config.application.clusterAdmin) { - new RbacDefinition(Role.Variant.CLUSTER_ADMIN) - .withName("argocd-cluster-admin") - .withNamespace(namespace) - .withServiceAccountsFrom( - namespace, - ["argocd-argocd-server", "argocd-argocd-application-controller", "argocd-applicationset-controller"] - ) - .withConfig(config) - .withRepo(repoSetup.clusterResources.repo) - .withSubfolder(clusterResourcesRepo.operatorRbacSubfolder()) - .generate() - } - } - } - - protected void createSCMCredentialsSecret() { - log.debug("Creating repo credential secret that is used by argocd to access repos in ${config.scm.scmProviderType.toString()}") - - // Create secret imperatively here instead of values.yaml, because we don't want it to show in git repo - createRepoCredentialsSecret( - 'argocd-repo-creds-scm', - namespace, - gitHandler.tenant.url, - gitHandler.tenant.credentials.username, - gitHandler.tenant.credentials.password - ) - - if (config.multiTenant.useDedicatedInstance) { - log.debug("Creating central repo credential secret that is used by argocd to access repos in ${config.scm.scmProviderType.toString()}") - - // Create secret imperatively here instead of values.yaml, because we don't want it to show in git repo - createRepoCredentialsSecret( - 'argocd-repo-creds-central-scm', - config.multiTenant.centralArgocdNamespace, - gitHandler.central.url, - gitHandler.central.credentials.username, - gitHandler.central.credentials.password - ) - } - } - - private void createRepoCredentialsSecret(String secretName, String ns, String url, String username, String password) { - k8sClient.createSecret('generic', secretName, ns, - new Tuple2('url', url), - new Tuple2('username', username), - new Tuple2('password', password) - ) - k8sClient.label('secret', secretName, ns, - new Tuple2('argocd.argoproj.io/secret-type', 'repo-creds')) - } - - protected ArgoCDRepoSetup getRepoSetup() { - return this.repoSetup - } + static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/argocd/argocd/argocd-helm-values.ftl.yaml" + + private final String namespace + private final Config config + private final K8sClient k8sClient + private final HelmClient helmClient + private final GitRepoFactory repoProvider + private final GitHandler gitHandler + private final String password + + private ArgoCDRepoSetup repoSetup + private RepoLayout clusterResourcesRepo + + ArgoCD( + Config config, + K8sClient k8sClient, + HelmClient helmClient, + DeploymentStrategy deployer, + FileSystemUtils fileSystemUtils, + GitRepoFactory repoProvider, + GitHandler gitHandler) { + this.repoProvider = repoProvider + this.config = config + this.k8sClient = k8sClient + this.helmClient = helmClient + this.deployer = deployer + this.fileSystemUtils = fileSystemUtils + this.gitHandler = gitHandler + this.password = config.application.password + this.namespace = "${config.application.namePrefix}${config.features.argocd.namespace}" + } + + @Override + boolean isEnabled() { + config.features.argocd.active + } + + @Override + void postConfigInit(Config configToSet) { + // Exit early if not in operator mode or if env list is empty + if (!configToSet.features.argocd.operator || !configToSet.features.argocd.env) { + log.debug("Skipping features.argocd.env validation: operator mode is disabled or env list is empty.") + return + } + + List env = configToSet.features.argocd.env as List> + + log.info("Validating env list in features.argocd.env with {} entries.", env.size()) + + env.each { map -> + if (!(map instanceof Map) || !map.containsKey('name') || !map.containsKey('value')) { + throw new IllegalArgumentException("Each env variable in features.argocd.env must be a map with 'name' and 'value'. Invalid entry found: $map") + } + } + + log.info("Env list validation for features.argocd.env completed successfully.") + } + + @Override + void enable() { + this.repoSetup = ArgoCDRepoSetup.create(config, fileSystemUtils, repoProvider, gitHandler) + this.clusterResourcesRepo = repoSetup.clusterRepoLayout() + + log.debug('Cloning Repositories') + repoSetup.initLocalRepos() + repoSetup.prepareClusterResourcesRepo() + repoSetup.commitAndPushAll('Initial Commit') + + log.debug('Installing Argo CD') + installArgoCd() + } + + private void installArgoCd() { + + log.debug("Creating namespaces") + k8sClient.createNamespaces(config.application.namespaces.activeNamespaces.toList()) + + createSCMCredentialsSecret() + + if (config.features.mail.smtpUser || config.features.mail.smtpPassword) { + k8sClient.createSecret('generic', + 'argocd-notifications-secret', + namespace, + new Tuple2('email-username', config.features.mail.smtpUser), + new Tuple2('email-password', config.features.mail.smtpPassword)) + } + + if (config.features.argocd.operator) { + generateRBAC() + deployWithOperator() + } else { + if (this.config.features.argocd?.values) { + String argocdConfigPath = clusterResourcesRepo.helmValuesFile() + log.debug("extend Argocd values.yaml with ${this.config.features.argocd.values}") + def argocdYaml = fileSystemUtils.readYaml(Path.of(argocdConfigPath)) + + def result = MapUtils.deepMerge(this.config.features.argocd.values, argocdYaml) + fileSystemUtils.writeYaml(result, new File(argocdConfigPath)) + log.debug("Argocd values.yaml contains ${result}") + } + deployWithHelm() + } + + if (config.multiTenant.useDedicatedInstance) { + //Bootstrapping dedicated instance + k8sClient.applyYaml(Path.of(clusterResourcesRepo.projectsDir(), "tenant.yaml").toString()) + k8sClient.applyYaml(Path.of(clusterResourcesRepo.applicationsDir(), "bootstrap.yaml").toString()) + + //Bootstrapping tenant Argocd projects + RepoLayout tenantRepoLayout = repoSetup.tenantRepoLayout() + k8sClient.applyYaml(Path.of(tenantRepoLayout.projectsDir(), "argocd.yaml").toString()) + k8sClient.applyYaml(Path.of(tenantRepoLayout.applicationsDir(), "bootstrap.yaml").toString()) + } else { + // Bootstrap root application + k8sClient.applyYaml(Path.of(clusterResourcesRepo.projectsDir(), "argocd.yaml").toString()) + k8sClient.applyYaml(Path.of(clusterResourcesRepo.applicationsDir(), "bootstrap.yaml").toString()) + } + + // Delete helm-argo secrets to decouple from helm. + // This does not delete Argo from the cluster, but you can no longer modify argo directly with helm + // For development keeping it in helm makes it easier (e.g. for helm uninstall). + k8sClient.delete('secret', namespace, + new Tuple2('owner', 'helm'), new Tuple2('name', 'argocd')) + } + + private void deployWithOperator() { + def cmd = """ +git clone https://github.com/argoproj-labs/argocd-operator && +cd argocd-operator && +git checkout release-0.17 && +make deploy IMG=quay.io/argoprojlabs/argocd-operator:v0.17.0 && +rm -Rf ../argocd-operator/ +""" + + def process = ["bash", "-c", cmd].execute() + process.in.eachLine { log.debug(it) } + process.err.eachLine { log.debug(it) } + process.waitFor() + // Apply argocd yaml from operator folder + String argocdConfigPath = clusterResourcesRepo.operatorConfigFile() + if (this.config.features.argocd?.values) { + log.debug("extend Argocd.yaml with ${this.config.features.argocd.values}") + def argocdYaml = fileSystemUtils.readYaml(Path.of(clusterResourcesRepo.operatorConfigFile())) + + def result = MapUtils.deepMerge(this.config.features.argocd.values, argocdYaml) + fileSystemUtils.writeYaml(result, new File(argocdConfigPath)) + log.debug("Argocd.yaml for operator contains ${result}") + // reload file + argocdConfigPath = clusterResourcesRepo.operatorConfigFile() + } + k8sClient.applyYaml(argocdConfigPath) + + // ArgoCD is not installed until the ArgoCD-Operator did his job. + // This can take some time, so we wait for the status of the custom resource to become "Available" + k8sClient.waitForResourcePhase("argocd", "argocd", namespace, "Available") + + log.debug("Setting new argocd admin password") + // Set admin password imperatively here instead of operator/argocd.yaml, because we don't want it to show in git repo + // The Operator uses an extra secret to store the admin Password, which is not bcrypted + k8sClient.patch('secret', 'argocd-cluster', namespace, + [stringData: ['admin.password': password]]) + + // In newer Versions ArgoCD Operator uses the password in argocd-cluster secret only as generated initial password + // but we want to set our own admin password so we set the password in both Secrets for consistency + String bcryptArgoCDPassword = BCrypt.hashpw(password, BCrypt.gensalt(4)) + k8sClient.patch('secret', 'argocd-secret', namespace, + [stringData: ['admin.password': bcryptArgoCDPassword]]) + + updatingArgoCDManagedNamespaces() + + log.debug("Apply RBAC permissions for ArgoCD in all managed namespaces imperatively") + // Apply rbac yamls from operator/rbac folder + String argocdRbacPath = clusterResourcesRepo.operatorRbacDir() + k8sClient.applyYaml("${argocdRbacPath} --recursive") + } + + private void deployWithHelm() { + addHelmValuesData('argocd', [host: config.features.argocd.url ? new URL(config.features.argocd.url).host : '']) + this.deployer.helm(this.namespace, this.namespace, namespace, config.features.argocd.helm, HELM_VALUES_PATH, config) + + log.debug("Setting new argocd admin password") + // Set admin password imperatively here instead of values.yaml, because we don't want it to show in git repo + String bcryptArgoCDPassword = BCrypt.hashpw(password, BCrypt.gensalt(4)) + k8sClient.patch('secret', 'argocd-secret', namespace, + [stringData: ['admin.password': bcryptArgoCDPassword]]) + + } + + // The ArgoCD instance installed via an operator only manages its deployment namespace. + // To manage additional namespaces, we need to update the 'argocd-default-cluster-config' secret with all managed namespaces. + void updatingArgoCDManagedNamespaces() { + + log.debug("Updating managed namespaces in ArgoCD configuration secret.") + def namespaceList = !config.multiTenant.useDedicatedInstance ? config.application.namespaces.activeNamespaces : config.application.namespaces.tenantNamespaces + + k8sClient.patch('secret', 'argocd-default-cluster-config', namespace, + [stringData: ['namespaces': namespaceList.join(',')]]) + + if (config.multiTenant.useDedicatedInstance) { + // Append new namespaces to existing ones from the secret. + // `kubectl patch` can't merge list subfields, so we read, decode, merge, and update the secret. + // This ensures all centrally managed namespaces are preserved. + String base64Namespaces = k8sClient.getArgoCDNamespacesSecret('argocd-default-cluster-config', config.multiTenant.centralArgocdNamespace) + byte[] decodedBytes = Base64.decoder.decode(base64Namespaces) + String decoded = new String(decodedBytes, "UTF-8") + def decodedList = decoded?.split(',') as List ?: [] + def activeList = config.application.namespaces.activeNamespaces?.flatten() as List ?: [] + def merged = (decodedList + activeList).unique().join(',') + log.debug("Updating Central Argocd 'argocd-default-cluster-config' secret") + k8sClient.patch('secret', 'argocd-default-cluster-config', config.multiTenant.centralArgocdNamespace, + [stringData: ['namespaces': merged]]) + } + } + + private void generateRBAC() { + + log.debug("Generate RBAC permissions for ArgoCD in all managed namespaces") + + if (config.multiTenant.useDedicatedInstance) { + //Generating Tenant Namespace RBACs for Tenant Argocd + for (String ns : config.application.namespaces.tenantNamespaces) { + new RbacDefinition(Role.Variant.ARGOCD) + .withName("argocd") + .withNamespace(ns) + .withServiceAccountsFrom(namespace, + ["argocd-argocd-server", "argocd-argocd-application-controller", "argocd-applicationset-controller"]) + .withConfig(config) + .withRepo(repoSetup.clusterResources.repo) + .withSubfolder(clusterResourcesRepo.operatorRbacTenantSubfolder()) + .generate() + } + + //Generating Central ArgoCD RBACs for managed namespaces + for (String ns : config.application.namespaces.activeNamespaces) { + log.debug("Generate RBAC permissions for centralized ArgoCD to access tenant ArgoCDs") + new RbacDefinition(Role.Variant.ARGOCD) + .withName('argocd-central') + .withNamespace(ns) + .withServiceAccountsFrom(config.multiTenant.centralArgocdNamespace, + ["argocd-argocd-server", "argocd-argocd-application-controller", "argocd-applicationset-controller"]) + .withConfig(config) + .withRepo(repoSetup.clusterResources.repo) + .withSubfolder(clusterResourcesRepo.operatorRbacSubfolder()) + .generate() + } + } else { + for (String ns : config.application.namespaces.activeNamespaces) { + new RbacDefinition(Role.Variant.ARGOCD) + .withName("argocd") + .withNamespace(ns) + .withServiceAccountsFrom(namespace, + ["argocd-argocd-server", "argocd-argocd-application-controller", "argocd-applicationset-controller"]) + .withConfig(config) + .withRepo(repoSetup.clusterResources.repo) + .withSubfolder(clusterResourcesRepo.operatorRbacSubfolder()) + .generate() + } + + if (config.application.clusterAdmin) { + new RbacDefinition(Role.Variant.CLUSTER_ADMIN) + .withName("argocd-cluster-admin") + .withNamespace(namespace) + .withServiceAccountsFrom(namespace, + ["argocd-argocd-server", "argocd-argocd-application-controller", "argocd-applicationset-controller"]) + .withConfig(config) + .withRepo(repoSetup.clusterResources.repo) + .withSubfolder(clusterResourcesRepo.operatorRbacSubfolder()) + .generate() + } + } + } + + protected void createSCMCredentialsSecret() { + log.debug("Creating repo credential secret that is used by argocd to access repos in ${config.scm.scmProviderType.toString()}") + + // Create secret imperatively here instead of values.yaml, because we don't want it to show in git repo + createRepoCredentialsSecret('argocd-repo-creds-scm', + namespace, + gitHandler.tenant.url, + gitHandler.tenant.credentials.username, + gitHandler.tenant.credentials.password) + + if (config.multiTenant.useDedicatedInstance) { + log.debug("Creating central repo credential secret that is used by argocd to access repos in ${config.scm.scmProviderType.toString()}") + + // Create secret imperatively here instead of values.yaml, because we don't want it to show in git repo + createRepoCredentialsSecret('argocd-repo-creds-central-scm', + config.multiTenant.centralArgocdNamespace, + gitHandler.central.url, + gitHandler.central.credentials.username, + gitHandler.central.credentials.password) + } + } + + private void createRepoCredentialsSecret(String secretName, String ns, String url, String username, String password) { + k8sClient.createSecret('generic', secretName, ns, + new Tuple2('url', url), + new Tuple2('username', username), + new Tuple2('password', password)) + k8sClient.label('secret', secretName, ns, + new Tuple2('argocd.argoproj.io/secret-type', 'repo-creds')) + } + + protected ArgoCDRepoSetup getRepoSetup() { + return this.repoSetup + } } \ No newline at end of file From 8167e210bff91da9bba2267e9dc977f862f494bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Thu, 26 Mar 2026 09:59:23 +0100 Subject: [PATCH 02/10] testing --- .../GitopsPlaygroundCliMainScripted.groovy | 2 +- .../gitops/features/argocd/ArgoCD.groovy | 34 +++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy index 65a2b2490..86e03e282 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy @@ -86,7 +86,7 @@ class GitopsPlaygroundCliMainScripted { context.registerSingleton(new Application(config, [new Registry(config, fileSystemUtils, k8sClient, helmStrategy), gitHandler, jenkins, - new ArgoCD(config, k8sClient, helmClient, deployer, fileSystemUtils, gitRepoFactory, gitHandler), + new ArgoCD(config, k8sClient, helmClient, helmStrategy, fileSystemUtils, gitRepoFactory, gitHandler), new Ingress(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), new CertManager(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), new Mail(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy index 3157b2c25..a0c6246be 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy @@ -2,7 +2,7 @@ package com.cloudogu.gitops.features.argocd import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.HelmStrategy import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.kubernetes.api.HelmClient @@ -42,7 +42,7 @@ class ArgoCD extends Feature { Config config, K8sClient k8sClient, HelmClient helmClient, - DeploymentStrategy deployer, + HelmStrategy deployer, FileSystemUtils fileSystemUtils, GitRepoFactory repoProvider, GitHandler gitHandler) { @@ -114,6 +114,7 @@ class ArgoCD extends Feature { if (config.features.argocd.operator) { generateRBAC() + installOperator() deployWithOperator() } else { if (this.config.features.argocd?.values) { @@ -151,18 +152,6 @@ class ArgoCD extends Feature { } private void deployWithOperator() { - def cmd = """ -git clone https://github.com/argoproj-labs/argocd-operator && -cd argocd-operator && -git checkout release-0.17 && -make deploy IMG=quay.io/argoprojlabs/argocd-operator:v0.17.0 && -rm -Rf ../argocd-operator/ -""" - - def process = ["bash", "-c", cmd].execute() - process.in.eachLine { log.debug(it) } - process.err.eachLine { log.debug(it) } - process.waitFor() // Apply argocd yaml from operator folder String argocdConfigPath = clusterResourcesRepo.operatorConfigFile() if (this.config.features.argocd?.values) { @@ -201,9 +190,24 @@ rm -Rf ../argocd-operator/ k8sClient.applyYaml("${argocdRbacPath} --recursive") } + private static void installOperator() { + def cmd = """ +git clone https://github.com/argoproj-labs/argocd-operator && +cd argocd-operator && +git checkout release-0.17 && +make deploy IMG=quay.io/argoprojlabs/argocd-operator:v0.17.0 && +rm -Rf ../argocd-operator/ +""" + + def process = ["bash", "-c", cmd].execute() + process.in.eachLine { log.debug(it) } + process.err.eachLine { log.debug(it) } + process.waitFor() + } + private void deployWithHelm() { addHelmValuesData('argocd', [host: config.features.argocd.url ? new URL(config.features.argocd.url).host : '']) - this.deployer.helm(this.namespace, this.namespace, namespace, config.features.argocd.helm, HELM_VALUES_PATH, config) + deployHelmChart(this.namespace, this.namespace, namespace, config.features.argocd.helm, HELM_VALUES_PATH, config) log.debug("Setting new argocd admin password") // Set admin password imperatively here instead of values.yaml, because we don't want it to show in git repo From 66ef6ecb9da86eb62e45c1beeb79a56eca7598f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Mon, 30 Mar 2026 09:41:14 +0200 Subject: [PATCH 03/10] deployment changed --- .../apps/argocd/applications/argocd.ftl.yaml | 32 -- .../argocd/applications/bootstrap.ftl.yaml | 24 - .../projects/cluster-resources.ftl.yaml | 2 + .../apps/argocd/projects/default.ftl.yaml | 15 - .../groovy/com/cloudogu/gitops/Feature.groovy | 276 +++++----- .../GitopsPlaygroundCliMainScripted.groovy | 46 +- .../cloudogu/gitops/features/Jenkins.groovy | 496 +++++++++--------- .../cloudogu/gitops/features/Registry.groovy | 116 ++-- .../gitops/features/argocd/ArgoCD.groovy | 9 +- .../features/deployment/Deployer.groovy | 47 +- .../gitops/features/git/GitHandler.groovy | 237 ++++----- .../providers/scmmanager/ScmManager.groovy | 345 ++++++------ 12 files changed, 770 insertions(+), 875 deletions(-) delete mode 100644 argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml delete mode 100644 argocd/cluster-resources/apps/argocd/applications/bootstrap.ftl.yaml delete mode 100644 argocd/cluster-resources/apps/argocd/projects/default.ftl.yaml diff --git a/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml b/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml deleted file mode 100644 index 3fbe9e597..000000000 --- a/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: argocd - namespace: ${config.application.namePrefix}argocd - annotations: - # Only app with the sync-status-unknown alert, so that we only get one alert when SCM is not reachable. - # Otherwise, there would be a spam wave by every application everytime the SCM is not reachable. -<#if config.features.mail.active?? && config.features.mail.active> - notifications.argoproj.io/subscribe.on-sync-status-unknown.email: ${config.features.argocd.emailToAdmin} - -# finalizer disabled, because otherwise everything under this Application would be deleted as well, if this Application is deleted by accident -# finalizers: -# - resources-finalizer.argocd.argoproj.io -spec: - destination: - server: https://kubernetes.default.svc - namespace: ${config.application.namePrefix}argocd - project: argocd - source: - path: apps/argocd/${config.features.argocd.operator?string("operator/", "argocd/")} - repoURL: ${scm.repoUrl}argocd/cluster-resources.git - targetRevision: main - # needed to sync the operator/rbac folder - <#if config.features.argocd.operator> - directory: - recurse: true - - syncPolicy: - automated: - prune: false # is set to false to prevent argo from deleting itself - selfHeal: true \ No newline at end of file diff --git a/argocd/cluster-resources/apps/argocd/applications/bootstrap.ftl.yaml b/argocd/cluster-resources/apps/argocd/applications/bootstrap.ftl.yaml deleted file mode 100644 index 044bdef7a..000000000 --- a/argocd/cluster-resources/apps/argocd/applications/bootstrap.ftl.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# This is the root Applications, which manages all other applications with the app-of-apps-pattern. -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: bootstrap - namespace: ${config.application.namePrefix}argocd -# finalizer disabled, because otherwise everything under this Application would be deleted as well, if this Application is deleted by accident -# finalizers: -# - resources-finalizer.argocd.argoproj.io -spec: - destination: - server: https://kubernetes.default.svc - namespace: ${config.application.namePrefix}argocd - project: argocd - source: - path: apps/argocd/applications/ - repoURL: ${scm.repoUrl}argocd/cluster-resources.git - targetRevision: main - directory: - recurse: true - syncPolicy: - automated: - prune: false # is set to false to prevent projects to be deleted by accident - selfHeal: true \ No newline at end of file diff --git a/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml b/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml index 33942c7da..1f492dfe8 100644 --- a/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml +++ b/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml @@ -29,6 +29,8 @@ spec: - https://helm.releases.hashicorp.com - https://charts.external-secrets.io - https://charts.jetstack.io + - https://charts.jenkins.io + - https://argoproj.github.io/argo-helm # allow to only see application resources from the specified namespace diff --git a/argocd/cluster-resources/apps/argocd/projects/default.ftl.yaml b/argocd/cluster-resources/apps/argocd/projects/default.ftl.yaml deleted file mode 100644 index a09df4f77..000000000 --- a/argocd/cluster-resources/apps/argocd/projects/default.ftl.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: AppProject -metadata: - name: default - namespace: ${config.application.namePrefix}argocd - annotations: -<#if config.features.mail.active?? && config.features.mail.active> - notifications.argoproj.io/subscribe.email: ${config.features.argocd.emailToAdmin} - -spec: - description: Default fallback AppProject if none other is specified. Is not allowed to do anything. - clusterResourceWhitelist: - destinations: - sourceRepos: - sourceNamespaces: diff --git a/src/main/groovy/com/cloudogu/gitops/Feature.groovy b/src/main/groovy/com/cloudogu/gitops/Feature.groovy index 857359d75..fccf2d456 100644 --- a/src/main/groovy/com/cloudogu/gitops/Feature.groovy +++ b/src/main/groovy/com/cloudogu/gitops/Feature.groovy @@ -1,5 +1,7 @@ package com.cloudogu.gitops +import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.DeploymentStrategy import com.cloudogu.gitops.features.git.GitHandler @@ -7,29 +9,27 @@ import com.cloudogu.gitops.utils.AirGappedUtils import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.MapUtils import com.cloudogu.gitops.utils.TemplatingEngine -import freemarker.template.Configuration -import freemarker.template.DefaultObjectWrapperBuilder -import groovy.util.logging.Slf4j -import groovy.yaml.YamlSlurper -import java.nio.file.Files import java.nio.file.Path +import groovy.util.logging.Slf4j +import groovy.yaml.YamlSlurper -import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.* +import freemarker.template.Configuration +import freemarker.template.DefaultObjectWrapperBuilder /** * A single tool to be deployed by GOP. - * + * * Typically, this is a helm chart (see {@link com.cloudogu.gitops.features.deployment.DeploymentStrategy} and * {@code downloadHelmCharts.sh}) with its own section in the config * (see {@link com.cloudogu.gitops.config.schema.Schema#features}).

- * + * * In the config, features typically set their default helm chart coordinates and provide options to *
    *
  • configure images
  • *
  • overwrite default helm values
  • *

- * + * * In addition to their own config, features react to several generic GOP config options.
* Here are some typical examples: *
    @@ -39,136 +39,138 @@ import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.* *
  • Install with Resource requests + limits: {@link com.cloudogu.gitops.config.schema.Schema.ApplicationSchema#podResources}
  • *
  • Install without CRDs: {@link com.cloudogu.gitops.config.schema.Schema.ApplicationSchema#skipCrds}
  • *
  • For apps with UI: Setting {@link com.cloudogu.gitops.config.schema.Schema.ApplicationSchema#username} and {@link com.cloudogu.gitops.config.schema.Schema.ApplicationSchema#password}
  • - *
- */ + * */ @Slf4j abstract class Feature { - protected FileSystemUtils fileSystemUtils - protected DeploymentStrategy deployer - protected AirGappedUtils airGappedUtils - protected GitHandler gitHandler - protected Map helmValuesTemplateData = [:] - - protected void addHelmValuesData(String key, Object value) { - this.helmValuesTemplateData[key] = value - } - - boolean install() { - if (isEnabled()) { - log.info("Installing Feature ${getClass().getSimpleName()}") - - if (this instanceof FeatureWithImage) { - (this as FeatureWithImage).createImagePullSecret() - } - - enable() - return true - } else { - log.debug("Feature ${getClass().getSimpleName()} is disabled") - disable() - return false - } - } - - String getActiveNamespaceFromFeature() { - //using reflection to get all subclasses implementing a own namespace - if (this.metaClass.hasProperty(this, 'namespace')) { - return isEnabled() ? this.getProperty('namespace') : null - } - return null - } - - static Map templateToMap(String filePath, Map parameters) { - def hydratedString = new TemplatingEngine().template(new File(filePath), parameters) - - if (hydratedString.trim().isEmpty()) { - // Otherwise YamlSlurper returns an empty array, whereas we expect a Map - return [:] - } - return new YamlSlurper().parseText(hydratedString) as Map - } - - protected void deployHelmChart( - String featureName, - String releaseName, - String namespace, - Config.HelmConfigWithValues helmConfig, - String helmValuesTemplatePath, - Config config - ) { - String repoURL = helmConfig.repoURL - String chartOrPath = helmConfig.chart - String version = helmConfig.version - RepoType repoType = RepoType.HELM - - this.addHelmValuesData("config", config) - this.addHelmValuesData("statics", new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_32).build().getStaticModels()) - - /* If we get a helmValuesTemplatePath we render the Template with the given Data. - * Some Features might not use a values template and thus passing no helmValuesTemplatePath, in that - * case we simply treat helmValuesTemplateData directly as helmValuesData */ - Map helmValuesData = this.helmValuesTemplateData - if (helmValuesTemplatePath) { - log.debug("got helm_value_path, rendering values template") - helmValuesData = templateToMap(helmValuesTemplatePath, this.helmValuesTemplateData) - } - - helmValuesData = MapUtils.deepMerge(helmConfig.values, helmValuesData) - Path tempValuesPath = this.fileSystemUtils.writeTempFile(helmValuesData) - - if (config.application.mirrorRepos) { - log.debug("Using a local, mirrored git repo as deployment source for feature ${featureName}") - - String repoNamespaceAndName = this.airGappedUtils.mirrorHelmRepoToGit(helmConfig) - repoURL = this.gitHandler.resourcesScm.repoUrl(repoNamespaceAndName) - chartOrPath = '.' - repoType = RepoType.GIT - version = new YamlSlurper() - .parse(Path.of("${config.application.localHelmChartFolder}/${helmConfig.chart}", - 'Chart.yaml'))['version'] - } - - log.debug("Starting deployment of feature ${featureName} from ${repoURL}.") - log.debug("helm values used: ${helmValuesData}") - - this.deployer.deployFeature( - repoURL, - featureName, - chartOrPath, - version, - namespace, - releaseName, - tempValuesPath, - repoType) - } - - abstract boolean isEnabled() - - - - /* - * Hooks for enabling or disabling a feature. Both optional, because not always needed. - */ - protected void enable() {} - protected void disable() {} - - /* - * Hook for special feature validation. Optional. - * Feature should throw RuntimeException to stop immediately. - */ - protected void validate() { } - - /** - * Hook for preConfigInit. Optional. - * Feature should throw RuntimeException to stop immediately. - */ - void preConfigInit(Config configToSet) { } - - /** - * Hook for postConfigInit. Optional. - * Feature should throw RuntimeException to stop immediately. - */ - void postConfigInit(Config configToSet) { } + protected FileSystemUtils fileSystemUtils + protected DeploymentStrategy deployer + protected AirGappedUtils airGappedUtils + protected GitHandler gitHandler + protected Map helmValuesTemplateData = [:] + + protected void addHelmValuesData(String key, Object value) { + this.helmValuesTemplateData[key] = value + } + + boolean install() { + if (isEnabled()) { + log.info("Installing Feature ${getClass().getSimpleName()}") + + if (this instanceof FeatureWithImage) { + (this as FeatureWithImage).createImagePullSecret() + } + + enable() + return true + } else { + log.debug("Feature ${getClass().getSimpleName()} is disabled") + disable() + return false + } + } + + String getActiveNamespaceFromFeature() { + //using reflection to get all subclasses implementing a own namespace + if (this.metaClass.hasProperty(this, 'namespace')) { + return isEnabled() ? this.getProperty('namespace') : null + } + return null + } + + static Map templateToMap(String filePath, Map parameters) { + def hydratedString = new TemplatingEngine().template(new File(filePath), parameters) + + if (hydratedString.trim().isEmpty()) { + // Otherwise YamlSlurper returns an empty array, whereas we expect a Map + return [:] + } + return new YamlSlurper().parseText(hydratedString) as Map + } + + protected void deployHelmChart( + String featureName, + String releaseName, + String namespace, + Config.HelmConfigWithValues helmConfig, + String helmValuesTemplatePath, + Config config, boolean initByHelm = false) { + String repoURL = helmConfig.repoURL + String chartOrPath = helmConfig.chart + String version = helmConfig.version + RepoType repoType = RepoType.HELM + + this.addHelmValuesData("config", config) + this.addHelmValuesData("statics", new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_32).build().getStaticModels()) + + /* If we get a helmValuesTemplatePath we render the Template with the given Data. + * Some Features might not use a values template and thus passing no helmValuesTemplatePath, in that + * case we simply treat helmValuesTemplateData directly as helmValuesData */ + Map helmValuesData = this.helmValuesTemplateData + if (helmValuesTemplatePath) { + log.debug("got helm_value_path, rendering values template") + helmValuesData = templateToMap(helmValuesTemplatePath, this.helmValuesTemplateData) + } + + helmValuesData = MapUtils.deepMerge(helmConfig.values, helmValuesData) + Path tempValuesPath = this.fileSystemUtils.writeTempFile(helmValuesData) + + if (config.application.mirrorRepos) { + log.debug("Using a local, mirrored git repo as deployment source for feature ${featureName}") + + String repoNamespaceAndName = this.airGappedUtils.mirrorHelmRepoToGit(helmConfig) + repoURL = this.gitHandler.resourcesScm.repoUrl(repoNamespaceAndName) + chartOrPath = '.' + repoType = RepoType.GIT + version = new YamlSlurper() + .parse(Path.of("${config.application.localHelmChartFolder}/${helmConfig.chart}", + 'Chart.yaml'))['version'] + } + + log.debug("Starting deployment of feature ${featureName} from ${repoURL}.") + log.debug("helm values used: ${helmValuesData}") + + this.deployer.deployFeature(repoURL, + featureName, + chartOrPath, + version, + namespace, + releaseName, + tempValuesPath, + repoType, + initByHelm) + } + + abstract boolean isEnabled() + + /* + * Hooks for enabling or disabling a feature. Both optional, because not always needed. + */ + + protected void enable() { + } + + protected void disable() { + } + + /* + * Hook for special feature validation. Optional. + * Feature should throw RuntimeException to stop immediately. + */ + + protected void validate() { + } + + /** + * Hook for preConfigInit. Optional. + * Feature should throw RuntimeException to stop immediately.*/ + void preConfigInit(Config configToSet) { + } + + /** + * Hook for postConfigInit. Optional. + * Feature should throw RuntimeException to stop immediately.*/ + void postConfigInit(Config configToSet) { + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy index 86e03e282..8bc19d0ff 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy @@ -3,10 +3,6 @@ package com.cloudogu.gitops.cli import com.cloudogu.gitops.Application import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.dependencyinjection.HttpClientFactory -import com.cloudogu.gitops.destroy.ArgoCDDestructionHandler -import com.cloudogu.gitops.destroy.Destroyer -import com.cloudogu.gitops.destroy.JenkinsDestructionHandler -import com.cloudogu.gitops.destroy.ScmmDestructionHandler import com.cloudogu.gitops.features.* import com.cloudogu.gitops.features.argocd.ArgoCD import com.cloudogu.gitops.features.deployment.ArgoCdApplicationStrategy @@ -64,37 +60,31 @@ class GitopsPlaygroundCliMainScripted { HttpClientFactory httpClientFactory = new HttpClientFactory() GitRepoFactory gitRepoFactory = new GitRepoFactory(config, fileSystemUtils) HelmStrategy helmStrategy = new HelmStrategy(config, helmClient) - GitHandler gitHandler = new GitHandler(config, helmStrategy, fileSystemUtils, k8sClient, networkingUtils) JenkinsApiClient jenkinsApiClient = new JenkinsApiClient(config, httpClientFactory.okHttpClientJenkins(config)) context.registerSingleton(k8sClient) + GitHandler gitHandler = new GitHandler(config, helmStrategy, fileSystemUtils, k8sClient, networkingUtils) + Deployer deployer = new Deployer(config, new ArgoCdApplicationStrategy(config, fileSystemUtils, gitRepoFactory, gitHandler), helmStrategy) - if (config.application.destroy) { - context.registerSingleton(new Destroyer([new ArgoCDDestructionHandler(config, k8sClient, gitRepoFactory, helmClient, fileSystemUtils, gitHandler), - new ScmmDestructionHandler(config), - new JenkinsDestructionHandler(new JobManager(jenkinsApiClient), config, new GlobalPropertyManager(jenkinsApiClient)),])) - } else { - Deployer deployer = new Deployer(config, new ArgoCdApplicationStrategy(config, fileSystemUtils, gitRepoFactory, gitHandler), helmStrategy) - AirGappedUtils airGappedUtils = new AirGappedUtils(config, gitRepoFactory, fileSystemUtils, helmClient, gitHandler) - Jenkins jenkins = new Jenkins(config, executor, fileSystemUtils, new GlobalPropertyManager(jenkinsApiClient), - new JobManager(jenkinsApiClient), new UserManager(jenkinsApiClient), - new PrometheusConfigurator(jenkinsApiClient), helmStrategy, k8sClient, networkingUtils, gitHandler) + AirGappedUtils airGappedUtils = new AirGappedUtils(config, gitRepoFactory, fileSystemUtils, helmClient, gitHandler) + Jenkins jenkins = new Jenkins(config, executor, fileSystemUtils, new GlobalPropertyManager(jenkinsApiClient), + new JobManager(jenkinsApiClient), new UserManager(jenkinsApiClient), + new PrometheusConfigurator(jenkinsApiClient), deployer, k8sClient, networkingUtils, gitHandler) - // make sure the order of features is in same order as the @Order values - context.registerSingleton(new Application(config, [new Registry(config, fileSystemUtils, k8sClient, helmStrategy), - gitHandler, - jenkins, - new ArgoCD(config, k8sClient, helmClient, helmStrategy, fileSystemUtils, gitRepoFactory, gitHandler), - new Ingress(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), - new CertManager(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), - new Mail(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), - new Monitoring(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitRepoFactory, gitHandler), - new ExternalSecretsOperator(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), - new Vault(config, fileSystemUtils, k8sClient, deployer, airGappedUtils, gitHandler), - new ContentLoader(config, k8sClient, gitRepoFactory, jenkins, gitHandler),])) - } + // make sure the order of features is in same order as the @Order values + context.registerSingleton(new Application(config, [new Registry(config, fileSystemUtils, k8sClient, deployer), + gitHandler, + jenkins, + new ArgoCD(config, k8sClient, helmClient, deployer, fileSystemUtils, gitRepoFactory, gitHandler), + new Ingress(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), + new CertManager(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), + new Mail(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), + new Monitoring(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitRepoFactory, gitHandler), + new ExternalSecretsOperator(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), + new Vault(config, fileSystemUtils, k8sClient, deployer, airGappedUtils, gitHandler), + new ContentLoader(config, k8sClient, gitRepoFactory, jenkins, gitHandler),])) } } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy b/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy index 1ff5d44bb..a8628b887 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy @@ -2,7 +2,7 @@ package com.cloudogu.gitops.features import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.HelmStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.features.git.config.util.ScmProviderType import com.cloudogu.gitops.jenkins.GlobalPropertyManager @@ -10,270 +10,248 @@ import com.cloudogu.gitops.jenkins.JobManager import com.cloudogu.gitops.jenkins.PrometheusConfigurator import com.cloudogu.gitops.jenkins.UserManager import com.cloudogu.gitops.kubernetes.api.K8sClient -import com.cloudogu.gitops.utils.* -import groovy.util.logging.Slf4j +import com.cloudogu.gitops.utils.CommandExecutor +import com.cloudogu.gitops.utils.FileSystemUtils +import com.cloudogu.gitops.utils.NetworkingUtils + import io.micronaut.core.annotation.Order + import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton @Order(70) class Jenkins extends Feature { - static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/jenkins/values.ftl.yaml" - - String namespace - private Config config - private CommandExecutor commandExecutor - private GlobalPropertyManager globalPropertyManager - private JobManager jobManager - private UserManager userManager - private PrometheusConfigurator prometheusConfigurator - private K8sClient k8sClient - private NetworkingUtils networkingUtils - - Jenkins( - Config config, - CommandExecutor commandExecutor, - FileSystemUtils fileSystemUtils, - GlobalPropertyManager globalPropertyManager, - JobManager jobManager, - UserManager userManager, - PrometheusConfigurator prometheusConfigurator, - HelmStrategy deployer, - K8sClient k8sClient, - NetworkingUtils networkingUtils, - GitHandler gitHandler - ) { - this.config = config - this.commandExecutor = commandExecutor - this.fileSystemUtils = fileSystemUtils - this.globalPropertyManager = globalPropertyManager - this.jobManager = jobManager - this.userManager = userManager - this.prometheusConfigurator = prometheusConfigurator - this.deployer = deployer - this.k8sClient = k8sClient - this.networkingUtils = networkingUtils - this.gitHandler = gitHandler - - if (config.jenkins.internal) { - this.namespace = "${config.application.namePrefix}jenkins" - } - } - - @Override - boolean isEnabled() { - return config.jenkins.active - } - - - @Override - void enable() { - - if (config.jenkins.internal) { - - k8sClient.createNamespace(namespace) - - // Mark the first node for Jenkins and agents. See jenkins/values.ftl.yaml "agent.workingDir" for details. - // Remove first (in case new nodes were added) - k8sClient.labelRemove('node', '--all', '', 'node') - def nodeName = k8sClient.waitForNode().replace('node/', '') - k8sClient.label('node', nodeName, new Tuple2('node', 'jenkins')) - - k8sClient.createSecret('generic', 'jenkins-credentials', namespace, - new Tuple2('jenkins-admin-user', config.jenkins.username), - new Tuple2('jenkins-admin-password', config.jenkins.password)) - - def helmConfig = config.jenkins.helm - String releaseName = "jenkins" - addHelmValuesData("dockerGid", findDockerGid()) - - deployHelmChart('jenkins', releaseName, namespace, helmConfig, HELM_VALUES_PATH, config) - - // Defined here: https://github.com/jenkinsci/helm-charts/blob/jenkins-5.8.1/charts/jenkins/templates/_helpers.tpl#L46-L57 - String serviceName = releaseName - // Update jenkins.url after it is deployed (and ports are known) - if (config.application.runningInsideK8s) { - log.debug("Setting jenkins url to k8s service, since installation is running inside k8s") - config.jenkins.url = networkingUtils.createUrl("${serviceName}.${namespace}.svc.cluster.local", "80") - } else { - log.debug("Setting jenkins configs for local single node cluster with internal jenkins. Waiting for NodePort...") - def port = k8sClient.waitForNodePort(serviceName, namespace) - String clusterBindAddress = networkingUtils.findClusterBindAddress() - config.jenkins.url = networkingUtils.createUrl(clusterBindAddress, port) - } - } - - commandExecutor.execute("${fileSystemUtils.rootDir}/scripts/jenkins/init-jenkins.sh", [ - TRACE : config.application.trace, - INTERNAL_JENKINS : config.jenkins.internal, - JENKINS_HELM_CHART_VERSION: config.jenkins.helm.version, - JENKINS_URL : config.jenkins.url, - JENKINS_USERNAME : config.jenkins.username, - JENKINS_PASSWORD : config.jenkins.password, - SCM_URL : this.gitHandler.tenant.url, - PREFIXED_SCM_URL : this.gitHandler.tenant.repoPrefix(), - SCM_PASSWORD : this.gitHandler.tenant.credentials.password, - SCM_PROVIDER : config.scm.scmProviderType, - INSTALL_ARGOCD : config.features.argocd.active, - NAME_PREFIX : config.application.namePrefix, - INSECURE : config.application.insecure, - SKIP_RESTART : config.jenkins.skipRestart, - SKIP_PLUGINS : config.jenkins.skipPlugins - ]) - - globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}SCM_URL", this.gitHandler.tenant.url) - globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}PREFIXED_SCM_URL", this.gitHandler.tenant.repoPrefix()) - - if (config.jenkins.additionalEnvs) { - for (entry in (config.jenkins.additionalEnvs as Map).entrySet()) { - globalPropertyManager.setGlobalProperty(entry.key.toString(), entry.value.toString()) - } - } - - if (config.registry.url) { - globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}REGISTRY_URL", config.registry.url) - } - - if (config.registry.path) { - globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}REGISTRY_PATH", config.registry.path) - } - - if (config.registry.twoRegistries) { - globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}REGISTRY_PROXY_URL", config.registry.proxyUrl) - globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}REGISTRY_PROXY_PATH", config.registry.proxyPath) - } - - if (config.jenkins.mavenCentralMirror) { - globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}MAVEN_CENTRAL_MIRROR", config.jenkins.mavenCentralMirror) - } - - globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}K8S_VERSION", Config.K8S_VERSION) - - if (userManager.isUsingCasSecurityRealm()) { - log.trace("Using CAS Security Realm. Must not create user.") - } else { - userManager.createUser(config.jenkins.metricsUsername, config.jenkins.metricsPassword) - } - - userManager.grantPermission(config.jenkins.metricsUsername, UserManager.Permissions.METRICS_VIEW) - - if (config.features.monitoring.active && config.jenkins.internal) { - // And external Jenkins can likely not be monitored - prometheusConfigurator.enableAuthentication() - } - - } - - void createJenkinsjob(String namespace, String repoName) { - def credentialId = "scm-user" - String prefixedNamespace = "${config.application.namePrefix}${namespace}" - String jobName = "${config.application.namePrefix}${repoName}" - - jobManager.createJob(jobName, - this.gitHandler.tenant.url, - prefixedNamespace, - credentialId) - - - if (config.scm.scmProviderType == ScmProviderType.SCM_MANAGER) { - jobManager.createCredential( - jobName, - credentialId, - "${config.application.namePrefix}gitops", - "${config.scm.getScmManager().password}", - 'credentials for accessing scm-manager') - } - - if (config.scm.scmProviderType == ScmProviderType.GITLAB) { - jobManager.createCredential( - jobName, - credentialId, - "${config.scm.getGitlab().username}", - "${config.scm.getGitlab().password}", - 'credentials for accessing gitlab') - } - - jobManager.createCredential( - jobName, - "registry-user", - "${config.registry.username}", - "${config.registry.password}", - 'credentials for accessing the docker-registry for writing images built on jenkins') - - if (config.registry.twoRegistries) { - jobManager.createCredential( - jobName, - "registry-proxy-user", - "${config.registry.proxyUsername}", - "${config.registry.proxyPassword}", - 'credentials for accessing the docker-registry that contains 3rd party or base images') - } - - jobManager.startJob(jobName) - } - - protected String findDockerGid() { - String gid = '' - def etcGroup = k8sClient.run("tmp-docker-gid-grepper-${new Random().nextInt(10000)}", - 'irrelevant' /* Redundant, but mandatory param */, namespace, createGidGrepperOverrides(), - '--restart=Never', '-ti', '--rm', '--quiet') - // --quiet is necessary to avoid 'pod deleted' output - - def lines = etcGroup?.split('\n') - for (String it : lines) { - def parts = it.split(":") - if (parts[0] == 'docker') { - gid = parts[2] - break - } - } - - if (!gid) { - log.warn('Unable to determine Docker Group ID (GID). Jenkins Agent pods will run as root user (UID 0)!\n' + - "Group docker not found in /etc/group:\n${etcGroup}") - return '' - } else { - log.debug("Using Docker Group ID (GID) ${gid} for Jenkins Agent pods") - return gid - } - } - - Map createGidGrepperOverrides() { - [ - 'spec': [ - 'containers' : [ - [ - 'name' : 'tmp-docker-gid-grepper', - // We use the same image for several tasks for performance and maintenance reasons - 'image' : "${config.jenkins.internalBashImage}", - 'args' : ['cat', '/etc/group'], - 'volumeMounts': [ - [ - 'name' : 'group', - 'mountPath': '/etc/group', - 'readOnly' : true - ] - ] - ] - ], - 'nodeSelector': [ - 'node': 'jenkins' - ], - 'volumes' : [ - [ - 'name' : 'group', - 'hostPath': [ - 'path': '/etc/group' - ] - ] - ] - ] - ] - } - @Override - String getActiveNamespaceFromFeature() { - return isEnabled() && config?.jenkins?.internal ? getNamespace() : null - } + static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/jenkins/values.ftl.yaml" + + String namespace + private Config config + private CommandExecutor commandExecutor + private GlobalPropertyManager globalPropertyManager + private JobManager jobManager + private UserManager userManager + private PrometheusConfigurator prometheusConfigurator + private K8sClient k8sClient + private NetworkingUtils networkingUtils + + Jenkins( + Config config, + CommandExecutor commandExecutor, + FileSystemUtils fileSystemUtils, + GlobalPropertyManager globalPropertyManager, + JobManager jobManager, + UserManager userManager, + PrometheusConfigurator prometheusConfigurator, + Deployer deployer, + K8sClient k8sClient, + NetworkingUtils networkingUtils, + GitHandler gitHandler) { + this.config = config + this.commandExecutor = commandExecutor + this.fileSystemUtils = fileSystemUtils + this.globalPropertyManager = globalPropertyManager + this.jobManager = jobManager + this.userManager = userManager + this.prometheusConfigurator = prometheusConfigurator + this.deployer = deployer + this.k8sClient = k8sClient + this.networkingUtils = networkingUtils + this.gitHandler = gitHandler + + if (config.jenkins.internal) { + this.namespace = "${config.application.namePrefix}jenkins" + } + } + + @Override + boolean isEnabled() { + return config.jenkins.active + } + + @Override + void enable() { + + if (config.jenkins.internal) { + + k8sClient.createNamespace(namespace) + + // Mark the first node for Jenkins and agents. See jenkins/values.ftl.yaml "agent.workingDir" for details. + // Remove first (in case new nodes were added) + k8sClient.labelRemove('node', '--all', '', 'node') + String nodeName = k8sClient.waitForNode().replace('node/', '') + k8sClient.label('node', nodeName, new Tuple2('node', 'jenkins')) + + k8sClient.createSecret('generic', 'jenkins-credentials', namespace, + new Tuple2('jenkins-admin-user', config.jenkins.username), + new Tuple2('jenkins-admin-password', config.jenkins.password)) + + Config.HelmConfigWithValues helmConfig = config.jenkins.helm + String releaseName = 'jenkins' + addHelmValuesData('dockerGid', findDockerGid()) + + deployHelmChart('jenkins', releaseName, namespace, helmConfig, HELM_VALUES_PATH, config, true) + + // Defined here: https://github.com/jenkinsci/helm-charts/blob/jenkins-5.8.1/charts/jenkins/templates/_helpers.tpl#L46-L57 + String serviceName = releaseName + // Update jenkins.url after it is deployed (and ports are known) + if (config.application.runningInsideK8s) { + log.debug('Setting jenkins url to k8s service, since installation is running inside k8s') + config.jenkins.url = networkingUtils.createUrl(serviceName + '.' + namespace + '.svc.cluster.local', '80') + } else { + log.debug('Setting jenkins configs for local single node cluster with internal jenkins. Waiting for NodePort...') + String port = k8sClient.waitForNodePort(serviceName, namespace) + String clusterBindAddress = networkingUtils.findClusterBindAddress() + config.jenkins.url = networkingUtils.createUrl(clusterBindAddress, port) + } + runSetupScript() + } + + } + + private void runSetupScript() { + commandExecutor.execute("${fileSystemUtils.rootDir}/scripts/jenkins/init-jenkins.sh", [TRACE : config.application.trace, + INTERNAL_JENKINS : config.jenkins.internal, + JENKINS_HELM_CHART_VERSION: config.jenkins.helm.version, + JENKINS_URL : config.jenkins.url, + JENKINS_USERNAME : config.jenkins.username, + JENKINS_PASSWORD : config.jenkins.password, + SCM_URL : this.gitHandler.tenant.url, + PREFIXED_SCM_URL : this.gitHandler.tenant.repoPrefix(), + SCM_PASSWORD : this.gitHandler.tenant.credentials.password, + SCM_PROVIDER : config.scm.scmProviderType, + INSTALL_ARGOCD : config.features.argocd.active, + NAME_PREFIX : config.application.namePrefix, + INSECURE : config.application.insecure, + SKIP_RESTART : config.jenkins.skipRestart, + SKIP_PLUGINS : config.jenkins.skipPlugins,]) + + globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}SCM_URL", this.gitHandler.tenant.url) + globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}PREFIXED_SCM_URL", this.gitHandler.tenant.repoPrefix()) + + if (config.jenkins.additionalEnvs) { + for (entry in (config.jenkins.additionalEnvs as Map).entrySet()) { + globalPropertyManager.setGlobalProperty(entry.key.toString(), entry.value.toString()) + } + } + + if (config.registry.url) { + globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}REGISTRY_URL", config.registry.url) + } + + if (config.registry.path) { + globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}REGISTRY_PATH", config.registry.path) + } + + if (config.registry.twoRegistries) { + globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}REGISTRY_PROXY_URL", config.registry.proxyUrl) + globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}REGISTRY_PROXY_PATH", config.registry.proxyPath) + } + + if (config.jenkins.mavenCentralMirror) { + globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}MAVEN_CENTRAL_MIRROR", config.jenkins.mavenCentralMirror) + } + + globalPropertyManager.setGlobalProperty("${config.application.namePrefixForEnvVars}K8S_VERSION", Config.K8S_VERSION) + + if (userManager.isUsingCasSecurityRealm()) { + log.trace("Using CAS Security Realm. Must not create user.") + } else { + userManager.createUser(config.jenkins.metricsUsername, config.jenkins.metricsPassword) + } + + userManager.grantPermission(config.jenkins.metricsUsername, UserManager.Permissions.METRICS_VIEW) + + if (config.features.monitoring.active && config.jenkins.internal) { + // And external Jenkins can likely not be monitored + prometheusConfigurator.enableAuthentication() + } + } + + void createJenkinsjob(String namespace, String repoName) { + def credentialId = "scm-user" + String prefixedNamespace = "${config.application.namePrefix}${namespace}" + String jobName = "${config.application.namePrefix}${repoName}" + + jobManager.createJob(jobName, + this.gitHandler.tenant.url, + prefixedNamespace, + credentialId) + + if (config.scm.scmProviderType == ScmProviderType.SCM_MANAGER) { + jobManager.createCredential(jobName, + credentialId, + "${config.application.namePrefix}gitops", + "${config.scm.getScmManager().password}", + 'credentials for accessing scm-manager') + } + + if (config.scm.scmProviderType == ScmProviderType.GITLAB) { + jobManager.createCredential(jobName, + credentialId, + "${config.scm.getGitlab().username}", + "${config.scm.getGitlab().password}", + 'credentials for accessing gitlab') + } + + jobManager.createCredential(jobName, + "registry-user", + "${config.registry.username}", + "${config.registry.password}", + 'credentials for accessing the docker-registry for writing images built on jenkins') + + if (config.registry.twoRegistries) { + jobManager.createCredential(jobName, + "registry-proxy-user", + "${config.registry.proxyUsername}", + "${config.registry.proxyPassword}", + 'credentials for accessing the docker-registry that contains 3rd party or base images') + } + + jobManager.startJob(jobName) + } + + protected String findDockerGid() { + String gid = '' + def etcGroup = k8sClient.run("tmp-docker-gid-grepper-${new Random().nextInt(10000)}", + 'irrelevant' /* Redundant, but mandatory param */, namespace, createGidGrepperOverrides(), + '--restart=Never', '-ti', '--rm', '--quiet') + // --quiet is necessary to avoid 'pod deleted' output + + def lines = etcGroup?.split('\n') + for (String it : lines) { + def parts = it.split(":") + if (parts[0] == 'docker') { + gid = parts[2] + break + } + } + + if (!gid) { + log.warn('Unable to determine Docker Group ID (GID). Jenkins Agent pods will run as root user (UID 0)!\n' + "Group docker not found in /etc/group:\n${etcGroup}") + return '' + } else { + log.debug("Using Docker Group ID (GID) ${gid} for Jenkins Agent pods") + return gid + } + } + + Map createGidGrepperOverrides() { + ['spec': ['containers' : [['name' : 'tmp-docker-gid-grepper', + // We use the same image for several tasks for performance and maintenance reasons + 'image' : "${config.jenkins.internalBashImage}", + 'args' : ['cat', '/etc/group'], + 'volumeMounts': [['name' : 'group', + 'mountPath': '/etc/group', + 'readOnly' : true]]]], + 'nodeSelector': ['node': 'jenkins'], + 'volumes' : [['name' : 'group', + 'hostPath': ['path': '/etc/group']]]]] + } + + @Override + String getActiveNamespaceFromFeature() { + return isEnabled() && config?.jenkins?.internal ? getNamespace() : null + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy b/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy index 61e66ed96..c5deeebc3 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy @@ -2,81 +2,79 @@ package com.cloudogu.gitops.features import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.HelmStrategy -import com.cloudogu.gitops.utils.FileSystemUtils +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.kubernetes.api.K8sClient -import groovy.util.logging.Slf4j +import com.cloudogu.gitops.utils.FileSystemUtils + import io.micronaut.core.annotation.Order + import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton -@Order(40) +@Order(60) class Registry extends Feature { - /** - * Local container port of the registry within the pod - */ - public static final String CONTAINER_PORT = '5000' + /** + * Local container port of the registry within the pod*/ + public static final String CONTAINER_PORT = '5000' - String namespace - private Config config - private K8sClient k8sClient + String namespace + private Config config + private K8sClient k8sClient - Registry( - Config config, - FileSystemUtils fileSystemUtils, - K8sClient k8sClient, - // For now we deploy imperatively using helm to avoid order problems. In future we could deploy via argocd. - HelmStrategy deployer - ) { - this.deployer = deployer - this.config = config - this.fileSystemUtils = fileSystemUtils - this.k8sClient = k8sClient + Registry( + Config config, + FileSystemUtils fileSystemUtils, + K8sClient k8sClient, + // For now we deploy imperatively using helm to avoid order problems. In future we could deploy via argocd. + Deployer deployer) { + this.deployer = deployer + this.config = config + this.fileSystemUtils = fileSystemUtils + this.k8sClient = k8sClient - if(config.registry.internal) { - this.namespace = "${config.application.namePrefix}registry" - } - } + if (config.registry.internal) { + this.namespace = "${config.application.namePrefix}registry" + } + } - @Override - boolean isEnabled() { - return config.registry.active - } + @Override + boolean isEnabled() { + return config.registry.active + } - @Override - void enable() { + @Override + void enable() { - if (config.registry.internal) { - addHelmValuesData("service", [ - nodePort: Config.DEFAULT_REGISTRY_PORT, - type : 'NodePort' - ]) + if (config.registry.internal) { + addHelmValuesData("service", [nodePort: Config.DEFAULT_REGISTRY_PORT, + type : 'NodePort']) - def helmConfig = config.registry.helm - deployHelmChart('registry', 'docker-registry', namespace, helmConfig, "", config) + def helmConfig = config.registry.helm + deployHelmChart('registry', 'docker-registry', namespace, helmConfig, "", config, true) - if (config.registry.internalPort != Config.DEFAULT_REGISTRY_PORT) { - /* Add additional node port - 30000 is needed as a static by docker via port mapping of k3d, e.g. 32769 -> 30000 on server-0 container - See "-p 30000" in init-cluster.sh - e.g 32769 is needed so the kubelet can access the image inside the server-0 container - */ + if (config.registry.internalPort != Config.DEFAULT_REGISTRY_PORT) { + /* Add additional node port + 30000 is needed as a static by docker via port mapping of k3d, e.g. 32769 -> 30000 on server-0 container + See "-p 30000" in init-cluster.sh + e.g 32769 is needed so the kubelet can access the image inside the server-0 container + */ - /* k8sClient.createServiceNodePort('docker-registry-internal-port', - CONTAINER_PORT, config.registry.internalPort.toString(), - namespace) */ + /* k8sClient.createServiceNodePort('docker-registry-internal-port', + CONTAINER_PORT, config.registry.internalPort.toString(), + namespace) */ - Map selector = new HashMap<>() - selector.put("app", "docker-registry") - k8sClient.k8sJavaApiClient.createNodePortService(namespace, - 'docker-registry-internal-port', - selector, - CONTAINER_PORT.toInteger(), - config.registry.internalPort, - 'docker-registry-internal-port') - } - } - } + Map selector = new HashMap<>() + selector.put("app", "docker-registry") + k8sClient.k8sJavaApiClient.createNodePortService(namespace, + 'docker-registry-internal-port', + selector, + CONTAINER_PORT.toInteger(), + config.registry.internalPort, + 'docker-registry-internal-port') + } + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy index a0c6246be..3c842dc18 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy @@ -2,7 +2,7 @@ package com.cloudogu.gitops.features.argocd import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.HelmStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.kubernetes.api.HelmClient @@ -42,7 +42,7 @@ class ArgoCD extends Feature { Config config, K8sClient k8sClient, HelmClient helmClient, - HelmStrategy deployer, + Deployer deployer, FileSystemUtils fileSystemUtils, GitRepoFactory repoProvider, GitHandler gitHandler) { @@ -141,7 +141,7 @@ class ArgoCD extends Feature { } else { // Bootstrap root application k8sClient.applyYaml(Path.of(clusterResourcesRepo.projectsDir(), "argocd.yaml").toString()) - k8sClient.applyYaml(Path.of(clusterResourcesRepo.applicationsDir(), "bootstrap.yaml").toString()) + k8sClient.applyYaml(Path.of(clusterResourcesRepo.applicationsDir(), "argocd.yaml").toString()) } // Delete helm-argo secrets to decouple from helm. @@ -207,8 +207,7 @@ rm -Rf ../argocd-operator/ private void deployWithHelm() { addHelmValuesData('argocd', [host: config.features.argocd.url ? new URL(config.features.argocd.url).host : '']) - deployHelmChart(this.namespace, this.namespace, namespace, config.features.argocd.helm, HELM_VALUES_PATH, config) - + deployHelmChart(this.namespace, this.namespace, namespace, config.features.argocd.helm, HELM_VALUES_PATH, config, true) log.debug("Setting new argocd admin password") // Set admin password imperatively here instead of values.yaml, because we don't want it to show in git repo String bcryptArgoCDPassword = BCrypt.hashpw(password, BCrypt.gensalt(4)) diff --git a/src/main/groovy/com/cloudogu/gitops/features/deployment/Deployer.groovy b/src/main/groovy/com/cloudogu/gitops/features/deployment/Deployer.groovy index 7d7578aeb..5646914d7 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/deployment/Deployer.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/deployment/Deployer.groovy @@ -3,30 +3,35 @@ package com.cloudogu.gitops.features.deployment import com.cloudogu.gitops.config.Config import io.micronaut.context.annotation.Primary -import jakarta.inject.Singleton import java.nio.file.Path +import jakarta.inject.Singleton @Singleton @Primary class Deployer implements DeploymentStrategy { - private Config config - private ArgoCdApplicationStrategy argoCdStrategy - private HelmStrategy helmStrategy - - Deployer(Config config, ArgoCdApplicationStrategy argoCdStrategy, HelmStrategy helmStrategy) { - this.helmStrategy = helmStrategy - this.argoCdStrategy = argoCdStrategy - this.config = config - } - - @Override - void deployFeature(String repoURL, String repoName, String chartOrPath, String version, String namespace, - String releaseName, Path helmValuesPath, RepoType repoType) { - if (config.features['argocd']['active']) { - argoCdStrategy.deployFeature(repoURL, repoName, chartOrPath, version, namespace, releaseName, helmValuesPath, repoType) - } else { - helmStrategy.deployFeature(repoURL, repoName, chartOrPath, version, namespace, releaseName, helmValuesPath, repoType) - } - } -} + Config config + ArgoCdApplicationStrategy argoCdStrategy + HelmStrategy helmStrategy + + Deployer(Config config, ArgoCdApplicationStrategy argoCdStrategy, HelmStrategy helmStrategy) { + this.helmStrategy = helmStrategy + this.argoCdStrategy = argoCdStrategy + this.config = config + } + + @Override + void deployFeature( + String repoURL, String repoName, String chartOrPath, String version, String namespace, + String releaseName, Path helmValuesPath, RepoType repoType, boolean initByHelm = false) { + + if (initByHelm) { + helmStrategy.deployFeature(repoURL, repoName, chartOrPath, version, namespace, releaseName, helmValuesPath, repoType) + } + + if (config.features['argocd']['active']) { + argoCdStrategy.deployFeature(repoURL, repoName, chartOrPath, version, namespace, releaseName, helmValuesPath, repoType) + } + } + +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy index 9df50be7c..bdd233cc0 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy @@ -7,134 +7,129 @@ import com.cloudogu.gitops.features.git.config.util.ScmProviderType import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.git.providers.gitlab.Gitlab import com.cloudogu.gitops.git.providers.scmmanager.ScmManager -import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.kubernetes.api.K8sClient +import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.NetworkingUtils -import groovy.util.logging.Slf4j + import io.micronaut.core.annotation.Order + import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton -@Order(60) +@Order(40) class GitHandler extends Feature { - Config config - - NetworkingUtils networkingUtils - HelmStrategy helmStrategy - FileSystemUtils fileSystemUtils - K8sClient k8sClient - - GitProvider tenant - GitProvider central - - - GitHandler(Config config, HelmStrategy helmStrategy, FileSystemUtils fileSystemUtils, K8sClient k8sClient, NetworkingUtils networkingUtils) { - this.config = config - this.helmStrategy = helmStrategy - this.fileSystemUtils = fileSystemUtils - this.k8sClient = k8sClient - this.networkingUtils = networkingUtils - } - - @Override - boolean isEnabled() { - return true - } - - void validate() { - if (config.scm.scmManager.url) { - config.scm.scmManager.internal = false - config.scm.scmManager.urlForJenkins = config.scm.scmManager.url - } else { - log.debug("Setting configs for internal SCM-Manager") - // We use the K8s service as default name here, because it is the only option: - // "scmm.localhost" will not work inside the Pods and k3d-container IP + Port (e.g. 172.x.y.z:9091) - // will not work on Windows and MacOS. - config.scm.scmManager.urlForJenkins = - "http://scmm.${config.application.namePrefix}scm-manager.svc.cluster.local/scm" - - // More internal fields are set lazily in ScmManger.groovy (after SCMM is deployed and ports are known) - } - config.scm.scmManager.gitOpsUsername="${config.application.namePrefix}gitops" - - if (config.scm.gitlab.url) { - config.scm.scmProviderType = ScmProviderType.GITLAB - config.scm.scmManager = null - if (!config.scm.gitlab.password || !config.scm.gitlab.parentGroupId) { - throw new RuntimeException('GitLab configuration incomplete: please provide both password (PAT) and parentGroupId') - } - } - - - - } - - //Retrieves the appropriate SCM for cluster resources depending on whether the environment is multi-tenant or not. - GitProvider getResourcesScm() { - if (central) { - return central - } else if (tenant) { - return tenant - } else { - throw new IllegalStateException("No SCM provider found.") - } - } - - @Override - void enable() { - //TenantSCM - switch (config.scm.scmProviderType) { - case ScmProviderType.GITLAB: - this.tenant = new Gitlab(this.config, this.config.scm.gitlab) - break - case ScmProviderType.SCM_MANAGER: - def prefixedNamespace = "${config.application.namePrefix}scm-manager".toString() - config.scm.scmManager.namespace = prefixedNamespace - this.tenant = new ScmManager(this.config, config.scm.scmManager, helmStrategy,k8sClient, networkingUtils, true) - // this.tenant.setup() setup will be here in future - break - default: - throw new IllegalArgumentException("Unsupported SCM provider found in TenantSCM") - } - - if (config.multiTenant.useDedicatedInstance) { - switch (config.multiTenant.scmProviderType) { - case ScmProviderType.GITLAB: - this.central = new Gitlab(this.config, this.config.multiTenant.gitlab) - break - case ScmProviderType.SCM_MANAGER: - this.central = new ScmManager(this.config, config.multiTenant.scmManager, helmStrategy,k8sClient, networkingUtils) - break - default: - throw new IllegalArgumentException("Unsupported SCM-Central provider: ${config.scm.scmProviderType}") - } - } - - //can be removed if we combine argocd and cluster-resources - final String namePrefix = (config?.application?.namePrefix ?: "").trim() - if (this.central) { - setupRepos(this.central, namePrefix) - setupRepos(this.tenant, namePrefix) - } else { - setupRepos(this.tenant, namePrefix) - } - } - - static void setupRepos(GitProvider gitProvider, String namePrefix = "") { - gitProvider.createRepository( - withOrgPrefix(namePrefix, "argocd/cluster-resources"), - "GitOps repo for basic cluster-resources" - ) - } - - /** - * Adds a prefix to the group/namespace part (before the first '/'): - * Example: "argocd/argocd" + "foo-" => "foo-argocd/argocd" - */ - static String withOrgPrefix(String prefix, String repoPath) { - if (!prefix) return repoPath - return prefix + repoPath - } + Config config + + NetworkingUtils networkingUtils + HelmStrategy deployer + FileSystemUtils fileSystemUtils + K8sClient k8sClient + + GitProvider tenant + GitProvider central + + GitHandler(Config config, HelmStrategy deployer, FileSystemUtils fileSystemUtils, K8sClient k8sClient, NetworkingUtils networkingUtils) { + this.config = config + this.deployer = deployer + this.fileSystemUtils = fileSystemUtils + this.k8sClient = k8sClient + this.networkingUtils = networkingUtils + } + + @Override + boolean isEnabled() { + return true + } + + void validate() { + if (config.scm.scmManager.url) { + config.scm.scmManager.internal = false + config.scm.scmManager.urlForJenkins = config.scm.scmManager.url + } else { + log.debug("Setting configs for internal SCM-Manager") + // We use the K8s service as default name here, because it is the only option: + // "scmm.localhost" will not work inside the Pods and k3d-container IP + Port (e.g. 172.x.y.z:9091) + // will not work on Windows and MacOS. + config.scm.scmManager.urlForJenkins = "http://scmm.${config.application.namePrefix}scm-manager.svc.cluster.local/scm" + + // More internal fields are set lazily in ScmManger.groovy (after SCMM is deployed and ports are known) + } + config.scm.scmManager.gitOpsUsername = "${config.application.namePrefix}gitops" + + if (config.scm.gitlab.url) { + config.scm.scmProviderType = ScmProviderType.GITLAB + config.scm.scmManager = null + if (!config.scm.gitlab.password || !config.scm.gitlab.parentGroupId) { + throw new RuntimeException('GitLab configuration incomplete: please provide both password (PAT) and parentGroupId') + } + } + + } + + //Retrieves the appropriate SCM for cluster resources depending on whether the environment is multi-tenant or not. + GitProvider getResourcesScm() { + if (central) { + return central + } else if (tenant) { + return tenant + } else { + throw new IllegalStateException("No SCM provider found.") + } + } + + @Override + void enable() { + //TenantSCM + switch (config.scm.scmProviderType) { + case ScmProviderType.GITLAB: + this.tenant = new Gitlab(this.config, this.config.scm.gitlab) + break + case ScmProviderType.SCM_MANAGER: + def prefixedNamespace = "${config.application.namePrefix}scm-manager".toString() + config.scm.scmManager.namespace = prefixedNamespace + this.tenant = new ScmManager(this.config, config.scm.scmManager, deployer, k8sClient, networkingUtils, true) + // this.tenant.setup() setup will be here in future + break + default: + throw new IllegalArgumentException("Unsupported SCM provider found in TenantSCM") + } + + if (config.multiTenant.useDedicatedInstance) { + switch (config.multiTenant.scmProviderType) { + case ScmProviderType.GITLAB: + this.central = new Gitlab(this.config, this.config.multiTenant.gitlab) + break + case ScmProviderType.SCM_MANAGER: + this.central = new ScmManager(this.config, config.multiTenant.scmManager, deployer, k8sClient, networkingUtils) + break + default: + throw new IllegalArgumentException("Unsupported SCM-Central provider: ${config.scm.scmProviderType}") + } + } + + //can be removed if we combine argocd and cluster-resources + final String namePrefix = (config?.application?.namePrefix ?: "").trim() + if (this.central) { + setupRepos(this.central, namePrefix) + setupRepos(this.tenant, namePrefix) + } else { + setupRepos(this.tenant, namePrefix) + } + } + + static void setupRepos(GitProvider gitProvider, String namePrefix = "") { + gitProvider.createRepository(withOrgPrefix(namePrefix, "argocd/cluster-resources"), + "GitOps repo for basic cluster-resources") + } + + /** + * Adds a prefix to the group/namespace part (before the first '/'): + * Example: "argocd/argocd" + "foo-" => "foo-argocd/argocd"*/ + static String withOrgPrefix(String prefix, String repoPath) { + if (!prefix) return repoPath + return prefix + repoPath + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy index 473cf2dca..fefe61c20 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy @@ -12,184 +12,181 @@ import com.cloudogu.gitops.git.providers.scmmanager.api.Repository import com.cloudogu.gitops.git.providers.scmmanager.api.ScmManagerApiClient import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.NetworkingUtils + import groovy.util.logging.Slf4j + import retrofit2.Response @Slf4j class ScmManager implements GitProvider { - ScmManagerUrlResolver urls - ScmManagerApiClient apiClient - ScmManagerConfig scmmConfig - - NetworkingUtils networkingUtils - HelmStrategy helmStrategy - K8sClient k8sClient - Config config - ScmManagerSetup scmManagerSetup - - ScmManager(Config config, ScmManagerConfig scmmConfig, HelmStrategy helmStrategy, K8sClient k8sClient, NetworkingUtils networkingUtils, Boolean installNeeded = false) { - this.scmmConfig = scmmConfig - this.config = config - this.helmStrategy = helmStrategy - this.k8sClient = k8sClient - this.networkingUtils = networkingUtils - init(installNeeded) - } - - void init(installNeeded) { - // --- Init Setup --- - if (this.scmmConfig.internal && installNeeded) { - this.scmManagerSetup = new ScmManagerSetup(this) - this.scmManagerSetup.setupHelm() - this.urls = new ScmManagerUrlResolver(this.config, this.scmmConfig, this.k8sClient, this.networkingUtils) - this.apiClient = new ScmManagerApiClient(this.urls.clientApiBase().toString(), this.scmmConfig.credentials, this.config.application.insecure) - this.scmManagerSetup.waitForScmmAvailable() - this.scmManagerSetup.configure() - } else { - this.urls = new ScmManagerUrlResolver(this.config, this.scmmConfig, this.k8sClient, this.networkingUtils) - this.apiClient = new ScmManagerApiClient(this.urls.clientApiBase().toString(), this.scmmConfig.credentials, this.config.application.insecure) - } - } - - // --- Git operations --- - @Override - boolean createRepository(String repoTarget, String description, boolean initialize) { - def repoNamespace = repoTarget.split('/', 2)[0] - def repoName = repoTarget.split('/', 2)[1] - def repo = new Repository(repoNamespace, repoName, description ?: "") - Response response = apiClient.repositoryApi().create(repo, initialize).execute() - return handle201or409(response, "Repository ${repoNamespace}/${repoName}") - } - - @Override - void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) { - def repoNamespace = repoTarget.split('/', 2)[0] - def repoName = repoTarget.split('/', 2)[1] - - boolean isGroup = (scope == Scope.GROUP) - Permission.Role scmManagerRole = mapToScmManager(role) - def permission = new Permission(principal, scmManagerRole, isGroup) - - Response response = apiClient.repositoryApi().createPermission(repoNamespace, repoName, permission).execute() - handle201or409(response, "Permission on ${repoNamespace}/${repoName}") - } - - @Override - Credentials getCredentials() { - return this.scmmConfig.credentials - } - - - @Override - String getGitOpsUsername() { - return scmmConfig.gitOpsUsername - } - - // --- In-cluster / Endpoints --- - /** In-cluster base …/scm (without trailing slash) */ - @Override - String getUrl() { - return urls.inClusterBase().toString() - } - - /** In-cluster repo prefix: …/scm//[] */ - @Override - String repoPrefix() { - return urls.inClusterRepoPrefix() - } - - - /** …/scm/// */ - @Override - String repoUrl(String repoTarget, RepoUrlScope scope) { - switch (scope) { - case RepoUrlScope.CLIENT: - return urls.clientRepoUrl(repoTarget) - case RepoUrlScope.IN_CLUSTER: - return urls.inClusterRepoUrl(repoTarget) - default: - return urls.inClusterRepoUrl(repoTarget) - } - } - - @Override - String getProtocol() { - return urls.inClusterBase().scheme // e.g. "http" - } - - @Override - String getHost() { - return urls.inClusterBase().host // e.g. "scmm.ns.svc.cluster.local" - } - - /** …/scm/api/v2/metrics/prometheus — client-side, typically scraped externally */ - @Override - URI prometheusMetricsEndpoint() { - return urls.prometheusEndpoint() - } - - /** - * No-op by design. Not used: ScmmDestructionHandler deletes repositories via ScmManagerApiClient. - * Kept for interface compatibility only. */ - @Override - void deleteRepository(String namespace, String repository, boolean prefixNamespace) { - // intentionally left blank - } - - /** - * No-op by design. Not used: ScmmDestructionHandler deletes users via ScmManagerApiClient. - * Kept for interface compatibility only. */ - @Override - void deleteUser(String name) { - // intentionally left blank - } - - /** - * No-op by design. Default branch management is not implemented via this abstraction. - * Kept for interface compatibility only. - */ - @Override - void setDefaultBranch(String repoTarget, String branch) { - // intentionally left blank - } - - // --- helpers --- - private static Permission.Role mapToScmManager(AccessRole role) { - switch (role) { - case AccessRole.READ: return Permission.Role.READ - case AccessRole.WRITE: return Permission.Role.WRITE - case AccessRole.MAINTAIN: - // SCM-manager doesn't know MAINTAIN -> downgrade to WRITE - log.warn("SCM-Manager: Mapping MAINTAIN → WRITE") - return Permission.Role.WRITE - case AccessRole.ADMIN: return Permission.Role.OWNER - case AccessRole.OWNER: return Permission.Role.OWNER - } - } - - private static boolean handle201or409(Response response, String what) { - int code = response.code() - if (code == 409) { - log.debug("${what} already exists — ignoring (HTTP 409)") - return false - } else if (code != 201) { - throw new RuntimeException("Could not create ${what}" + - "HTTP Details: ${response.code()} ${response.message()}: ${response.errorBody().string()}") - } - return true// because its created - } - - /** Test-only constructor (package-private on purpose). */ - ScmManager(Config config, ScmManagerConfig scmmConfig, - ScmManagerUrlResolver urls, - ScmManagerApiClient apiClient) { - this.scmmConfig = Objects.requireNonNull(scmmConfig, "scmmConfig must not be null") - this.urls = Objects.requireNonNull(urls, "urls must not be null") - this.apiClient = apiClient ?: new ScmManagerApiClient( - urls.clientApiBase().toString(), - scmmConfig.credentials, - Objects.requireNonNull(config, "config must not be null").application.insecure - ) - } + ScmManagerUrlResolver urls + ScmManagerApiClient apiClient + ScmManagerConfig scmmConfig + + NetworkingUtils networkingUtils + HelmStrategy helmStrategy + K8sClient k8sClient + Config config + ScmManagerSetup scmManagerSetup + + ScmManager(Config config, ScmManagerConfig scmmConfig, HelmStrategy helmStrategy, K8sClient k8sClient, NetworkingUtils networkingUtils, Boolean installNeeded = false) { + this.scmmConfig = scmmConfig + this.config = config + this.helmStrategy = helmStrategy + this.k8sClient = k8sClient + this.networkingUtils = networkingUtils + init(installNeeded) + } + + void init(installNeeded) { + // --- Init Setup --- + if (this.scmmConfig.internal && installNeeded) { + this.scmManagerSetup = new ScmManagerSetup(this) + this.scmManagerSetup.setupHelm() + this.urls = new ScmManagerUrlResolver(this.config, this.scmmConfig, this.k8sClient, this.networkingUtils) + this.apiClient = new ScmManagerApiClient(this.urls.clientApiBase().toString(), this.scmmConfig.credentials, this.config.application.insecure) + this.scmManagerSetup.waitForScmmAvailable() + this.scmManagerSetup.configure() + } else { + this.urls = new ScmManagerUrlResolver(this.config, this.scmmConfig, this.k8sClient, this.networkingUtils) + this.apiClient = new ScmManagerApiClient(this.urls.clientApiBase().toString(), this.scmmConfig.credentials, this.config.application.insecure) + } + } + + // --- Git operations --- + @Override + boolean createRepository(String repoTarget, String description, boolean initialize) { + def repoNamespace = repoTarget.split('/', 2)[0] + def repoName = repoTarget.split('/', 2)[1] + def repo = new Repository(repoNamespace, repoName, description ?: "") + Response response = apiClient.repositoryApi().create(repo, initialize).execute() + return handle201or409(response, "Repository ${repoNamespace}/${repoName}") + } + + @Override + void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) { + def repoNamespace = repoTarget.split('/', 2)[0] + def repoName = repoTarget.split('/', 2)[1] + + boolean isGroup = (scope == Scope.GROUP) + Permission.Role scmManagerRole = mapToScmManager(role) + def permission = new Permission(principal, scmManagerRole, isGroup) + + Response response = apiClient.repositoryApi().createPermission(repoNamespace, repoName, permission).execute() + handle201or409(response, "Permission on ${repoNamespace}/${repoName}") + } + + @Override + Credentials getCredentials() { + return this.scmmConfig.credentials + } + + @Override + String getGitOpsUsername() { + return scmmConfig.gitOpsUsername + } + + // --- In-cluster / Endpoints --- + /** In-cluster base …/scm (without trailing slash) */ + @Override + String getUrl() { + return urls.inClusterBase().toString() + } + + /** In-cluster repo prefix: …/scm//[] */ + @Override + String repoPrefix() { + return urls.inClusterRepoPrefix() + } + + /** …/scm/// */ + @Override + String repoUrl(String repoTarget, RepoUrlScope scope) { + switch (scope) { + case RepoUrlScope.CLIENT: + return urls.clientRepoUrl(repoTarget) + case RepoUrlScope.IN_CLUSTER: + return urls.inClusterRepoUrl(repoTarget) + default: + return urls.inClusterRepoUrl(repoTarget) + } + } + + @Override + String getProtocol() { + return urls.inClusterBase().scheme // e.g. "http" + } + + @Override + String getHost() { + return urls.inClusterBase().host // e.g. "scmm.ns.svc.cluster.local" + } + + /** …/scm/api/v2/metrics/prometheus — client-side, typically scraped externally */ + @Override + URI prometheusMetricsEndpoint() { + return urls.prometheusEndpoint() + } + + /** + * No-op by design. Not used: ScmmDestructionHandler deletes repositories via ScmManagerApiClient. + * Kept for interface compatibility only. */ + @Override + void deleteRepository(String namespace, String repository, boolean prefixNamespace) { + // intentionally left blank + } + + /** + * No-op by design. Not used: ScmmDestructionHandler deletes users via ScmManagerApiClient. + * Kept for interface compatibility only. */ + @Override + void deleteUser(String name) { + // intentionally left blank + } + + /** + * No-op by design. Default branch management is not implemented via this abstraction. + * Kept for interface compatibility only.*/ + @Override + void setDefaultBranch(String repoTarget, String branch) { + // intentionally left blank + } + + // --- helpers --- + private static Permission.Role mapToScmManager(AccessRole role) { + switch (role) { + case AccessRole.READ: return Permission.Role.READ + case AccessRole.WRITE: return Permission.Role.WRITE + case AccessRole.MAINTAIN: + // SCM-manager doesn't know MAINTAIN -> downgrade to WRITE + log.warn("SCM-Manager: Mapping MAINTAIN → WRITE") + return Permission.Role.WRITE + case AccessRole.ADMIN: return Permission.Role.OWNER + case AccessRole.OWNER: return Permission.Role.OWNER + } + } + + private static boolean handle201or409(Response response, String what) { + int code = response.code() + if (code == 409) { + log.debug("${what} already exists — ignoring (HTTP 409)") + return false + } else if (code != 201) { + throw new RuntimeException("Could not create ${what}" + "HTTP Details: ${response.code()} ${response.message()}: ${response.errorBody().string()}") + } + return true // because its created + } + + /** Test-only constructor (package-private on purpose). */ + ScmManager( + Config config, ScmManagerConfig scmmConfig, + ScmManagerUrlResolver urls, + ScmManagerApiClient apiClient) { + this.scmmConfig = Objects.requireNonNull(scmmConfig, "scmmConfig must not be null") + this.urls = Objects.requireNonNull(urls, "urls must not be null") + this.apiClient = apiClient ?: new ScmManagerApiClient(urls.clientApiBase().toString(), + scmmConfig.credentials, + Objects.requireNonNull(config, "config must not be null").application.insecure) + } } \ No newline at end of file From fac1c04d6e6a2566c231310d2c8d5391e1181efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Tue, 7 Apr 2026 11:08:19 +0200 Subject: [PATCH 04/10] deployment changed --- .../argocd/applications/applications.ftl.yaml | 24 + .../apps/argocd/applications/argocd.ftl.yaml | 34 ++ .../projects/cluster-resources.ftl.yaml | 1 + .../gitops/features/argocd/ArgoCD.groovy | 12 +- .../ArgoCdApplicationStrategy.groovy | 256 ++++----- .../com/cloudogu/gitops/git/GitRepo.groovy | 532 +++++++++--------- 6 files changed, 446 insertions(+), 413 deletions(-) create mode 100644 argocd/cluster-resources/apps/argocd/applications/applications.ftl.yaml create mode 100644 argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml diff --git a/argocd/cluster-resources/apps/argocd/applications/applications.ftl.yaml b/argocd/cluster-resources/apps/argocd/applications/applications.ftl.yaml new file mode 100644 index 000000000..fd4e8e6f6 --- /dev/null +++ b/argocd/cluster-resources/apps/argocd/applications/applications.ftl.yaml @@ -0,0 +1,24 @@ +# This is the root Applications, which manages all other applications with the app-of-apps-pattern. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: applications + namespace: ${config.application.namePrefix}argocd +# finalizer disabled, because otherwise everything under this Application would be deleted as well, if this Application is deleted by accident +# finalizers: +# - resources-finalizer.argocd.argoproj.io +spec: + destination: + server: https://kubernetes.default.svc + namespace: ${config.application.namePrefix}argocd + project: argocd + source: + path: apps/argocd/applications + repoURL: ${scm.repoUrl}argocd/cluster-resources.git + targetRevision: main + directory: + recurse: true + syncPolicy: + automated: + prune: false # is set to false to prevent projects to be deleted by accident + selfHeal: true \ No newline at end of file diff --git a/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml b/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml new file mode 100644 index 000000000..59a122807 --- /dev/null +++ b/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: "argoproj.io/v1alpha1" +kind: "Application" +metadata: + name: "argocd" + namespace: ${config.application.namePrefix}argocd +spec: + destination: + server: "https://kubernetes.default.svc" + namespace: ${config.application.namePrefix}argocd + project: "argocd" + sources: + - repoURL: ${config.features.argocd.helm.repoURL} + chart: ${config.features.argocd.helm.chart} + targetRevision: ${config.features.argocd.helm.version} + helm: + releaseName: ${config.application.namePrefix}argocd + valueFiles: + - "$values/apps/argocd/argocd-gop-helm.yaml" + - "$values/apps/argocd/argocd-user-values.yaml" + ignoreMissingValueFiles: true + - repoURL: ${scm.repoUrl}argocd/cluster-resources.git + targetRevision: "main" + ref: "values" + path: "apps/argocd/argocd" + directory: + recurse: true + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - "ServerSideApply=true" + - "CreateNamespace=true" \ No newline at end of file diff --git a/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml b/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml index 1f492dfe8..3bb873f4f 100644 --- a/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml +++ b/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml @@ -31,6 +31,7 @@ spec: - https://charts.jetstack.io - https://charts.jenkins.io - https://argoproj.github.io/argo-helm + - https://twuni.github.io/docker-registry.helm # allow to only see application resources from the specified namespace diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy index 3c842dc18..9a932b854 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy @@ -141,7 +141,7 @@ class ArgoCD extends Feature { } else { // Bootstrap root application k8sClient.applyYaml(Path.of(clusterResourcesRepo.projectsDir(), "argocd.yaml").toString()) - k8sClient.applyYaml(Path.of(clusterResourcesRepo.applicationsDir(), "argocd.yaml").toString()) + k8sClient.applyYaml(Path.of(clusterResourcesRepo.applicationsDir(), "applications.yaml").toString()) } // Delete helm-argo secrets to decouple from helm. @@ -206,14 +206,18 @@ rm -Rf ../argocd-operator/ } private void deployWithHelm() { - addHelmValuesData('argocd', [host: config.features.argocd.url ? new URL(config.features.argocd.url).host : '']) - deployHelmChart(this.namespace, this.namespace, namespace, config.features.argocd.helm, HELM_VALUES_PATH, config, true) + + helmClient.addRepo('argo', this.config.features.argocd.helm.repoURL) + helmClient.upgrade('argocd', "argo/argo-cd", [namespace: namespace]) + + // addHelmValuesData('argocd', [host: config.features.argocd.url ? new URL(config.features.argocd.url).host : '']) + // deployHelmChart(this.namespace, this.namespace, namespace, config.features.argocd.helm, HELM_VALUES_PATH, config, true) + log.debug("Setting new argocd admin password") // Set admin password imperatively here instead of values.yaml, because we don't want it to show in git repo String bcryptArgoCDPassword = BCrypt.hashpw(password, BCrypt.gensalt(4)) k8sClient.patch('secret', 'argocd-secret', namespace, [stringData: ['admin.password': bcryptArgoCDPassword]]) - } // The ArgoCD instance installed via an operator only manages its deployment namespace. diff --git a/src/main/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategy.groovy b/src/main/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategy.groovy index a86d2ab1e..51b89c7d0 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategy.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategy.groovy @@ -5,149 +5,127 @@ import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.utils.FileSystemUtils -import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator -import com.fasterxml.jackson.dataformat.yaml.YAMLMapper -import groovy.util.logging.Slf4j -import jakarta.inject.Singleton import java.nio.file.Path +import jakarta.inject.Singleton +import groovy.util.logging.Slf4j + +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper @Singleton @Slf4j class ArgoCdApplicationStrategy implements DeploymentStrategy { - private FileSystemUtils fileSystemUtils - private Config config - private final GitRepoFactory gitRepoProvider - - private GitHandler gitHandler - - ArgoCdApplicationStrategy( - Config config, - FileSystemUtils fileSystemUtils, - GitRepoFactory gitRepoProvider, - GitHandler gitHandler - ) { - this.gitRepoProvider = gitRepoProvider - this.fileSystemUtils = fileSystemUtils - this.config = config - this.gitHandler = gitHandler - } - - @Override - @SuppressWarnings('GroovyGStringKey') - // Using dynamic strings as keys seems an easy to read way to avoid more ifs - void deployFeature(String repoURL, String repoName, String chartOrPath, String version, String namespace, - String releaseName, Path helmValuesPath, RepoType repoType) { - log.trace("Deploying helm chart via ArgoCD: ${releaseName}. Reading values from ${helmValuesPath}") - def namePrefix = config.application.namePrefix - def shallCreateNamespace = config.features['argocd']['operator'] ? "CreateNamespace=false" : "CreateNamespace=true" - - GitRepo clusterResourcesRepo = gitRepoProvider.getRepo('argocd/cluster-resources', this.gitHandler.resourcesScm) - clusterResourcesRepo.cloneRepo() - - String project = "cluster-resources" - String namespaceName = "${namePrefix}argocd" - String featureName = repoName - //DedicatedInstances - if (config.multiTenant.useDedicatedInstance) { - repoName = "${config.application.namePrefix}${repoName}" - namespaceName = "${config.multiTenant.centralArgocdNamespace}" - project = config.application.namePrefix.replaceFirst(/-$/, "") - } - - // Feature-Name -> Ordner under apps/ - String featurePath = "apps/${featureName}" - - - String valuesRelPath = "${featurePath}/${featureName}-gop-helm.yaml" // relative to repo-root - // inline values from tmpHelmValues file into ArgoCD Application YAML - def inlineValues = helmValuesPath.toFile().text - clusterResourcesRepo.writeFile(valuesRelPath, inlineValues) - - //GOP should not overwrite this file - String userValuesRelPath = "${featurePath}/${featureName}-user-values.yaml" - clusterResourcesRepo.writeFile(userValuesRelPath, "") - - // 1) helm source (external chart source) - def helmSource = [ - repoURL : repoURL, - (chooseKeyChartOrPath(repoType)) : chartOrPath, - targetRevision : version, - helm : [ - releaseName: releaseName, - valueFiles : [ - "\$values/${valuesRelPath}".toString(), - "\$values/${userValuesRelPath}".toString() - ], - ignoreMissingValueFiles: true - ] - ] - - // 2) Git source for values - // - repoURL: cluster-resources repo - // - ref: values → used in valueFiles as $values - // - path: apps/ → additional manifests - def featureRepoUrl = "${clusterResourcesRepo.gitProvider.repoPrefix()}argocd/cluster-resources.git".toString() - def gitSource = [ - repoURL : featureRepoUrl, - targetRevision: "main", - ref : "values", - path : featurePath, - directory : [recurse: true] - ] - - def sources = [helmSource, gitSource] - - // Prepare ArgoCD Application YAML - def yamlMapper = YAMLMapper.builder() - .enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE) - .build() - - def yamlResult = yamlMapper.writeValueAsString([ - apiVersion: "argoproj.io/v1alpha1", - kind : "Application", - metadata : [ - name : repoName, - namespace: namespaceName - ], - spec : [ - destination: [ - server : "https://kubernetes.default.svc", - namespace: namespace - ], - project : project, - sources : sources, - syncPolicy : [ - automated : [ - prune : true, - selfHeal: true - ], - syncOptions: [ - // So that we can apply very large resources (e.g. prometheus CRD) - "ServerSideApply=true", - // Create namespaces for helm charts (while not using the argocd-operater mode) - shallCreateNamespace - ] - ] - ] - ]) - - String appManifestPath="apps/argocd/applications/${releaseName}.yaml" - clusterResourcesRepo.writeFile(appManifestPath, yamlResult) - - log.debug("Deploying helm release ${releaseName} basing on chart ${chartOrPath} from ${repoURL}, version " + - "${version}, into namespace ${namespace}. Using Argo CD application:\n${yamlResult}") - - clusterResourcesRepo.commitAndPush("Added $repoName/$chartOrPath to ArgoCD") - } - - String chooseKeyChartOrPath(RepoType repoType) { - switch (repoType) { - case RepoType.HELM: 'chart' - break - case RepoType.GIT: 'path' - break - default: throw new RuntimeException("Repo type ${repoType} not implemented for ${this.class.simpleName}") - } - } + private FileSystemUtils fileSystemUtils + private Config config + private final GitRepoFactory gitRepoProvider + + private GitHandler gitHandler + + ArgoCdApplicationStrategy( + Config config, + FileSystemUtils fileSystemUtils, + GitRepoFactory gitRepoProvider, + GitHandler gitHandler) { + this.gitRepoProvider = gitRepoProvider + this.fileSystemUtils = fileSystemUtils + this.config = config + this.gitHandler = gitHandler + } + + @Override + // Using dynamic strings as keys seems an easy to read way to avoid more ifs + void deployFeature( + String repoURL, String repoName, String chartOrPath, String version, String namespace, + String releaseName, Path helmValuesPath, RepoType repoType) { + log.trace("Deploying helm chart via ArgoCD: ${releaseName}. Reading values from ${helmValuesPath}") + def namePrefix = config.application.namePrefix + def shallCreateNamespace = config.features['argocd']['operator'] ? "CreateNamespace=false" : "CreateNamespace=true" + + GitRepo clusterResourcesRepo = gitRepoProvider.getRepo('argocd/cluster-resources', this.gitHandler.resourcesScm) + clusterResourcesRepo.cloneRepo() + + String project = "cluster-resources" + String namespaceName = "${namePrefix}argocd" + String featureName = repoName + + //DedicatedInstances + if (config.multiTenant.useDedicatedInstance) { + repoName = "${config.application.namePrefix}${repoName}" + namespaceName = "${config.multiTenant.centralArgocdNamespace}" + project = config.application.namePrefix.replaceFirst(/-$/, "") + } + + // Feature-Name -> Ordner under apps/ + String featurePath = "apps/${featureName}" + + String valuesRelPath = "${featurePath}/${featureName}-gop-helm.yaml" + // relative to repo-root + // inline values from tmpHelmValues file into ArgoCD Application YAML + def inlineValues = helmValuesPath.toFile().text + clusterResourcesRepo.writeFile(valuesRelPath, inlineValues) + + //GOP should not overwrite this file + String userValuesRelPath = "${featurePath}/${featureName}-user-values.yaml" + clusterResourcesRepo.writeFile(userValuesRelPath, "") + + // 1) helm source (external chart source) + def helmSource = [repoURL : repoURL, + (chooseKeyChartOrPath(repoType)): chartOrPath, + targetRevision : version, + helm : [releaseName : releaseName, + valueFiles : ["\$values/${valuesRelPath}".toString(), + "\$values/${userValuesRelPath}".toString()], + ignoreMissingValueFiles: true]] + + // 2) Git source for values + // - repoURL: cluster-resources repo + // - ref: values → used in valueFiles as $values + // - path: apps/ → additional manifests + def featureRepoUrl = "${clusterResourcesRepo.gitProvider.repoPrefix()}argocd/cluster-resources.git".toString() + def gitSource = [repoURL : featureRepoUrl, + targetRevision: "main", + ref : "values", + path : featurePath, + directory : [recurse: true]] + + def sources = [helmSource, gitSource] + + // Prepare ArgoCD Application YAML + def yamlMapper = YAMLMapper.builder() + .enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE) + .build() + + def yamlResult = yamlMapper.writeValueAsString([apiVersion: "argoproj.io/v1alpha1", + kind : "Application", + metadata : [name : repoName, + namespace: namespaceName], + spec : [destination: [server : "https://kubernetes.default.svc", + namespace: namespace], + project : project, + sources : sources, + syncPolicy : [automated : [prune : true, + selfHeal: true], + syncOptions: [// So that we can apply very large resources (e.g. prometheus CRD) + "ServerSideApply=true", + // Create namespaces for helm charts (while not using the argocd-operater mode) + shallCreateNamespace]]]]) + + String appManifestPath = "apps/argocd/applications/${releaseName}.yaml" + clusterResourcesRepo.writeFile(appManifestPath, yamlResult, false) + + log.debug("Deploying helm release ${releaseName} basing on chart ${chartOrPath} from ${repoURL}, version " + "${version}, into namespace ${namespace}. Using Argo CD application:\n${yamlResult}") + + clusterResourcesRepo.commitAndPush("Added $repoName/$chartOrPath to ArgoCD") + } + + String chooseKeyChartOrPath(RepoType repoType) { + switch (repoType) { + case RepoType.HELM: 'chart' + break + case RepoType.GIT: 'path' + break + default: throw new RuntimeException("Repo type ${repoType} not implemented for ${this.class.simpleName}") + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/GitRepo.groovy b/src/main/groovy/com/cloudogu/gitops/git/GitRepo.groovy index fd0001fc6..26b3e15df 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/GitRepo.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/GitRepo.groovy @@ -9,7 +9,9 @@ import com.cloudogu.gitops.git.providers.RepoUrlScope import com.cloudogu.gitops.git.providers.Scope import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.TemplatingEngine + import groovy.util.logging.Slf4j + import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.ListBranchCommand import org.eclipse.jgit.api.PushCommand @@ -27,275 +29,265 @@ import org.eclipse.jgit.treewalk.filter.PathFilter @Slf4j class GitRepo { - static final String NAMESPACE_3RD_PARTY_DEPENDENCIES = '3rd-party-dependencies' - - private final Config config - public GitProvider gitProvider - private final FileSystemUtils fileSystemUtils - - private final String repoTarget - private final boolean insecure - private final String gitName - private final String gitEmail - - private Git gitMemoization - private final String absoluteLocalRepoTmpDir - - GitRepo(Config config, - GitProvider gitProvider, - String repoTarget, - FileSystemUtils fileSystemUtils) { - def tmpDir = File.createTempDir() - tmpDir.deleteOnExit() - this.absoluteLocalRepoTmpDir = tmpDir.absolutePath - this.config = config - this.gitProvider = gitProvider - this.fileSystemUtils = fileSystemUtils - - this.repoTarget = "${config.application.namePrefix}${repoTarget}" - - this.insecure = config.application.insecure - this.gitName = config.application.gitName - this.gitEmail = config.application.gitEmail - } - - String getRepoTarget() { - return repoTarget - } - - boolean createRepositoryAndSetPermission(String description, boolean initialize = true) { - def isNewRepo = this.gitProvider.createRepository(repoTarget, description, initialize) - if (gitProvider.getGitOpsUsername()) { - gitProvider.setRepositoryPermission( - repoTarget, - gitProvider.getGitOpsUsername(), - AccessRole.WRITE, - Scope.USER - ) - } - return isNewRepo - - } - - String getAbsoluteLocalRepoTmpDir() { - return absoluteLocalRepoTmpDir - } - - void cloneRepo() { - def cloneUrl = getGitRepositoryUrl() - log.debug("Cloning ${repoTarget}, Origin: ${cloneUrl}") - Git.cloneRepository() - .setURI(cloneUrl) - .setDirectory(new File(absoluteLocalRepoTmpDir)) - .setCredentialsProvider(getCredentialProvider()) - .call() - } - - void commitAndPush(String message, String tag) { - commitAndPush(message, tag, 'HEAD:refs/heads/main') - } - - - void commitAndPush(String commitMessage, String tag, String refSpec) { - log.debug("Adding files to ${repoTarget}") - def git = getGit() - git.add().addFilepattern(".").call() - - if (git.status().call().hasUncommittedChanges()) { - log.debug("Commiting ${repoTarget}") - git.commit() - .setSign(false) - .setMessage(commitMessage) - .setAuthor(gitName, gitEmail) - .setCommitter("${gitName} - GOP v${Version.NAME.split(',')[0].replace('(','')}", gitEmail) //parsing the Versions from the full text in Version.Name. In local Dev there is no Tag->Version is empty - .call() - - def pushCommand = createPushCommand(refSpec) - - if (tag) { - log.debug("Setting tag '${tag}' on repo: ${repoTarget}") - // Delete existing tags first to get idempotence - git.tagDelete().setTags(tag).call() - git.tag() - .setName(tag) - .call() - pushCommand.setPushTags() - } - - log.debug("Pushing repo: ${repoTarget}, refSpec: ${refSpec}") - pushCommand.call() - } else { - log.debug("No changes after add, nothing to commit or push on repo: ${repoTarget}") - } - } - - - void commitAndPush(String commitMessage) { - commitAndPush(commitMessage, null, 'HEAD:refs/heads/main') - } - /** - * Push all refs, i.e. all tags and branches - */ - - void pushAll(boolean force) { - createPushCommand('refs/*:refs/*').setForce(force).call() - } - - - void pushRef(String ref, boolean force) { - pushRef(ref, ref, force) - } - - - void pushRef(String ref, String targetRef, boolean force) { - createPushCommand("${ref}:${targetRef}").setForce(force).call() - } - - - /** - * Delete all files in this repository - */ - void clearRepo() { - fileSystemUtils.deleteFilesExcept(new File(absoluteLocalRepoTmpDir), ".git") - } - - - void copyDirectoryContents(String srcDir) { - copyDirectoryContents(srcDir, (FileFilter) null) - } - - - void copyDirectoryContents(String srcDir, FileFilter fileFilter) { - if (!srcDir) { - log.warn("Source directory is not defined. Nothing to copy?") - return - } - - log.debug("Initializing repo $repoTarget from $srcDir") - String absoluteSrcDirLocation = new File(srcDir).isAbsolute() - ? srcDir - : "${fileSystemUtils.getRootDir()}/${srcDir}" - fileSystemUtils.copyDirectory(absoluteSrcDirLocation, absoluteLocalRepoTmpDir, fileFilter) - } - - - void writeFile(String path, String content) { - def file = new File("$absoluteLocalRepoTmpDir/$path") - fileSystemUtils.createDirectory(file.parent) - file.createNewFile() - file.text = content - } - - void replaceTemplates(Map parameters) { - new TemplatingEngine().replaceTemplates(new File(absoluteLocalRepoTmpDir), parameters) - } - - String getGitRepositoryUrl() { - return this.gitProvider.repoUrl(repoTarget, RepoUrlScope.CLIENT) - } - - static boolean isCommit(File repoPath, String ref) { - if (!ref) { - return false - } - - try (Git git = Git.open(repoPath)) { - // Get all branch and tag names - def allRefs = [] - - // Add all branch names (without refs/heads/ prefix) - git.branchList().call().each { branch -> - allRefs.add(branch.name.replaceFirst('refs/heads/', '')) - } - - // Add all tag names (without refs/tags/ prefix) - git.tagList().call().each { tag -> - allRefs.add(tag.name.replaceFirst('refs/tags/', '')) - } - - // If the ref matches any branch or tag name, it's not a commit hash - if (allRefs.contains(ref)) { - return false - } - - // If it's not a branch or tag, try to resolve it as a commit - def objectId = git.repository.resolve(ref) - return objectId != null - - } - } - - /** - * checks, if file exists in repo in some branch. - * @param pathToRepo - * @param filename - */ - static boolean existFileInSomeBranch(String repo, String filename) { - String filenameToSearch = filename - File repoPath = new File(repo + '/.git') - - try (def git = Git.open(repoPath)) { - List branches = git - .branchList() - .setListMode(ListBranchCommand.ListMode.ALL) - .call() - - for (Ref branch : branches) { - String branchName = branch.getName() - - ObjectId commitId = git.repository.resolve(branchName) - if (commitId == null) { - continue - } - try (RevWalk revWalk = new RevWalk(git.repository)) { - RevCommit commit = revWalk.parseCommit(commitId) - try (TreeWalk treeWalk = new TreeWalk(git.repository)) { - - treeWalk.addTree(commit.getTree()) - treeWalk.setFilter(PathFilter.create(filenameToSearch)) - - if (treeWalk.next()) { - log.debug("File ${filename} found in branch ${branchName}") - - return true - } - } - } - } - } - log.debug("File ${filename} not found in repository ${repoPath}") - return false - } - - static boolean isTag(File repo, String ref) { - if (!ref) { - return false - } - try (def git = Git.open(repo)) { - git.tagList().call().any { it.name.endsWith("/" + ref) || it.name == ref } - } - } - - private PushCommand createPushCommand(String refSpec) { - getGit() - .push() - .setRemote(getGitRepositoryUrl()) - .setRefSpecs(new RefSpec(refSpec)) - .setCredentialsProvider(getCredentialProvider()) - } - - private Git getGit() { - if (gitMemoization != null) { - return gitMemoization - } - - return gitMemoization = Git.open(new File(absoluteLocalRepoTmpDir)) - } - - private CredentialsProvider getCredentialProvider() { - def auth = this.gitProvider.getCredentials() - def passwordAuthentication = new UsernamePasswordCredentialsProvider(auth.username, auth.password) - return insecure ? new ChainingCredentialsProvider(new InsecureCredentialProvider(), passwordAuthentication) : passwordAuthentication - } + static final String NAMESPACE_3RD_PARTY_DEPENDENCIES = '3rd-party-dependencies' + + private final Config config + public GitProvider gitProvider + private final FileSystemUtils fileSystemUtils + + private final String repoTarget + private final boolean insecure + private final String gitName + private final String gitEmail + + private Git gitMemoization + private final String absoluteLocalRepoTmpDir + + GitRepo( + Config config, + GitProvider gitProvider, + String repoTarget, + FileSystemUtils fileSystemUtils) { + def tmpDir = File.createTempDir() + tmpDir.deleteOnExit() + this.absoluteLocalRepoTmpDir = tmpDir.absolutePath + this.config = config + this.gitProvider = gitProvider + this.fileSystemUtils = fileSystemUtils + + this.repoTarget = "${config.application.namePrefix}${repoTarget}" + + this.insecure = config.application.insecure + this.gitName = config.application.gitName + this.gitEmail = config.application.gitEmail + } + + String getRepoTarget() { + return repoTarget + } + + boolean createRepositoryAndSetPermission(String description, boolean initialize = true) { + def isNewRepo = this.gitProvider.createRepository(repoTarget, description, initialize) + if (gitProvider.getGitOpsUsername()) { + gitProvider.setRepositoryPermission(repoTarget, + gitProvider.getGitOpsUsername(), + AccessRole.WRITE, + Scope.USER) + } + return isNewRepo + + } + + String getAbsoluteLocalRepoTmpDir() { + return absoluteLocalRepoTmpDir + } + + void cloneRepo() { + def cloneUrl = getGitRepositoryUrl() + log.debug("Cloning ${repoTarget}, Origin: ${cloneUrl}") + Git.cloneRepository() + .setURI(cloneUrl) + .setDirectory(new File(absoluteLocalRepoTmpDir)) + .setCredentialsProvider(getCredentialProvider()) + .call() + } + + void commitAndPush(String message, String tag) { + commitAndPush(message, tag, 'HEAD:refs/heads/main') + } + + void commitAndPush(String commitMessage, String tag, String refSpec) { + log.debug("Adding files to ${repoTarget}") + def git = getGit() + git.add().addFilepattern(".").call() + + if (git.status().call().hasUncommittedChanges()) { + log.debug("Commiting ${repoTarget}") + git.commit() + .setSign(false) + .setMessage(commitMessage) + .setAuthor(gitName, gitEmail) + .setCommitter("${gitName} - GOP v${Version.NAME.split(',')[0].replace('(', '')}", gitEmail) //parsing the Versions from the full text in Version.Name. In local Dev there is no Tag->Version is empty + .call() + + def pushCommand = createPushCommand(refSpec) + + if (tag) { + log.debug("Setting tag '${tag}' on repo: ${repoTarget}") + // Delete existing tags first to get idempotence + git.tagDelete().setTags(tag).call() + git.tag() + .setName(tag) + .call() + pushCommand.setPushTags() + } + + log.debug("Pushing repo: ${repoTarget}, refSpec: ${refSpec}") + pushCommand.call() + } else { + log.debug("No changes after add, nothing to commit or push on repo: ${repoTarget}") + } + } + + void commitAndPush(String commitMessage) { + commitAndPush(commitMessage, null, 'HEAD:refs/heads/main') + } + + /** + * Push all refs, i.e. all tags and branches*/ + + void pushAll(boolean force) { + createPushCommand('refs/*:refs/*').setForce(force).call() + } + + void pushRef(String ref, boolean force) { + pushRef(ref, ref, force) + } + + void pushRef(String ref, String targetRef, boolean force) { + createPushCommand("${ref}:${targetRef}").setForce(force).call() + } + + /** + * Delete all files in this repository*/ + void clearRepo() { + fileSystemUtils.deleteFilesExcept(new File(absoluteLocalRepoTmpDir), ".git") + } + + void copyDirectoryContents(String srcDir) { + copyDirectoryContents(srcDir, (FileFilter) null) + } + + void copyDirectoryContents(String srcDir, FileFilter fileFilter) { + if (!srcDir) { + log.warn("Source directory is not defined. Nothing to copy?") + return + } + + log.debug("Initializing repo $repoTarget from $srcDir") + String absoluteSrcDirLocation = new File(srcDir).isAbsolute() ? srcDir : "${fileSystemUtils.getRootDir()}/${srcDir}" + fileSystemUtils.copyDirectory(absoluteSrcDirLocation, absoluteLocalRepoTmpDir, fileFilter) + } + + void writeFile(String path, String content, boolean overwrite = true) { + File file = new File("$absoluteLocalRepoTmpDir/$path") + fileSystemUtils.createDirectory(file.parent) + + if (!overwrite && file.exists()) { + return + } + + file.text = content + } + + void replaceTemplates(Map parameters) { + new TemplatingEngine().replaceTemplates(new File(absoluteLocalRepoTmpDir), parameters) + } + + String getGitRepositoryUrl() { + return this.gitProvider.repoUrl(repoTarget, RepoUrlScope.CLIENT) + } + + static boolean isCommit(File repoPath, String ref) { + if (!ref) { + return false + } + + try (Git git = Git.open(repoPath)) { + // Get all branch and tag names + def allRefs = [] + + // Add all branch names (without refs/heads/ prefix) + git.branchList().call().each { branch -> allRefs.add(branch.name.replaceFirst('refs/heads/', '')) + } + + // Add all tag names (without refs/tags/ prefix) + git.tagList().call().each { tag -> allRefs.add(tag.name.replaceFirst('refs/tags/', '')) + } + + // If the ref matches any branch or tag name, it's not a commit hash + if (allRefs.contains(ref)) { + return false + } + + // If it's not a branch or tag, try to resolve it as a commit + def objectId = git.repository.resolve(ref) + return objectId != null + + } + } + + /** + * checks, if file exists in repo in some branch. + * @param pathToRepo + * @param filename + */ + static boolean existFileInSomeBranch(String repo, String filename) { + String filenameToSearch = filename + File repoPath = new File(repo + '/.git') + + try (def git = Git.open(repoPath)) { + List branches = git + .branchList() + .setListMode(ListBranchCommand.ListMode.ALL) + .call() + + for (Ref branch : branches) { + String branchName = branch.getName() + + ObjectId commitId = git.repository.resolve(branchName) + if (commitId == null) { + continue + } + try (RevWalk revWalk = new RevWalk(git.repository)) { + RevCommit commit = revWalk.parseCommit(commitId) + try (TreeWalk treeWalk = new TreeWalk(git.repository)) { + + treeWalk.addTree(commit.getTree()) + treeWalk.setFilter(PathFilter.create(filenameToSearch)) + + if (treeWalk.next()) { + log.debug("File ${filename} found in branch ${branchName}") + + return true + } + } + } + } + } + log.debug("File ${filename} not found in repository ${repoPath}") + return false + } + + static boolean isTag(File repo, String ref) { + if (!ref) { + return false + } + try (def git = Git.open(repo)) { + git.tagList().call().any { it.name.endsWith("/" + ref) || it.name == ref } + } + } + + private PushCommand createPushCommand(String refSpec) { + getGit() + .push() + .setRemote(getGitRepositoryUrl()) + .setRefSpecs(new RefSpec(refSpec)) + .setCredentialsProvider(getCredentialProvider()) + } + + private Git getGit() { + if (gitMemoization != null) { + return gitMemoization + } + + return gitMemoization = Git.open(new File(absoluteLocalRepoTmpDir)) + } + + private CredentialsProvider getCredentialProvider() { + def auth = this.gitProvider.getCredentials() + def passwordAuthentication = new UsernamePasswordCredentialsProvider(auth.username, auth.password) + return insecure ? new ChainingCredentialsProvider(new InsecureCredentialProvider(), passwordAuthentication) : passwordAuthentication + } } \ No newline at end of file From 41598cf5a66ec5a3a0589f9dec984e3e5f5a5fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Tue, 14 Apr 2026 14:35:30 +0200 Subject: [PATCH 05/10] working so far --- .../projects/cluster-resources.ftl.yaml | 1 + .../GitopsPlaygroundCliMainScripted.groovy | 2 +- .../gitops/features/CertManager.groovy | 73 +-- .../features/ExternalSecretsOperator.groovy | 72 +-- .../cloudogu/gitops/features/Ingress.groovy | 71 +-- .../cloudogu/gitops/features/Jenkins.groovy | 1 - .../com/cloudogu/gitops/features/Mail.groovy | 80 ++-- .../gitops/features/Monitoring.groovy | 420 +++++++++--------- .../cloudogu/gitops/features/Registry.groovy | 2 +- .../com/cloudogu/gitops/features/Vault.groovy | 108 ++--- .../gitops/features/argocd/ArgoCD.groovy | 2 +- .../gitops/features/git/GitHandler.groovy | 18 +- .../providers/scmmanager/ScmManager.groovy | 11 +- .../scmmanager/ScmManagerSetup.groovy | 360 +++++++-------- .../kubernetes/api/K8sJavaApiClient.groovy | 142 +++--- 15 files changed, 678 insertions(+), 685 deletions(-) diff --git a/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml b/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml index 3bb873f4f..c4a08892e 100644 --- a/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml +++ b/argocd/cluster-resources/apps/argocd/projects/cluster-resources.ftl.yaml @@ -32,6 +32,7 @@ spec: - https://charts.jenkins.io - https://argoproj.github.io/argo-helm - https://twuni.github.io/docker-registry.helm + - https://packages.scm-manager.org/repository/helm-v2-releases/ # allow to only see application resources from the specified namespace diff --git a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy index 8bc19d0ff..84de8c26b 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy @@ -65,7 +65,7 @@ class GitopsPlaygroundCliMainScripted { httpClientFactory.okHttpClientJenkins(config)) context.registerSingleton(k8sClient) - GitHandler gitHandler = new GitHandler(config, helmStrategy, fileSystemUtils, k8sClient, networkingUtils) + GitHandler gitHandler = new GitHandler(config, fileSystemUtils, k8sClient, networkingUtils) Deployer deployer = new Deployer(config, new ArgoCdApplicationStrategy(config, fileSystemUtils, gitRepoFactory, gitHandler), helmStrategy) AirGappedUtils airGappedUtils = new AirGappedUtils(config, gitRepoFactory, fileSystemUtils, helmClient, gitHandler) diff --git a/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy b/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy index 854a7edaa..681d271c3 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy @@ -5,49 +5,50 @@ import com.cloudogu.gitops.FeatureWithImage import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.DeploymentStrategy import com.cloudogu.gitops.features.git.GitHandler +import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.AirGappedUtils import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.K8sClient -import groovy.util.logging.Slf4j + import io.micronaut.core.annotation.Order + import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton -@Order(160) +@Order(15) class CertManager extends Feature implements FeatureWithImage { - static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/cert-manager/templates/certManager-helm-values.ftl.yaml" - - final K8sClient k8sClient - final Config config - final String namespace = "${config.application.namePrefix}cert-manager" - - CertManager( - Config config, - FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer, - K8sClient k8sClient, - AirGappedUtils airGappedUtils, - GitHandler gitHandler - ) { - this.deployer = deployer - this.config = config - this.fileSystemUtils = fileSystemUtils - this.k8sClient = k8sClient - this.airGappedUtils = airGappedUtils - this.gitHandler = gitHandler - } - - @Override - boolean isEnabled() { - return config.features.certManager.active - } - - @Override - void enable() { - def helmConfig = config.features.certManager.helm - - deployHelmChart('cert-manager', 'cert-manager', namespace, helmConfig, HELM_VALUES_PATH, config) - } + static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/cert-manager/templates/certManager-helm-values.ftl.yaml" + + final K8sClient k8sClient + final Config config + final String namespace = "${config.application.namePrefix}cert-manager" + + CertManager( + Config config, + FileSystemUtils fileSystemUtils, + DeploymentStrategy deployer, + K8sClient k8sClient, + AirGappedUtils airGappedUtils, + GitHandler gitHandler) { + this.deployer = deployer + this.config = config + this.fileSystemUtils = fileSystemUtils + this.k8sClient = k8sClient + this.airGappedUtils = airGappedUtils + this.gitHandler = gitHandler + } + + @Override + boolean isEnabled() { + return config.features.certManager.active + } + + @Override + void enable() { + def helmConfig = config.features.certManager.helm + + deployHelmChart('cert-manager', 'cert-manager', namespace, helmConfig, HELM_VALUES_PATH, config) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/ExternalSecretsOperator.groovy b/src/main/groovy/com/cloudogu/gitops/features/ExternalSecretsOperator.groovy index 2aedc1eac..4ae974dd8 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/ExternalSecretsOperator.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/ExternalSecretsOperator.groovy @@ -5,49 +5,49 @@ import com.cloudogu.gitops.FeatureWithImage import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.DeploymentStrategy import com.cloudogu.gitops.features.git.GitHandler +import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.AirGappedUtils import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.K8sClient -import groovy.util.logging.Slf4j + import io.micronaut.core.annotation.Order -import jakarta.inject.Singleton +import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton -@Order(400) +@Order(35) class ExternalSecretsOperator extends Feature implements FeatureWithImage { - static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/external-secrets/templates/values.ftl.yaml" - - String namespace = "${config.application.namePrefix}secrets" - Config config - K8sClient k8sClient - - ExternalSecretsOperator( - Config config, - FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer, - K8sClient k8sClient, - AirGappedUtils airGappedUtils, - GitHandler gitHandler - ) { - this.deployer = deployer - this.config = config - this.fileSystemUtils = fileSystemUtils - this.k8sClient = k8sClient - this.airGappedUtils = airGappedUtils - this.gitHandler=gitHandler - } - - @Override - boolean isEnabled() { - return config.features.secrets.active - } - - @Override - void enable() { - def helmConfig = config.features.secrets.externalSecrets.helm - deployHelmChart('external-secrets-operator', 'external-secrets', namespace, helmConfig, HELM_VALUES_PATH, config) - } + static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/external-secrets/templates/values.ftl.yaml" + + String namespace = "${config.application.namePrefix}secrets" + Config config + K8sClient k8sClient + + ExternalSecretsOperator( + Config config, + FileSystemUtils fileSystemUtils, + DeploymentStrategy deployer, + K8sClient k8sClient, + AirGappedUtils airGappedUtils, + GitHandler gitHandler) { + this.deployer = deployer + this.config = config + this.fileSystemUtils = fileSystemUtils + this.k8sClient = k8sClient + this.airGappedUtils = airGappedUtils + this.gitHandler = gitHandler + } + + @Override + boolean isEnabled() { + return config.features.secrets.active + } + + @Override + void enable() { + def helmConfig = config.features.secrets.externalSecrets.helm + deployHelmChart('external-secrets-operator', 'external-secrets', namespace, helmConfig, HELM_VALUES_PATH, config) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy b/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy index 18967db5f..d6e1ee69b 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy @@ -5,48 +5,49 @@ import com.cloudogu.gitops.FeatureWithImage import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.DeploymentStrategy import com.cloudogu.gitops.features.git.GitHandler +import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.AirGappedUtils import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.K8sClient -import groovy.util.logging.Slf4j + import io.micronaut.core.annotation.Order + import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton -@Order(150) +@Order(5) class Ingress extends Feature implements FeatureWithImage { - static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/ingress/templates/ingress-helm-values.ftl.yaml" - - String namespace = "${config.application.namePrefix}" + config.features.ingress.ingressNamespace - Config config - K8sClient k8sClient - - Ingress( - Config config, - FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer, - K8sClient k8sClient, - AirGappedUtils airGappedUtils, - GitHandler gitHandler - ) { - this.deployer = deployer - this.config = config - this.fileSystemUtils = fileSystemUtils - this.k8sClient = k8sClient - this.airGappedUtils = airGappedUtils - this.gitHandler = gitHandler - } - - @Override - boolean isEnabled() { - return config.features.ingress.active - } - - @Override - void enable() { - def helmConfig = config.features.ingress.helm - deployHelmChart('traefik', 'traefik', namespace, helmConfig, HELM_VALUES_PATH, config) - } + static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/ingress/templates/ingress-helm-values.ftl.yaml" + + String namespace = "${config.application.namePrefix}" + config.features.ingress.ingressNamespace + Config config + K8sClient k8sClient + + Ingress( + Config config, + FileSystemUtils fileSystemUtils, + DeploymentStrategy deployer, + K8sClient k8sClient, + AirGappedUtils airGappedUtils, + GitHandler gitHandler) { + this.deployer = deployer + this.config = config + this.fileSystemUtils = fileSystemUtils + this.k8sClient = k8sClient + this.airGappedUtils = airGappedUtils + this.gitHandler = gitHandler + } + + @Override + boolean isEnabled() { + return config.features.ingress.active + } + + @Override + void enable() { + def helmConfig = config.features.ingress.helm + deployHelmChart('traefik', 'traefik', namespace, helmConfig, HELM_VALUES_PATH, config) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy b/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy index a8628b887..81317af61 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy @@ -25,7 +25,6 @@ import groovy.util.logging.Slf4j class Jenkins extends Feature { static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/jenkins/values.ftl.yaml" - String namespace private Config config private CommandExecutor commandExecutor diff --git a/src/main/groovy/com/cloudogu/gitops/features/Mail.groovy b/src/main/groovy/com/cloudogu/gitops/features/Mail.groovy index 859e050c1..0a2131955 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Mail.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Mail.groovy @@ -5,60 +5,60 @@ import com.cloudogu.gitops.FeatureWithImage import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.DeploymentStrategy import com.cloudogu.gitops.features.git.GitHandler +import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.AirGappedUtils import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.K8sClient -import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j + import io.micronaut.core.annotation.Order + import jakarta.inject.Singleton +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + import org.springframework.security.crypto.bcrypt.BCrypt @Slf4j @Singleton -@Order(200) +@Order(20) @CompileStatic class Mail extends Feature implements FeatureWithImage { - final static private String HELM_VALUES_PATH = 'argocd/cluster-resources/apps/mail/templates/mail-helm-values.ftl.yaml' - final private String password + final static private String HELM_VALUES_PATH = 'argocd/cluster-resources/apps/mail/templates/mail-helm-values.ftl.yaml' + final private String password - String namespace = "${config.application.namePrefix}monitoring" - Config config - K8sClient k8sClient + String namespace = "${config.application.namePrefix}monitoring" + Config config + K8sClient k8sClient - Mail( - Config config, - FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer, - K8sClient k8sClient, - AirGappedUtils airGappedUtils, - GitHandler gitHandler - ) { - this.deployer = deployer - this.config = config - this.password = this.config.application.password - this.k8sClient = k8sClient - this.fileSystemUtils = fileSystemUtils - this.airGappedUtils = airGappedUtils - this.gitHandler = gitHandler - } + Mail( + Config config, + FileSystemUtils fileSystemUtils, + DeploymentStrategy deployer, + K8sClient k8sClient, + AirGappedUtils airGappedUtils, + GitHandler gitHandler) { + this.deployer = deployer + this.config = config + this.password = this.config.application.password + this.k8sClient = k8sClient + this.fileSystemUtils = fileSystemUtils + this.airGappedUtils = airGappedUtils + this.gitHandler = gitHandler + } - @Override - boolean isEnabled() { - return config.features.mail.mailServer - } + @Override + boolean isEnabled() { + return config.features.mail.mailServer + } - @Override - void enable() { - String bcryptMailhogPassword = BCrypt.hashpw(password, BCrypt.gensalt(4)) + @Override + void enable() { + String bcryptMailhogPassword = BCrypt.hashpw(password, BCrypt.gensalt(4)) - addHelmValuesData('passwordCrypt', bcryptMailhogPassword) - addHelmValuesData('mail', [ - // Note that passing the URL object here leads to problems in Graal Native image, see Git history - host: config.features.mail.mailUrl ? new URL(config.features.mail.mailUrl).host : '', - ]) + addHelmValuesData('passwordCrypt', bcryptMailhogPassword) + addHelmValuesData('mail', [// Note that passing the URL object here leads to problems in Graal Native image, see Git history + host: config.features.mail.mailUrl ? new URL(config.features.mail.mailUrl).host : '',]) - deployHelmChart('mailhog', 'mailhog', namespace, config.features.mail.helm, HELM_VALUES_PATH, config) - } -} + deployHelmChart('mailhog', 'mailhog', namespace, config.features.mail.helm, HELM_VALUES_PATH, config) + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy b/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy index ab93d5e6c..d5add725f 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy @@ -8,229 +8,213 @@ import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.kubernetes.api.K8sClient -import com.cloudogu.gitops.utils.* -import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j +import com.cloudogu.gitops.utils.AirGappedUtils +import com.cloudogu.gitops.utils.FileSystemUtils +import com.cloudogu.gitops.utils.TemplatingEngine + import io.micronaut.core.annotation.Order -import jakarta.inject.Singleton + import java.nio.file.Path +import jakarta.inject.Singleton +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j @Slf4j @Singleton -@Order(300) +@Order(45) @CompileStatic class Monitoring extends Feature implements FeatureWithImage { - static final String HELM_VALUES_PATH = 'argocd/cluster-resources/apps/monitoring/templates/prometheus-stack-helm-values.ftl.yaml' - static final String RBAC_NAMESPACE_ISOLATION_TEMPLATE = 'argocd/cluster-resources/apps/monitoring/templates/rbac/namespace-isolation-rbac.ftl.yaml' - static final String NETWORK_POLICIES_PROMETHEUS_ALLOW_TEMPLATE = 'argocd/cluster-resources/apps/monitoring/templates/netpols/prometheus-allow-scraping.ftl.yaml' - - String namespace = "${config.application.namePrefix}monitoring" - Config config - K8sClient k8sClient - - private GitRepoFactory scmRepoProvider - - Monitoring( - Config config, - FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer, - K8sClient k8sClient, - AirGappedUtils airGappedUtils, - GitRepoFactory scmRepoProvider, - GitHandler gitHandler - ) { - this.config = config - this.fileSystemUtils = fileSystemUtils - this.deployer = deployer - this.k8sClient = k8sClient - this.airGappedUtils = airGappedUtils - this.scmRepoProvider = scmRepoProvider - this.gitHandler = gitHandler - } - - @Override - boolean isEnabled() { - return config.features.monitoring.active - } - - @Override - void enable() { - String uid = '' - if (config.application.openshift) { - uid = findValidOpenShiftUid() - } - - addHelmValuesData('monitoring', [grafana: [host: config.features.monitoring.grafanaUrl ? new URL(config.features.monitoring.grafanaUrl).host : '']]) - addHelmValuesData('namespaces', (config.application.namespaces.activeNamespaces ?: []) as LinkedHashSet) - addHelmValuesData('scm', scmConfigurationMetrics()) - addHelmValuesData('jenkins', jenkinsConfigurationMetrics()) - addHelmValuesData('uid', uid) - - // Create secrets imperatively here instead of values.yaml, because we don't want credentials to be visible in the Git repo - setupMonitoringSecrets() - createMonitoringCrd() - - GitRepo clusterResourcesRepo = scmRepoProvider.getRepo('argocd/cluster-resources', this.gitHandler.resourcesScm) - clusterResourcesRepo.cloneRepo() - - if (config.application.namespaceIsolation || config.application.netpols) { - if (config.application.namespaceIsolation) { generateNamespaceIsolationRBAC(clusterResourcesRepo) } - if (config.application.netpols) { generateNetpols(clusterResourcesRepo) } - } - - // Remove dashboards for features that are not enabled - cleanupUnusedDashboards(clusterResourcesRepo) - - clusterResourcesRepo.commitAndPush('Update Prometheus dashboards, RBAC and network policies.') - deployHelmChart('monitoring', 'kube-prometheus-stack', namespace, config.features.monitoring.helm, HELM_VALUES_PATH, config) - } - - private void setupMonitoringSecrets() { - k8sClient.createSecret( - 'generic', - 'prometheus-metrics-creds-scmm', - namespace, - new Tuple2('password', config.application.password) - ) - - k8sClient.createSecret( - 'generic', - 'prometheus-metrics-creds-jenkins', - namespace, - new Tuple2('password', config.jenkins.metricsPassword), - ) - - if (config.features.mail.smtpUser || config.features.mail.smtpPassword) { - k8sClient.createSecret( - 'generic', - 'grafana-email-secret', - namespace, - new Tuple2('user', config.features.mail.smtpUser), - new Tuple2('password', config.features.mail.smtpPassword) - ) - } - } - - private void generateNamespaceIsolationRBAC(GitRepo repo) { - for (String currentNamespace : config.application.namespaces.activeNamespaces) { - String rbacYaml = new TemplatingEngine().template(new File(RBAC_NAMESPACE_ISOLATION_TEMPLATE), - [namespace : currentNamespace, - namePrefix: config.application.namePrefix, - config : config,]) - repo.writeFile( - "apps/monitoring/misc/rbac/${currentNamespace}.yaml", - rbacYaml - ) - } - } - - private void generateNetpols(GitRepo repo) { - for (String currentNamespace : config.application.namespaces.activeNamespaces) { - String netpolsYaml = new TemplatingEngine().template(new File(NETWORK_POLICIES_PROMETHEUS_ALLOW_TEMPLATE), - [namespace : currentNamespace, - namePrefix: config.application.namePrefix,]) - - repo.writeFile( - "apps/monitoring/misc/netpols/${currentNamespace}.yaml", - netpolsYaml - ) - } - } - - private Map scmConfigurationMetrics() { - URI uri = this.gitHandler.resourcesScm.prometheusMetricsEndpoint() - return [ - protocol: uri?.scheme ?: '', - host : uri?.authority ?: '', - path : uri?.path ?: '', - ] - } - - protected void createMonitoringCrd() { - if (!config.application.skipCrds) { - def serviceMonitorCrdYaml - if (config.application.mirrorRepos) { - serviceMonitorCrdYaml = Path.of( - "${config.application.localHelmChartFolder}/${config.features.monitoring.helm.chart}/charts/crds/crds/crd-servicemonitors.yaml" - ).toString() - } else { - serviceMonitorCrdYaml = - "https://raw.githubusercontent.com/prometheus-community/helm-charts/" + - "kube-prometheus-stack-${config.features.monitoring.helm.version}/" + - "charts/kube-prometheus-stack/charts/crds/crds/crd-servicemonitors.yaml" - } - - log.debug("Applying ServiceMonitor CRD; Argo CD fails if it is not there. Chicken-egg-problem.\n" + - "Applying from path ${serviceMonitorCrdYaml}") - k8sClient.applyYaml(serviceMonitorCrdYaml) - } - } - - private Map jenkinsConfigurationMetrics() { - URI uri = baseUriJenkins(config).resolve('prometheus') - return [ - metricsUsername: config.jenkins.metricsUsername ?: '', - protocol : uri.scheme ?: '', - host : uri.authority ?: '', - path : uri.path ?: '', - ] - } - - private static URI baseUriJenkins(Config config) { - if (config.jenkins.internal) { - return new URI("http://jenkins.${config.application.namePrefix}jenkins.svc.cluster.local/") - } - def urlString = config.jenkins?.url?.strip() ?: "" - if (!urlString) { - throw new IllegalArgumentException("config.jenkins.url must be set when config.jenkins.internal = false") - } - def url = URI.create(urlString) - return url.toString().endsWith("/") ? url : URI.create(url.toString() + "/") - } - - private String findValidOpenShiftUid() { - String uidRange = k8sClient.getAnnotation('namespace', namespace, 'openshift.io/sa.scc.uid-range') - - if (uidRange) { - log.debug("found UID=${uidRange}") - String uid = uidRange.split('/')[0] - return uid - } else { - throw new RuntimeException("Could not find a valid UID! Really running on OpenShift?") - } - } - - protected void cleanupUnusedDashboards(GitRepo clusterResourcesRepo) { - String repoRoot = clusterResourcesRepo.getAbsoluteLocalRepoTmpDir() - String dashboardRoot = "${repoRoot}/apps/prometheusstack/misc/dashboard" - - if (!config.features.ingress.active) { - fileSystemUtils.deleteFile("${dashboardRoot}/traefik-dashboard.yaml") - fileSystemUtils.deleteFile("${dashboardRoot}/traefik-dashboard-requests-handling.yaml") - } - - if (!config.jenkins.active) { - fileSystemUtils.deleteFile("${dashboardRoot}/jenkins-dashboard.yaml") - } - - if (!config.scm.scmManager?.url) { - fileSystemUtils.deleteFile("${dashboardRoot}/scmm-dashboard.yaml") - } - } - - @Override - String getNamespace() { - return namespace - } - - @Override - K8sClient getK8sClient() { - return k8sClient - } - - @Override - Config getConfig() { - return config - } -} + static final String HELM_VALUES_PATH = 'argocd/cluster-resources/apps/monitoring/templates/prometheus-stack-helm-values.ftl.yaml' + static final String RBAC_NAMESPACE_ISOLATION_TEMPLATE = 'argocd/cluster-resources/apps/monitoring/templates/rbac/namespace-isolation-rbac.ftl.yaml' + static final String NETWORK_POLICIES_PROMETHEUS_ALLOW_TEMPLATE = 'argocd/cluster-resources/apps/monitoring/templates/netpols/prometheus-allow-scraping.ftl.yaml' + + String namespace = "${config.application.namePrefix}monitoring" + Config config + K8sClient k8sClient + + private GitRepoFactory scmRepoProvider + + Monitoring( + Config config, + FileSystemUtils fileSystemUtils, + DeploymentStrategy deployer, + K8sClient k8sClient, + AirGappedUtils airGappedUtils, + GitRepoFactory scmRepoProvider, + GitHandler gitHandler) { + this.config = config + this.fileSystemUtils = fileSystemUtils + this.deployer = deployer + this.k8sClient = k8sClient + this.airGappedUtils = airGappedUtils + this.scmRepoProvider = scmRepoProvider + this.gitHandler = gitHandler + } + + @Override + boolean isEnabled() { + return config.features.monitoring.active + } + + @Override + void enable() { + String uid = '' + if (config.application.openshift) { + uid = findValidOpenShiftUid() + } + + addHelmValuesData('monitoring', [grafana: [host: config.features.monitoring.grafanaUrl ? new URL(config.features.monitoring.grafanaUrl).host : '']]) + addHelmValuesData('namespaces', (config.application.namespaces.activeNamespaces ?: []) as LinkedHashSet) + addHelmValuesData('scm', scmConfigurationMetrics()) + addHelmValuesData('jenkins', jenkinsConfigurationMetrics()) + addHelmValuesData('uid', uid) + + // Create secrets imperatively here instead of values.yaml, because we don't want credentials to be visible in the Git repo + setupMonitoringSecrets() + createMonitoringCrd() + + GitRepo clusterResourcesRepo = scmRepoProvider.getRepo('argocd/cluster-resources', this.gitHandler.resourcesScm) + clusterResourcesRepo.cloneRepo() + + if (config.application.namespaceIsolation || config.application.netpols) { + if (config.application.namespaceIsolation) { generateNamespaceIsolationRBAC(clusterResourcesRepo) } + if (config.application.netpols) { generateNetpols(clusterResourcesRepo) } + } + + // Remove dashboards for features that are not enabled + cleanupUnusedDashboards(clusterResourcesRepo) + + clusterResourcesRepo.commitAndPush('Update Prometheus dashboards, RBAC and network policies.') + deployHelmChart('monitoring', 'kube-prometheus-stack', namespace, config.features.monitoring.helm, HELM_VALUES_PATH, config) + } + + private void setupMonitoringSecrets() { + k8sClient.createSecret('generic', + 'prometheus-metrics-creds-scmm', + namespace, + new Tuple2('password', config.application.password)) + + k8sClient.createSecret('generic', + 'prometheus-metrics-creds-jenkins', + namespace, + new Tuple2('password', config.jenkins.metricsPassword),) + + if (config.features.mail.smtpUser || config.features.mail.smtpPassword) { + k8sClient.createSecret('generic', + 'grafana-email-secret', + namespace, + new Tuple2('user', config.features.mail.smtpUser), + new Tuple2('password', config.features.mail.smtpPassword)) + } + } + + private void generateNamespaceIsolationRBAC(GitRepo repo) { + for (String currentNamespace : config.application.namespaces.activeNamespaces) { + String rbacYaml = new TemplatingEngine().template(new File(RBAC_NAMESPACE_ISOLATION_TEMPLATE), + [namespace : currentNamespace, + namePrefix: config.application.namePrefix, + config : config,]) + repo.writeFile("apps/monitoring/misc/rbac/${currentNamespace}.yaml", + rbacYaml) + } + } + + private void generateNetpols(GitRepo repo) { + for (String currentNamespace : config.application.namespaces.activeNamespaces) { + String netpolsYaml = new TemplatingEngine().template(new File(NETWORK_POLICIES_PROMETHEUS_ALLOW_TEMPLATE), + [namespace : currentNamespace, + namePrefix: config.application.namePrefix,]) + + repo.writeFile("apps/monitoring/misc/netpols/${currentNamespace}.yaml", + netpolsYaml) + } + } + + private Map scmConfigurationMetrics() { + URI uri = this.gitHandler.resourcesScm.prometheusMetricsEndpoint() + return [protocol: uri?.scheme ?: '', + host : uri?.authority ?: '', + path : uri?.path ?: '',] + } + + protected void createMonitoringCrd() { + if (!config.application.skipCrds) { + def serviceMonitorCrdYaml + if (config.application.mirrorRepos) { + serviceMonitorCrdYaml = Path.of("${config.application.localHelmChartFolder}/${config.features.monitoring.helm.chart}/charts/crds/crds/crd-servicemonitors.yaml").toString() + } else { + serviceMonitorCrdYaml = "https://raw.githubusercontent.com/prometheus-community/helm-charts/" + "kube-prometheus-stack-${config.features.monitoring.helm.version}/" + + "charts/kube-prometheus-stack/charts/crds/crds/crd-servicemonitors.yaml" + } + + log.debug("Applying ServiceMonitor CRD; Argo CD fails if it is not there. Chicken-egg-problem.\n" + "Applying from path ${serviceMonitorCrdYaml}") + k8sClient.applyYaml(serviceMonitorCrdYaml) + } + } + + private Map jenkinsConfigurationMetrics() { + URI uri = baseUriJenkins(config).resolve('prometheus') + return [metricsUsername: config.jenkins.metricsUsername ?: '', + protocol : uri.scheme ?: '', + host : uri.authority ?: '', + path : uri.path ?: '',] + } + + private static URI baseUriJenkins(Config config) { + if (config.jenkins.internal) { + return new URI("http://jenkins.${config.application.namePrefix}jenkins.svc.cluster.local/") + } + def urlString = config.jenkins?.url?.strip() ?: "" + if (!urlString) { + throw new IllegalArgumentException("config.jenkins.url must be set when config.jenkins.internal = false") + } + def url = URI.create(urlString) + return url.toString().endsWith("/") ? url : URI.create(url.toString() + "/") + } + + private String findValidOpenShiftUid() { + String uidRange = k8sClient.getAnnotation('namespace', namespace, 'openshift.io/sa.scc.uid-range') + + if (uidRange) { + log.debug("found UID=${uidRange}") + String uid = uidRange.split('/')[0] + return uid + } else { + throw new RuntimeException("Could not find a valid UID! Really running on OpenShift?") + } + } + + protected void cleanupUnusedDashboards(GitRepo clusterResourcesRepo) { + String repoRoot = clusterResourcesRepo.getAbsoluteLocalRepoTmpDir() + String dashboardRoot = "${repoRoot}/apps/prometheusstack/misc/dashboard" + + if (!config.features.ingress.active) { + fileSystemUtils.deleteFile("${dashboardRoot}/traefik-dashboard.yaml") + fileSystemUtils.deleteFile("${dashboardRoot}/traefik-dashboard-requests-handling.yaml") + } + + if (!config.jenkins.active) { + fileSystemUtils.deleteFile("${dashboardRoot}/jenkins-dashboard.yaml") + } + + if (!config.scm.scmManager?.url) { + fileSystemUtils.deleteFile("${dashboardRoot}/scmm-dashboard.yaml") + } + } + + @Override + String getNamespace() { + return namespace + } + + @Override + K8sClient getK8sClient() { + return k8sClient + } + + @Override + Config getConfig() { + return config + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy b/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy index c5deeebc3..e9f36f0ff 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy @@ -13,7 +13,7 @@ import groovy.util.logging.Slf4j @Slf4j @Singleton -@Order(60) +@Order(10) class Registry extends Feature { /** diff --git a/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy b/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy index 4616144ed..c4daf0774 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy @@ -6,75 +6,75 @@ import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.DeploymentStrategy import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.kubernetes.api.K8sClient -import com.cloudogu.gitops.utils.* -import groovy.util.logging.Slf4j +import com.cloudogu.gitops.utils.AirGappedUtils +import com.cloudogu.gitops.utils.FileSystemUtils +import com.cloudogu.gitops.utils.TemplatingEngine + import io.micronaut.core.annotation.Order + import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton -@Order(500) +@Order(40) class Vault extends Feature implements FeatureWithImage { - static final String VAULT_START_SCRIPT_PATH = "argocd/cluster-resources/apps/vault/templates/dev-post-start.ftl.sh" - static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/vault/templates/values.ftl.yaml" + static final String VAULT_START_SCRIPT_PATH = "argocd/cluster-resources/apps/vault/templates/dev-post-start.ftl.sh" + static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/vault/templates/values.ftl.yaml" - String namespace = "${config.application.namePrefix}secrets" - Config config - K8sClient k8sClient + String namespace = "${config.application.namePrefix}secrets" + Config config + K8sClient k8sClient - Vault( - Config config, - FileSystemUtils fileSystemUtils, - K8sClient k8sClient, - DeploymentStrategy deployer, - AirGappedUtils airGappedUtils, - GitHandler gitHandler - ) { - this.deployer = deployer - this.config = config - this.fileSystemUtils = fileSystemUtils - this.k8sClient = k8sClient - this.airGappedUtils = airGappedUtils - this.gitHandler = gitHandler - } + Vault( + Config config, + FileSystemUtils fileSystemUtils, + K8sClient k8sClient, + DeploymentStrategy deployer, + AirGappedUtils airGappedUtils, + GitHandler gitHandler) { + this.deployer = deployer + this.config = config + this.fileSystemUtils = fileSystemUtils + this.k8sClient = k8sClient + this.airGappedUtils = airGappedUtils + this.gitHandler = gitHandler + } - @Override - boolean isEnabled() { - return config.features.secrets.active - } + @Override + boolean isEnabled() { + return config.features.secrets.active + } - @Override - void enable() { - // Note that some specific configuration steps are implemented in ArgoCD - def helmConfig = config.features.secrets.vault.helm + @Override + void enable() { + // Note that some specific configuration steps are implemented in ArgoCD + def helmConfig = config.features.secrets.vault.helm - addHelmValuesData("host", config.features.secrets.vault.url ? new URL(config.features.secrets.vault.url as String).host : '') + addHelmValuesData("host", config.features.secrets.vault.url ? new URL(config.features.secrets.vault.url as String).host : '') - String vaultMode = config.features.secrets.vault.mode - if (vaultMode == 'dev') { - log.debug('WARNING! Vault dev mode is enabled! In this mode, Vault runs entirely in-memory\n' + - 'and starts unsealed with a single unseal key. ') + String vaultMode = config.features.secrets.vault.mode + if (vaultMode == 'dev') { + log.debug('WARNING! Vault dev mode is enabled! In this mode, Vault runs entirely in-memory\n' + 'and starts unsealed with a single unseal key. ') - // Create config map from init script - // Init script creates/authorizes secrets, users, service accounts, etc. - def vaultPostStartConfigMap = 'vault-dev-post-start' - def vaultPostStartVolume = 'dev-post-start' + // Create config map from init script + // Init script creates/authorizes secrets, users, service accounts, etc. + def vaultPostStartConfigMap = 'vault-dev-post-start' + def vaultPostStartVolume = 'dev-post-start' - def templatedFile = fileSystemUtils.copyToTempDir(fileSystemUtils.getRootDir() + "/"+VAULT_START_SCRIPT_PATH) - def postStartScript = new TemplatingEngine().replaceTemplate(templatedFile.toFile(), [namePrefix: config.application.namePrefix]) + def templatedFile = fileSystemUtils.copyToTempDir(fileSystemUtils.getRootDir() + "/" + VAULT_START_SCRIPT_PATH) + def postStartScript = new TemplatingEngine().replaceTemplate(templatedFile.toFile(), [namePrefix: config.application.namePrefix]) - log.debug('Creating namespace for vault, so it can add its secrets there') - k8sClient.createNamespace(namespace) - k8sClient.createConfigMapFromFile(vaultPostStartConfigMap, namespace, postStartScript.absolutePath) + log.debug('Creating namespace for vault, so it can add its secrets there') + k8sClient.createNamespace(namespace) + k8sClient.createConfigMapFromFile(vaultPostStartConfigMap, namespace, postStartScript.absolutePath) - addHelmValuesData("dev", [ - rootToken: UUID.randomUUID(), - vaultPostStartConfigMap: vaultPostStartConfigMap, - vaultPostStartVolume: vaultPostStartVolume, - postStartScriptName: postStartScript.name - ]) - } + addHelmValuesData("dev", [rootToken : UUID.randomUUID(), + vaultPostStartConfigMap: vaultPostStartConfigMap, + vaultPostStartVolume : vaultPostStartVolume, + postStartScriptName : postStartScript.name]) + } - deployHelmChart('vault', 'vault', namespace, helmConfig, HELM_VALUES_PATH, config) - } + deployHelmChart('vault', 'vault', namespace, helmConfig, HELM_VALUES_PATH, config) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy index 9a932b854..63d19dc92 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy @@ -22,7 +22,7 @@ import org.springframework.security.crypto.bcrypt.BCrypt @Slf4j @Singleton -@Order(100) +@Order(2) class ArgoCD extends Feature { static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/argocd/argocd/argocd-helm-values.ftl.yaml" diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy index bdd233cc0..23f504c48 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy @@ -2,7 +2,7 @@ package com.cloudogu.gitops.features.git import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.HelmStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.config.util.ScmProviderType import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.git.providers.gitlab.Gitlab @@ -13,27 +13,29 @@ import com.cloudogu.gitops.utils.NetworkingUtils import io.micronaut.core.annotation.Order +import jakarta.inject.Inject +import jakarta.inject.Provider import jakarta.inject.Singleton import groovy.util.logging.Slf4j @Slf4j @Singleton -@Order(40) +@Order(1) class GitHandler extends Feature { Config config NetworkingUtils networkingUtils - HelmStrategy deployer + @Inject + Provider deployerProvider FileSystemUtils fileSystemUtils K8sClient k8sClient GitProvider tenant GitProvider central - GitHandler(Config config, HelmStrategy deployer, FileSystemUtils fileSystemUtils, K8sClient k8sClient, NetworkingUtils networkingUtils) { + GitHandler(Config config, FileSystemUtils fileSystemUtils, K8sClient k8sClient, NetworkingUtils networkingUtils) { this.config = config - this.deployer = deployer this.fileSystemUtils = fileSystemUtils this.k8sClient = k8sClient this.networkingUtils = networkingUtils @@ -90,8 +92,8 @@ class GitHandler extends Feature { case ScmProviderType.SCM_MANAGER: def prefixedNamespace = "${config.application.namePrefix}scm-manager".toString() config.scm.scmManager.namespace = prefixedNamespace - this.tenant = new ScmManager(this.config, config.scm.scmManager, deployer, k8sClient, networkingUtils, true) - // this.tenant.setup() setup will be here in future + this.tenant = new ScmManager(this.config, config.scm.scmManager, deployerProvider.get(), k8sClient, networkingUtils) + (tenant as ScmManager).init(true) break default: throw new IllegalArgumentException("Unsupported SCM provider found in TenantSCM") @@ -103,7 +105,7 @@ class GitHandler extends Feature { this.central = new Gitlab(this.config, this.config.multiTenant.gitlab) break case ScmProviderType.SCM_MANAGER: - this.central = new ScmManager(this.config, config.multiTenant.scmManager, deployer, k8sClient, networkingUtils) + this.central = new ScmManager(this.config, config.multiTenant.scmManager, deployerProvider.get(), k8sClient, networkingUtils) break default: throw new IllegalArgumentException("Unsupported SCM-Central provider: ${config.scm.scmProviderType}") diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy index fefe61c20..77b3bffba 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy @@ -2,7 +2,7 @@ package com.cloudogu.gitops.git.providers.scmmanager import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.Credentials -import com.cloudogu.gitops.features.deployment.HelmStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.config.util.ScmManagerConfig import com.cloudogu.gitops.git.providers.AccessRole import com.cloudogu.gitops.git.providers.GitProvider @@ -25,21 +25,20 @@ class ScmManager implements GitProvider { ScmManagerConfig scmmConfig NetworkingUtils networkingUtils - HelmStrategy helmStrategy + Deployer deployer K8sClient k8sClient Config config ScmManagerSetup scmManagerSetup - ScmManager(Config config, ScmManagerConfig scmmConfig, HelmStrategy helmStrategy, K8sClient k8sClient, NetworkingUtils networkingUtils, Boolean installNeeded = false) { + ScmManager(Config config, ScmManagerConfig scmmConfig, Deployer deployer, K8sClient k8sClient, NetworkingUtils networkingUtils) { this.scmmConfig = scmmConfig this.config = config - this.helmStrategy = helmStrategy + this.deployer = deployer this.k8sClient = k8sClient this.networkingUtils = networkingUtils - init(installNeeded) } - void init(installNeeded) { + void init(boolean installNeeded) { // --- Init Setup --- if (this.scmmConfig.internal && installNeeded) { this.scmManagerSetup = new ScmManagerSetup(this) diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy index 33f0008d0..337ea63f4 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy @@ -1,188 +1,198 @@ package com.cloudogu.gitops.git.providers.scmmanager +import com.cloudogu.gitops.features.deployment.DeploymentStrategy import com.cloudogu.gitops.git.providers.scmmanager.api.ScmManagerApiClient import com.cloudogu.gitops.git.providers.scmmanager.api.ScmManagerUser import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.MapUtils import com.cloudogu.gitops.utils.TemplatingEngine + import groovy.util.logging.Slf4j @Slf4j class ScmManagerSetup { - private ScmManager scmManager - - static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/scm-manager/templates/values.ftl.yaml" - - ScmManagerSetup(ScmManager scmManager) { - this.scmManager = scmManager - } - - void waitForScmmAvailable(int timeoutSeconds = 180, int intervalMillis = 5000, int startDelay = 0) { - long startTime = System.currentTimeMillis() - long timeoutMillis = timeoutSeconds * 1000L - sleep(startDelay) - while (System.currentTimeMillis() - startTime < timeoutMillis) { - try { - def call = scmManager.apiClient.generalApi().checkScmmAvailable() - def response = call.execute() - - if (response.successful) { - return - } - } catch (Exception e) { - log.debug("Waiting for SCM-Manager... Error: ${e.message}") - } - - - sleep(intervalMillis) - } - throw new RuntimeException("Timeout: SCM-Manager did not respond with 200 OK within ${timeoutSeconds} seconds") - } - - void configure() { - installScmmPlugins() - setSetupConfigs() - if (this.scmManager.config.jenkins.active) { - configureJenkinsPlugin() - } - addDefaultUsers() - log.info("ScmManager Setup finished!") - } - - void setupHelm() { - def releaseName = 'scmm' - - def templatedMap = TemplatingEngine.templateToMap(HELM_VALUES_PATH, [ - host : this.scmManager.scmmConfig.ingress, - username : this.scmManager.scmmConfig.credentials.username, - password : this.scmManager.scmmConfig.credentials.password, - helm : this.scmManager.scmmConfig.helm, - releaseName: releaseName - ]) - - def helmConfig = this.scmManager.scmmConfig.helm - def mergedMap = MapUtils.deepMerge(helmConfig.values, templatedMap) - def tempValuesPath = new FileSystemUtils().writeTempFile(mergedMap) - this.scmManager.helmStrategy.deployFeature( - helmConfig.repoURL, - 'scm-manager', - helmConfig.chart, - helmConfig.version, - this.scmManager.scmmConfig.namespace, - releaseName, - tempValuesPath - ) - } - - def installScmmPlugins() { - - if (this.scmManager.config.scm.scmManager.skipPlugins) { - log.debug("Skipping SCM plugin installation") - return - } - - def pluginNames = [ - "scm-mail-plugin", - "scm-review-plugin", - "scm-code-editor-plugin", - "scm-editor-plugin", - "scm-landingpage-plugin", - "scm-el-plugin", - "scm-readme-plugin", - "scm-webhook-plugin", - "scm-ci-plugin", - "scm-metrics-prometheus-plugin" - ] - - if (this.scmManager.config.jenkins.active) { - pluginNames.add("scm-jenkins-plugin") - } - Boolean restartForThisPlugin = false - pluginNames.each { String pluginName -> - log.debug("Installing Plugin ${pluginName} ...") - restartForThisPlugin = !this.scmManager.config.scm.scmManager.skipRestart && pluginName == pluginNames.last() - ScmManagerApiClient.handleApiResponse(scmManager.apiClient.pluginApi().install(pluginName, restartForThisPlugin)) - } - - log.debug("SCM-Manager plugin installation finished successfully!") - if (restartForThisPlugin) { - waitForScmmAvailable(180,2000,100) - } - } - - void setSetupConfigs() { - def setupConfigs = [ - enableProxy : false, - proxyPort : 8080, - proxyServer : "proxy.mydomain.com", - proxyUser : null, - proxyPassword : null, - realmDescription : "SONIA :: SCM Manager", - disableGroupingGrid : false, - dateFormat : "YYYY-MM-DD HH:mm:ss", - anonymousAccessEnabled : false, - anonymousMode : "OFF", - baseUrl : this.scmManager.url, - forceBaseUrl : false, - loginAttemptLimit : -1, - proxyExcludes : [], - skipFailedAuthenticators: false, - pluginUrl : "https://plugin-center-api.scm-manager.org/api/v1/plugins/{version}?os={os}&arch={arch}", - loginAttemptLimitTimeout: 300, - enabledXsrfProtection : true, - namespaceStrategy : "CustomNamespaceStrategy", - loginInfoUrl : "https://login-info.scm-manager.org/api/v1/login-info", - releaseFeedUrl : "https://scm-manager.org/download/rss.xml", - mailDomainName : "scm-manager.local", - adminGroups : [], - adminUsers : [] - ] - - ScmManagerApiClient.handleApiResponse(scmManager.apiClient.generalApi().setConfig(setupConfigs)) - log.debug("Successfully added SCMM Setup Configs") - } - - void configureJenkinsPlugin() { - - def jenkinsPluginConfig = [ - disableRepositoryConfiguration: false, - disableMercurialTrigger : false, - disableGitTrigger : false, - disableEventTrigger : false, - url : this.scmManager.config.jenkins.urlForScm - ] as Map - - ScmManagerApiClient.handleApiResponse(this.scmManager.apiClient.pluginApi().configureJenkinsPlugin(jenkinsPluginConfig)) - log.debug("Successfully configured JenkinsPlugin in SCM-Manager.") - } - - void addDefaultUsers() { - def metricsUsername = "${this.scmManager.config.application.namePrefix}metrics" - addUser(this.scmManager.scmmConfig.gitOpsUsername, this.scmManager.scmmConfig.password) - addUser(metricsUsername, this.scmManager.scmmConfig.password) - grantUserPermissions(metricsUsername, ["metrics:read"]) - } - - void addUser(String username, String password, String email = 'changeme@test.local') { - ScmManagerUser userRequest = [ - name : username, - displayName: username, - mail : email, - external : false, - password : password, - active : true, - _links : [:] - ] - ScmManagerApiClient.handleApiResponse(scmManager.apiClient.usersApi().addUser(userRequest)) - log.debug("Successfully created SCM-Manager User.") - } - - void grantUserPermissions(String username, List permissions) { - def permissionBody = [ - permissions: permissions - ] - ScmManagerApiClient.handleApiResponse(scmManager.apiClient.usersApi().setPermissionForUser(username, permissionBody)) - log.debug("Granted permissions ${permissions} to user ${username}.") - } + private ScmManager scmManager + + static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/scm-manager/templates/values.ftl.yaml" + + ScmManagerSetup(ScmManager scmManager) { + this.scmManager = scmManager + } + + void waitForScmmAvailable(int timeoutSeconds = 180, int intervalMillis = 5000, int startDelay = 0) { + long startTime = System.currentTimeMillis() + long timeoutMillis = timeoutSeconds * 1000L + sleep(startDelay) + while (System.currentTimeMillis() - startTime < timeoutMillis) { + try { + def call = scmManager.apiClient.generalApi().checkScmmAvailable() + def response = call.execute() + + if (response.successful) { + return + } + } catch (Exception e) { + log.debug("Waiting for SCM-Manager... Error: ${e.message}") + } + + sleep(intervalMillis) + } + throw new RuntimeException("Timeout: SCM-Manager did not respond with 200 OK within ${timeoutSeconds} seconds") + } + + void configure() { + installScmmPlugins() + setSetupConfigs() + if (this.scmManager.config.jenkins.active) { + configureJenkinsPlugin() + } + addDefaultUsers() + log.info("ScmManager Setup finished!") + } + + void setupHelm() { + def releaseName = 'scmm' + + def templatedMap = TemplatingEngine.templateToMap(HELM_VALUES_PATH, [host : this.scmManager.scmmConfig.ingress, + username : this.scmManager.scmmConfig.credentials.username, + password : this.scmManager.scmmConfig.credentials.password, + helm : this.scmManager.scmmConfig.helm, + releaseName: releaseName]) + + def helmConfig = this.scmManager.scmmConfig.helm + def mergedMap = MapUtils.deepMerge(helmConfig.values, templatedMap) + def tempValuesPath = new FileSystemUtils().writeTempFile(mergedMap) + scmManager.deployer.helmStrategy.deployFeature(helmConfig.repoURL, + 'scm-manager', + helmConfig.chart, + helmConfig.version, + this.scmManager.scmmConfig.namespace, + releaseName, + tempValuesPath, + DeploymentStrategy.RepoType.HELM) + } + + void createArgocdApplication() { + def releaseName = 'scm-manager' + + def templatedMap = TemplatingEngine.templateToMap(HELM_VALUES_PATH, [host : this.scmManager.scmmConfig.ingress, + username : this.scmManager.scmmConfig.credentials.username, + password : this.scmManager.scmmConfig.credentials.password, + helm : this.scmManager.scmmConfig.helm, + releaseName: releaseName]) + + def helmConfig = this.scmManager.scmmConfig.helm + def mergedMap = MapUtils.deepMerge(helmConfig.values, templatedMap) + def tempValuesPath = new FileSystemUtils().writeTempFile(mergedMap) + scmManager.deployer.argoCdStrategy.deployFeature(helmConfig.repoURL, + 'scm-manager', + helmConfig.chart, + helmConfig.version, + this.scmManager.scmmConfig.namespace, + releaseName, + tempValuesPath, + DeploymentStrategy.RepoType.HELM) + } + + def installScmmPlugins() { + + if (this.scmManager.config.scm.scmManager.skipPlugins) { + log.debug("Skipping SCM plugin installation") + return + } + + def pluginNames = ["scm-mail-plugin", + "scm-review-plugin", + "scm-code-editor-plugin", + "scm-editor-plugin", + "scm-landingpage-plugin", + "scm-el-plugin", + "scm-readme-plugin", + "scm-webhook-plugin", + "scm-ci-plugin", + "scm-metrics-prometheus-plugin"] + + if (this.scmManager.config.jenkins.active) { + pluginNames.add("scm-jenkins-plugin") + } + Boolean restartForThisPlugin = false + pluginNames.each { String pluginName -> + log.debug("Installing Plugin ${pluginName} ...") + restartForThisPlugin = !this.scmManager.config.scm.scmManager.skipRestart && pluginName == pluginNames.last() + ScmManagerApiClient.handleApiResponse(scmManager.apiClient.pluginApi().install(pluginName, restartForThisPlugin)) + } + + log.debug("SCM-Manager plugin installation finished successfully!") + if (restartForThisPlugin) { + waitForScmmAvailable(180, 2000, 100) + } + } + + void setSetupConfigs() { + def setupConfigs = [enableProxy : false, + proxyPort : 8080, + proxyServer : "proxy.mydomain.com", + proxyUser : null, + proxyPassword : null, + realmDescription : "SONIA :: SCM Manager", + disableGroupingGrid : false, + dateFormat : "YYYY-MM-DD HH:mm:ss", + anonymousAccessEnabled : false, + anonymousMode : "OFF", + baseUrl : this.scmManager.url, + forceBaseUrl : false, + loginAttemptLimit : -1, + proxyExcludes : [], + skipFailedAuthenticators: false, + pluginUrl : "https://plugin-center-api.scm-manager.org/api/v1/plugins/{version}?os={os}&arch={arch}", + loginAttemptLimitTimeout: 300, + enabledXsrfProtection : true, + namespaceStrategy : "CustomNamespaceStrategy", + loginInfoUrl : "https://login-info.scm-manager.org/api/v1/login-info", + releaseFeedUrl : "https://scm-manager.org/download/rss.xml", + mailDomainName : "scm-manager.local", + adminGroups : [], + adminUsers : []] + + ScmManagerApiClient.handleApiResponse(scmManager.apiClient.generalApi().setConfig(setupConfigs)) + log.debug("Successfully added SCMM Setup Configs") + } + + void configureJenkinsPlugin() { + + def jenkinsPluginConfig = [disableRepositoryConfiguration: false, + disableMercurialTrigger : false, + disableGitTrigger : false, + disableEventTrigger : false, + url : this.scmManager.config.jenkins.urlForScm] as Map + + ScmManagerApiClient.handleApiResponse(this.scmManager.apiClient.pluginApi().configureJenkinsPlugin(jenkinsPluginConfig)) + log.debug("Successfully configured JenkinsPlugin in SCM-Manager.") + } + + void addDefaultUsers() { + def metricsUsername = "${this.scmManager.config.application.namePrefix}metrics" + addUser(this.scmManager.scmmConfig.gitOpsUsername, this.scmManager.scmmConfig.password) + addUser(metricsUsername, this.scmManager.scmmConfig.password) + grantUserPermissions(metricsUsername, ["metrics:read"]) + } + + void addUser(String username, String password, String email = 'changeme@test.local') { + ScmManagerUser userRequest = [name : username, + displayName: username, + mail : email, + external : false, + password : password, + active : true, + _links : [:]] + ScmManagerApiClient.handleApiResponse(scmManager.apiClient.usersApi().addUser(userRequest)) + log.debug("Successfully created SCM-Manager User.") + } + + void grantUserPermissions(String username, List permissions) { + def permissionBody = [permissions: permissions] + ScmManagerApiClient.handleApiResponse(scmManager.apiClient.usersApi().setPermissionForUser(username, permissionBody)) + log.debug("Granted permissions ${permissions} to user ${username}.") + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/kubernetes/api/K8sJavaApiClient.groovy b/src/main/groovy/com/cloudogu/gitops/kubernetes/api/K8sJavaApiClient.groovy index b03e51de3..00b4019b5 100644 --- a/src/main/groovy/com/cloudogu/gitops/kubernetes/api/K8sJavaApiClient.groovy +++ b/src/main/groovy/com/cloudogu/gitops/kubernetes/api/K8sJavaApiClient.groovy @@ -1,6 +1,7 @@ package com.cloudogu.gitops.kubernetes.api import com.cloudogu.gitops.config.Credentials + import io.fabric8.kubernetes.api.model.IntOrString import io.fabric8.kubernetes.api.model.Secret import io.fabric8.kubernetes.api.model.Service @@ -10,86 +11,81 @@ import io.fabric8.kubernetes.client.KubernetesClientBuilder class K8sJavaApiClient { - KubernetesClient client + KubernetesClient client - K8sJavaApiClient(){ - this.client = new KubernetesClientBuilder().build() - } + K8sJavaApiClient() { + this.client = new KubernetesClientBuilder().build() + } - /** - * Gets login credentials from a K8s secret - */ - Credentials getCredentialsFromSecret(String secretname, String namespace, String usernameKey='username', String passwordKey='password') { - try { - Secret secret = this.client.secrets() - .inNamespace(namespace) - .withName(secretname) - .get() + /** + * Gets login credentials from a K8s secret*/ + Credentials getCredentialsFromSecret(String secretname, String namespace, String usernameKey = 'username', String passwordKey = 'password') { + try { + Secret secret = this.client.secrets() + .inNamespace(namespace) + .withName(secretname) + .get() - def secretData = secret.getData() - String username = new String(Base64.getDecoder().decode(secretData[usernameKey])) - String password = new String(Base64.getDecoder().decode(secretData[passwordKey])) - return new Credentials(username, password) - } catch (Exception e) { - throw new RuntimeException("Couldn't parse credentials from K8s secret: ${secretname} in namespace ${namespace}", e) - } - } + def secretData = secret.getData() + String username = new String(Base64.getDecoder().decode(secretData[usernameKey])) + String password = new String(Base64.getDecoder().decode(secretData[passwordKey])) + return new Credentials(username, password) + } catch (Exception e) { + throw new RuntimeException("Couldn't parse credentials from K8s secret: ${secretname} in namespace ${namespace}", e) + } + } - Service createNodePortService( - String namespace, - String serviceName, - Map selector, - Integer port, - Integer nodePort, - String portName = 'custom-port' - ) { + Service createNodePortService( + String namespace, + String serviceName, + Map selector, + Integer port, + Integer nodePort, + String portName = 'custom-port') { - def service = new ServiceBuilder() - .withNewMetadata() - .withName(serviceName) - .withNamespace(namespace) - .endMetadata() - .withNewSpec() - .withType("NodePort") - .addToSelector(selector) - .addNewPort() - .withName(portName) - .withPort(port) - .withTargetPort(new IntOrString(port)) - .withNodePort(nodePort) - .endPort() - .endSpec() - .build() + def service = new ServiceBuilder() + .withNewMetadata() + .withName(serviceName) + .withNamespace(namespace) + .endMetadata() + .withNewSpec() + .withType("NodePort") + .addToSelector(selector) + .addNewPort() + .withName(portName) + .withPort(port) + .withTargetPort(new IntOrString(port)) + .withNodePort(nodePort) + .endPort() + .endSpec() + .build() - client.services() - .inNamespace(namespace) - .resource(service) - .create() - } + client.services() + .inNamespace(namespace) + .resource(service) + .create() + } - /** - * Gets login credentials from a K8s secret - */ - Credentials getCredentialsFromSecret(Credentials credentials) { - try { - Secret secret = this.client.secrets() - .inNamespace(credentials.secretNamespace) - .withName(credentials.secretName) - .get() + /** + * Gets login credentials from a K8s secret*/ + Credentials getCredentialsFromSecret(Credentials credentials) { + try { + Secret secret = this.client.secrets() + .inNamespace(credentials.secretNamespace) + .withName(credentials.secretName) + .get() - def secretData = secret.getData() - def usernameEncoded = secretData[credentials.usernameKey] - String username = usernameEncoded != null - ? new String(Base64.decoder.decode(usernameEncoded)) - : credentials.username - String password = new String(Base64.getDecoder().decode(secretData[credentials.passwordKey])) - Credentials credentialsNew = new Credentials(credentials) - credentialsNew.username = username - credentialsNew.password = password + def secretData = secret.getData() + def usernameEncoded = secretData[credentials.usernameKey] + String username = usernameEncoded != null ? new String(Base64.decoder.decode(usernameEncoded)) : credentials.username + String password = new String(Base64.getDecoder().decode(secretData[credentials.passwordKey])) + Credentials credentialsNew = new Credentials(credentials) + credentialsNew.username = username + credentialsNew.password = password - return credentialsNew - } catch (Exception e) { - throw new RuntimeException("Couldn't parse credentials from K8s secret: ${credentials.secretName} in namespace ${credentials.secretNamespace}", e) - } - } + return credentialsNew + } catch (Exception e) { + throw new RuntimeException("Couldn't parse credentials from K8s secret: ${credentials.secretName} in namespace ${credentials.secretNamespace}", e) + } + } } \ No newline at end of file From dbdef8f69ae74c9c6b7ed79c1a1678530e00091b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Wed, 15 Apr 2026 10:32:18 +0200 Subject: [PATCH 06/10] working scmm --- .../com/cloudogu/gitops/features/git/GitHandler.groovy | 5 ++++- .../gitops/git/providers/scmmanager/ScmManager.groovy | 1 + .../gitops/git/providers/scmmanager/ScmManagerSetup.groovy | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy index 23f504c48..5ff6aae03 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy @@ -90,7 +90,7 @@ class GitHandler extends Feature { this.tenant = new Gitlab(this.config, this.config.scm.gitlab) break case ScmProviderType.SCM_MANAGER: - def prefixedNamespace = "${config.application.namePrefix}scm-manager".toString() + String prefixedNamespace = "${config.application.namePrefix}scm-manager" config.scm.scmManager.namespace = prefixedNamespace this.tenant = new ScmManager(this.config, config.scm.scmManager, deployerProvider.get(), k8sClient, networkingUtils) (tenant as ScmManager).init(true) @@ -120,6 +120,9 @@ class GitHandler extends Feature { } else { setupRepos(this.tenant, namePrefix) } + + //creating ArgocdApplication after repos are created. Fixing the bootstrap problem + (this.tenant as ScmManager)?.scmManagerSetup?.createArgocdApplication() } static void setupRepos(GitProvider gitProvider, String namePrefix = "") { diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy index 77b3bffba..f5510c910 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManager.groovy @@ -51,6 +51,7 @@ class ScmManager implements GitProvider { this.urls = new ScmManagerUrlResolver(this.config, this.scmmConfig, this.k8sClient, this.networkingUtils) this.apiClient = new ScmManagerApiClient(this.urls.clientApiBase().toString(), this.scmmConfig.credentials, this.config.application.insecure) } + } // --- Git operations --- diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy index 337ea63f4..badf2635b 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy @@ -74,7 +74,7 @@ class ScmManagerSetup { } void createArgocdApplication() { - def releaseName = 'scm-manager' + def releaseName = 'scmm' def templatedMap = TemplatingEngine.templateToMap(HELM_VALUES_PATH, [host : this.scmManager.scmmConfig.ingress, username : this.scmManager.scmmConfig.credentials.username, From 380ca406891b0d44c4cc4685e430a2e3e112bcce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Wed, 22 Apr 2026 15:03:48 +0200 Subject: [PATCH 07/10] Tests und DI fixed --- .../groovy/com/cloudogu/gitops/Feature.groovy | 226 +- .../GitopsPlaygroundCliMainScripted.groovy | 11 +- .../com/cloudogu/gitops/config/Config.groovy | 67 +- .../gitops/features/CertManager.groovy | 7 +- .../gitops/features/ContentLoader.groovy | 1174 ++++--- .../features/ExternalSecretsOperator.groovy | 4 +- .../cloudogu/gitops/features/Ingress.groovy | 4 +- .../gitops/features/Monitoring.groovy | 4 +- .../com/cloudogu/gitops/features/Vault.groovy | 4 +- .../gitops/features/argocd/ArgoCD.groovy | 15 +- .../features/deployment/Deployer.groovy | 25 +- .../gitops/features/git/GitHandler.groovy | 9 +- .../gitops/ApplicationConfiguratorTest.groovy | 1176 ++++--- .../gitops/features/CertManagerTest.groovy | 4 +- .../gitops/features/ContentLoaderTest.groovy | 2197 ++++++------ .../ExternalSecretsOperatorTest.groovy | 330 +- .../gitops/features/IngressTest.groovy | 330 +- .../gitops/features/JenkinsTest.groovy | 659 ++-- .../gitops/features/MonitoringTest.groovy | 1274 ++++--- .../gitops/features/RegistryTest.groovy | 148 +- .../cloudogu/gitops/features/VaultTest.groovy | 467 ++- .../gitops/features/argocd/ArgoCDTest.groovy | 2959 ++++++++--------- .../features/deployment/DeployerTest.groovy | 59 +- .../scmmanager/ScmManagerSetupTest.groovy | 125 +- 24 files changed, 5400 insertions(+), 5878 deletions(-) diff --git a/src/main/groovy/com/cloudogu/gitops/Feature.groovy b/src/main/groovy/com/cloudogu/gitops/Feature.groovy index 9f870936c..da26b4491 100644 --- a/src/main/groovy/com/cloudogu/gitops/Feature.groovy +++ b/src/main/groovy/com/cloudogu/gitops/Feature.groovy @@ -1,34 +1,35 @@ package com.cloudogu.gitops +import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType + import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.utils.AirGappedUtils import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.MapUtils import com.cloudogu.gitops.utils.TemplatingEngine -import freemarker.template.Configuration -import freemarker.template.DefaultObjectWrapperBuilder -import groovy.util.logging.Slf4j -import groovy.yaml.YamlSlurper import java.nio.file.Path +import groovy.util.logging.Slf4j +import groovy.yaml.YamlSlurper -import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType +import freemarker.template.Configuration +import freemarker.template.DefaultObjectWrapperBuilder /** * A single tool to be deployed by GOP. - * + * * Typically, this is a helm chart (see {@link com.cloudogu.gitops.features.deployment.DeploymentStrategy} and * {@code downloadHelmCharts.sh}) with its own section in the config * (see {@link com.cloudogu.gitops.config.schema.Schema#features}).

- * + * * In the config, features typically set their default helm chart coordinates and provide options to *
    *
  • configure images
  • *
  • overwrite default helm values
  • *

- * + * * In addition to their own config, features react to several generic GOP config options.
* Here are some typical examples: *
    @@ -38,56 +39,55 @@ import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoTyp *
  • Install with Resource requests + limits: {@link com.cloudogu.gitops.config.schema.Schema.ApplicationSchema#podResources}
  • *
  • Install without CRDs: {@link com.cloudogu.gitops.config.schema.Schema.ApplicationSchema#skipCrds}
  • *
  • For apps with UI: Setting {@link com.cloudogu.gitops.config.schema.Schema.ApplicationSchema#username} and {@link com.cloudogu.gitops.config.schema.Schema.ApplicationSchema#password}
  • - *
- */ + * */ @Slf4j abstract class Feature { - protected FileSystemUtils fileSystemUtils - protected DeploymentStrategy deployer - protected AirGappedUtils airGappedUtils - protected GitHandler gitHandler - protected Map helmValuesTemplateData = [:] - - protected void addHelmValuesData(String key, Object value) { - this.helmValuesTemplateData[key] = value - } - - boolean install() { - if (isEnabled()) { - log.info("Installing Feature ${getClass().getSimpleName()}") - - if (this instanceof FeatureWithImage) { - (this as FeatureWithImage).createImagePullSecret() - } - - enable() - return true - } else { - log.debug("Feature ${getClass().getSimpleName()} is disabled") - disable() - return false - } - } - - String getActiveNamespaceFromFeature() { - //using reflection to get all subclasses implementing a own namespace - if (this.metaClass.hasProperty(this, 'namespace')) { - return isEnabled() ? this.getProperty('namespace') : null - } - return null - } - - static Map templateToMap(String filePath, Map parameters) { - def hydratedString = new TemplatingEngine().template(new File(filePath), parameters) - - if (hydratedString.trim().isEmpty()) { - // Otherwise YamlSlurper returns an empty array, whereas we expect a Map - return [:] - } - return new YamlSlurper().parseText(hydratedString) as Map - } + protected FileSystemUtils fileSystemUtils + protected Deployer deployer + protected AirGappedUtils airGappedUtils + protected GitHandler gitHandler + protected Map helmValuesTemplateData = [:] + + protected void addHelmValuesData(String key, Object value) { + this.helmValuesTemplateData[key] = value + } + + boolean install() { + if (isEnabled()) { + log.info("Installing Feature ${getClass().getSimpleName()}") + + if (this instanceof FeatureWithImage) { + (this as FeatureWithImage).createImagePullSecret() + } + + enable() + return true + } else { + log.debug("Feature ${getClass().getSimpleName()} is disabled") + disable() + return false + } + } + + String getActiveNamespaceFromFeature() { + //using reflection to get all subclasses implementing a own namespace + if (this.metaClass.hasProperty(this, 'namespace')) { + return isEnabled() ? this.getProperty('namespace') : null + } + return null + } + + static Map templateToMap(String filePath, Map parameters) { + def hydratedString = new TemplatingEngine().template(new File(filePath), parameters) + + if (hydratedString.trim().isEmpty()) { + // Otherwise YamlSlurper returns an empty array, whereas we expect a Map + return [:] + } + return new YamlSlurper().parseText(hydratedString) as Map + } protected void deployHelmChart( String featureName, @@ -101,41 +101,41 @@ abstract class Feature { String version = helmConfig.version RepoType repoType = RepoType.HELM - this.addHelmValuesData("config", config) - this.addHelmValuesData("statics", new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_32).build().getStaticModels()) - - /* If we get a helmValuesTemplatePath we render the Template with the given Data. - * Some Features might not use a values template and thus passing no helmValuesTemplatePath, in that - * case we simply treat helmValuesTemplateData directly as helmValuesData */ - Map helmValuesData = this.helmValuesTemplateData - if (helmValuesTemplatePath) { - def helmValuesPath = helmValuesTemplatePath.toString() - if (helmValuesPath.contains(".ftl")) { - log.debug("Rendering helm values template from ${helmValuesTemplatePath}") - helmValuesData = templateToMap(helmValuesTemplatePath, this.helmValuesTemplateData) - } else { - log.debug("Reading plain helm values YAML from ${helmValuesTemplatePath}") - helmValuesData = fileSystemUtils.readYaml(Path.of(helmValuesTemplatePath)) as Map - } - } - - helmValuesData = MapUtils.deepMerge(helmConfig.values, helmValuesData) - Path tempValuesPath = this.fileSystemUtils.writeTempFile(helmValuesData) - - if (config.application.mirrorRepos) { - log.debug("Using a local, mirrored git repo as deployment source for feature ${featureName}") - - String repoNamespaceAndName = this.airGappedUtils.mirrorHelmRepoToGit(helmConfig) - repoURL = this.gitHandler.resourcesScm.repoUrl(repoNamespaceAndName) - chartOrPath = '.' - repoType = RepoType.GIT - version = new YamlSlurper() - .parse(Path.of("${config.application.localHelmChartFolder}/${helmConfig.chart}", - 'Chart.yaml'))['version'] - } - - log.debug("Starting deployment of feature ${featureName} from ${repoURL}.") - log.debug("helm values used: ${helmValuesData}") + this.addHelmValuesData("config", config) + this.addHelmValuesData("statics", new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_32).build().getStaticModels()) + + /* If we get a helmValuesTemplatePath we render the Template with the given Data. + * Some Features might not use a values template and thus passing no helmValuesTemplatePath, in that + * case we simply treat helmValuesTemplateData directly as helmValuesData */ + Map helmValuesData = this.helmValuesTemplateData + if (helmValuesTemplatePath) { + def helmValuesPath = helmValuesTemplatePath.toString() + if (helmValuesPath.contains(".ftl")) { + log.debug("Rendering helm values template from ${helmValuesTemplatePath}") + helmValuesData = templateToMap(helmValuesTemplatePath, this.helmValuesTemplateData) + } else { + log.debug("Reading plain helm values YAML from ${helmValuesTemplatePath}") + helmValuesData = fileSystemUtils.readYaml(Path.of(helmValuesTemplatePath)) as Map + } + } + + helmValuesData = MapUtils.deepMerge(helmConfig.values, helmValuesData) + Path tempValuesPath = this.fileSystemUtils.writeTempFile(helmValuesData) + + if (config.application.mirrorRepos) { + log.debug("Using a local, mirrored git repo as deployment source for feature ${featureName}") + + String repoNamespaceAndName = this.airGappedUtils.mirrorHelmRepoToGit(helmConfig) + repoURL = this.gitHandler.resourcesScm.repoUrl(repoNamespaceAndName) + chartOrPath = '.' + repoType = RepoType.GIT + version = new YamlSlurper() + .parse(Path.of("${config.application.localHelmChartFolder}/${helmConfig.chart}", + 'Chart.yaml'))['version'] + } + + log.debug("Starting deployment of feature ${featureName} from ${repoURL}.") + log.debug("helm values used: ${helmValuesData}") this.deployer.deployFeature(repoURL, featureName, @@ -148,31 +148,35 @@ abstract class Feature { initByHelm) } - abstract boolean isEnabled() + abstract boolean isEnabled() + /* + * Hooks for enabling or disabling a feature. Both optional, because not always needed. + */ + protected void enable() { + } + + protected void disable() { + } - /* - * Hooks for enabling or disabling a feature. Both optional, because not always needed. - */ - protected void enable() {} - protected void disable() {} + /* + * Hook for special feature validation. Optional. + * Feature should throw RuntimeException to stop immediately. + */ - /* - * Hook for special feature validation. Optional. - * Feature should throw RuntimeException to stop immediately. - */ - protected void validate() { } + protected void validate() { + } - /** - * Hook for preConfigInit. Optional. - * Feature should throw RuntimeException to stop immediately. - */ - void preConfigInit(Config configToSet) { } + /** + * Hook for preConfigInit. Optional. + * Feature should throw RuntimeException to stop immediately.*/ + void preConfigInit(Config configToSet) { + } - /** - * Hook for postConfigInit. Optional. - * Feature should throw RuntimeException to stop immediately. - */ - void postConfigInit(Config configToSet) { } + /** + * Hook for postConfigInit. Optional. + * Feature should throw RuntimeException to stop immediately.*/ + void postConfigInit(Config configToSet) { + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy index 0ffb444f5..38c2b4216 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy @@ -65,10 +65,11 @@ class GitopsPlaygroundCliMainScripted { httpClientFactory.okHttpClientJenkins(config)) context.registerSingleton(k8sClient) - GitHandler gitHandler = new GitHandler(config, fileSystemUtils, k8sClient, networkingUtils) - Deployer deployer = new Deployer(config, new ArgoCdApplicationStrategy(config, fileSystemUtils, gitRepoFactory, gitHandler), helmStrategy) - + Deployer deployer = new Deployer(helmStrategy) + GitHandler gitHandler = new GitHandler(config, deployer, fileSystemUtils, k8sClient, networkingUtils) + ArgoCdApplicationStrategy argoCdApplicationStrategy = new ArgoCdApplicationStrategy(config, fileSystemUtils, gitRepoFactory, gitHandler) AirGappedUtils airGappedUtils = new AirGappedUtils(config, gitRepoFactory, fileSystemUtils, helmClient, gitHandler) + Jenkins jenkins = new Jenkins(config, executor, fileSystemUtils, new GlobalPropertyManager(jenkinsApiClient), new JobManager(jenkinsApiClient), new UserManager(jenkinsApiClient), new PrometheusConfigurator(jenkinsApiClient), deployer, k8sClient, networkingUtils, gitHandler) @@ -77,13 +78,13 @@ class GitopsPlaygroundCliMainScripted { context.registerSingleton(new Application(config, [new Registry(config, fileSystemUtils, k8sClient, deployer), gitHandler, jenkins, - new ArgoCD(config, k8sClient, helmClient, deployer, fileSystemUtils, gitRepoFactory, gitHandler), + new ArgoCD(config, k8sClient, deployer, fileSystemUtils, gitRepoFactory, gitHandler), new Ingress(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), new CertManager(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), new Monitoring(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitRepoFactory, gitHandler), new ExternalSecretsOperator(config, fileSystemUtils, deployer, k8sClient, airGappedUtils, gitHandler), new Vault(config, fileSystemUtils, k8sClient, deployer, airGappedUtils, gitHandler), - new ContentLoader(config, k8sClient, gitRepoFactory, jenkins, gitHandler),])) + new ContentLoader(config, k8sClient, gitRepoFactory, jenkins, gitHandler, fileSystemUtils, deployer)])) } } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/config/Config.groovy b/src/main/groovy/com/cloudogu/gitops/config/Config.groovy index be2e468c9..2ccd99cba 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/Config.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/Config.groovy @@ -97,9 +97,10 @@ class Config { @JsonPropertyDescription(CONTENT_VARIABLES_DESCRIPTION) Map variables = [:] - // ✅ NEW: helm releases that should be deployed via ArgoCDApplicationStrategy without requiring a git repo - @JsonPropertyDescription()//(CONTENT_HELM_RELEASES_DESCRIPTION) - List helmReleases = [] + // ✅ NEW: helm releases that should be deployed via ArgoCDApplicationStrategy without requiring a git repo + @JsonPropertyDescription() + //(CONTENT_HELM_RELEASES_DESCRIPTION) + List helmReleases = [] @Option(names = ['--content-whitelist'], description = CONTENT_STATICSWHITELIST_ENABLED_DESCRIPTION) @JsonPropertyDescription(CONTENT_STATICSWHITELIST_ENABLED_DESCRIPTION) @@ -154,30 +155,39 @@ class Config { Boolean createJenkinsJob = false } + static class HelmReleaseSchema { @JsonPropertyDescription(CONTENT_HELM_RELEASE_NAME_DESCRIPTION) - String name = '' // featureName/apps/, also default for releaseName + String name = '' + // featureName/apps/, also default for releaseName @JsonPropertyDescription(CONTENT_HELM_RELEASE_REPO_URL_DESCRIPTION) - String repoURL = '' // helm repo url + String repoURL = '' + // helm repo url @JsonPropertyDescription(CONTENT_HELM_RELEASE_CHART_DESCRIPTION) - String chart = '' // chart name + String chart = '' + // chart name @JsonPropertyDescription(CONTENT_HELM_RELEASE_VERSION_DESCRIPTION) - String version = '' // chart version + String version = '' + // chart version @JsonPropertyDescription(CONTENT_HELM_RELEASE_NAMESPACE_DESCRIPTION) - String namespace = '' // target namespace to deploy into + String namespace = '' + // target namespace to deploy into @JsonPropertyDescription(CONTENT_HELM_RELEASE_RELEASE_NAME_DESCRIPTION) - String releaseName = '' // optional override; if empty => use name + String releaseName = '' + // optional override; if empty => use name @JsonPropertyDescription(CONTENT_HELM_RELEASE_VALUES_FILE_DESCRIPTION) - String valuesPath = '' // optional local path, e.g. src/main/resources/foo/values.yaml + String valuesPath = '' + // optional local path, e.g. src/main/resources/foo/values.yaml @JsonPropertyDescription(CONTENT_HELM_RELEASE_VALUES_DESCRIPTION) - Map values = [:] // optional inline values (merged with valuesFile) + Map values = [:] + // optional inline values (merged with valuesFile) } } @@ -229,13 +239,13 @@ class Config { @JsonPropertyDescription(REGISTRY_PROXY_URL_DESCRIPTION) String proxyUrl = '' - @Option(names = ['--registry-proxy-path'], description = REGISTRY_PROXY_PATH_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_PROXY_PATH_DESCRIPTION) - String proxyPath = '' + @Option(names = ['--registry-proxy-path'], description = REGISTRY_PROXY_PATH_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_PROXY_PATH_DESCRIPTION) + String proxyPath = '' - @Option(names = ['--registry-proxy-username'], description = REGISTRY_PROXY_PASSWORD_DESCRIPTION) - @JsonPropertyDescription(REGISTRY_PROXY_USERNAME_DESCRIPTION) - String proxyUsername = '' + @Option(names = ['--registry-proxy-username'], description = REGISTRY_PROXY_PASSWORD_DESCRIPTION) + @JsonPropertyDescription(REGISTRY_PROXY_USERNAME_DESCRIPTION) + String proxyUsername = '' @Option(names = ['--registry-proxy-password'], description = 'Optional when --registry-proxy-url is set') @JsonPropertyDescription(REGISTRY_PROXY_PASSWORD_DESCRIPTION) @@ -256,8 +266,8 @@ class Config { @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) HelmConfigWithValues helm = new HelmConfigWithValues(chart: 'docker-registry', - repoURL: 'https://twuni.github.io/docker-registry.helm', - version: '3.0.0') + repoURL: 'https://twuni.github.io/docker-registry.helm', + version: '3.0.0') } @@ -522,18 +532,9 @@ class Config { Boolean active = false - @Option(names = ['--mail'], description = MAILSERVER_ENABLE_DESCRIPTION, scope = ScopeType.INHERIT) - @JsonPropertyDescription(MAILSERVER_ENABLE_DESCRIPTION) - Boolean mailServer = false - - - @Option(names = ['--mail-url'], description = MAIL_URL_DESCRIPTION) - @JsonPropertyDescription(MAIL_URL_DESCRIPTION) - String mailUrl = '' - - @Option(names = ['--smtp-address'], description = SMTP_ADDRESS_DESCRIPTION) - @JsonPropertyDescription(SMTP_ADDRESS_DESCRIPTION) - String smtpAddress = '' + @Option(names = ['--smtp-address'], description = SMTP_ADDRESS_DESCRIPTION) + @JsonPropertyDescription(SMTP_ADDRESS_DESCRIPTION) + String smtpAddress = '' @Option(names = ['--smtp-port'], description = SMTP_PORT_DESCRIPTION) @JsonPropertyDescription(SMTP_PORT_DESCRIPTION) @@ -685,8 +686,8 @@ class Config { @Mixin @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) CertManagerHelmSchema helm = new CertManagerHelmSchema(chart: 'cert-manager', - repoURL: 'https://charts.jetstack.io', - version: '1.19.4') + repoURL: 'https://charts.jetstack.io', + version: '1.19.4') static class CertManagerHelmSchema extends HelmConfigWithValues { diff --git a/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy b/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy index f26dea18f..a06bdd5bc 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy @@ -3,7 +3,7 @@ package com.cloudogu.gitops.features import com.cloudogu.gitops.Feature import com.cloudogu.gitops.FeatureWithImage import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.AirGappedUtils @@ -25,9 +25,10 @@ class CertManager extends Feature implements FeatureWithImage { final Config config final String namespace = "${config.application.namePrefix}cert-manager" - CertManager(Config config, + CertManager( + Config config, FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer, + Deployer deployer, K8sClient k8sClient, AirGappedUtils airGappedUtils, GitHandler gitHandler) { diff --git a/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy b/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy index 483426de8..c5abc3a74 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy @@ -1,10 +1,13 @@ package com.cloudogu.gitops.features +import static com.cloudogu.gitops.config.Config.ContentRepoType +import static com.cloudogu.gitops.config.Config.ContentSchema.ContentRepositorySchema + import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.Config.OverwriteMode import com.cloudogu.gitops.config.Credentials -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.git.GitRepoFactory @@ -13,12 +16,16 @@ import com.cloudogu.gitops.utils.AllowListFreemarkerObjectWrapper import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.MapUtils import com.cloudogu.gitops.utils.TemplatingEngine + +import io.micronaut.core.annotation.Order + +import java.nio.file.Path +import jakarta.inject.Singleton +import groovy.util.logging.Slf4j + import com.fasterxml.jackson.annotation.JsonIgnore import freemarker.template.Configuration import freemarker.template.DefaultObjectWrapperBuilder -import groovy.util.logging.Slf4j -import io.micronaut.core.annotation.Order -import jakarta.inject.Singleton import org.apache.commons.io.FileUtils import org.eclipse.jgit.api.CloneCommand import org.eclipse.jgit.api.Git @@ -26,606 +33,573 @@ import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.lib.Repository import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider -import java.nio.file.Path - -import static com.cloudogu.gitops.config.Config.ContentRepoType -import static com.cloudogu.gitops.config.Config.ContentSchema.ContentRepositorySchema - @Slf4j @Singleton @Order(999) // We want to evaluate content last, to allow for changing all other repos class ContentLoader extends Feature { - private Config config - private K8sClient k8sClient - private GitRepoFactory repoProvider - private Jenkins jenkins - // set by lazy initialisation - private TemplatingEngine templatingEngine - // used to clone repos in validation phase - private List cachedRepoCoordinates = new ArrayList<>() - private GitHandler gitHandler - - protected File mergedReposFolder - - //For security reasons we safe the credentialsProvider for each repo here and not in config pro each repo - @JsonIgnore - UsernamePasswordCredentialsProvider credentialsProvider - - ContentLoader( - Config config, - K8sClient k8sClient, - GitRepoFactory repoProvider, - Jenkins jenkins, - GitHandler gitHandler, - FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer - ) { - this.config = config - this.k8sClient = k8sClient - this.repoProvider = repoProvider - this.jenkins = jenkins - this.gitHandler = gitHandler - this.fileSystemUtils = fileSystemUtils - this.deployer = deployer - } - - @Override - boolean isEnabled() { - return true // for now always on. Once we refactor from Argo CD class we add a param to enable - } - - @Override - void enable() { - // ensure cache is cleaned - clearCache() - // clones repo to check valid configuration and reuse result for further step. - cachedRepoCoordinates = cloneContentRepos() - createImagePullSecrets() - createContentRepos() - deployHelmReleasesFromContent() - } - - @Override - void validate() { - - } - - @Override - void preConfigInit(Config configToSet) { - config.content.repos.each { repo -> - - if (!repo.url) { - throw new RuntimeException("content.repos requires a url parameter.") - } - if (repo.target) { - if (repo.target.count('/') == 0) { - throw new RuntimeException("content.target needs / to separate namespace/group from repo name. Repo: ${repo.url}") - } - } - - switch (repo.type) { - case ContentRepoType.COPY: - if (!repo.target) { - throw new RuntimeException("content.repos.type ${ContentRepoType.COPY} requires content.repos.target to be set. Repo: ${repo.url}") - } - break - case ContentRepoType.FOLDER_BASED: - if (repo.target) { - throw new RuntimeException("content.repos.type ${ContentRepoType.FOLDER_BASED} does not support target parameter. Repo: ${repo.url}") - } - if (repo.targetRef) { - throw new RuntimeException("content.repos.type ${ContentRepoType.FOLDER_BASED} does not support targetRef parameter. Repo: ${repo.url}") - } - break - case ContentRepoType.MIRROR: - if (!repo.target) { - throw new RuntimeException("content.repos.type ${ContentRepoType.MIRROR} requires content.repos.target to be set. Repo: ${repo.url}") - } - if (repo.path != ContentRepositorySchema.DEFAULT_PATH) { - throw new RuntimeException("content.repos.type ${ContentRepoType.MIRROR} does not support path. Current path: ${repo.path}. Repo: ${repo.url}") - } - if (repo.templating) { - throw new RuntimeException("content.repos.type ${ContentRepoType.MIRROR} does not support templating. Repo: ${repo.url}") - } - break - } - } - } - - protected void deployHelmReleasesFromContent() { - if (!config.content?.helmReleases) { - log.debug("No content.helmReleases configured - skipping.") - return - } - - config.content.helmReleases.each { helmRelease -> - String version = helmRelease.version?.trim() - if (!version) { - version = "*" - } - - Config.HelmConfigWithValues helmConfig = new Config.HelmConfigWithValues( - repoURL: helmRelease.repoURL, - chart: helmRelease.chart, - version: version, - values: [:] as Map // IMPORTANT: we will pass merged values via a file - ) - - Map fileValues = [:] - if (helmRelease.valuesPath?.trim()) { - // This is a plain YAML file (NOT a .ftl template) - fileValues = (fileSystemUtils.readYaml(Path.of(helmRelease.valuesPath)) ?: [:]) as Map - } - - Map inlineValues = (helmRelease.values ?: [:]) as Map - - // merge: file first, inline overrides - Map mergedValues = MapUtils.deepMerge(inlineValues, fileValues) - - // always write a temp values file and pass its path to deployHelmChart - Path mergedValuesFile = fileSystemUtils.writeTempFile(mergedValues) - - deployHelmChart( - helmRelease.name, - helmRelease.releaseName ?: helmRelease.name, - helmRelease.namespace, - helmConfig, - mergedValuesFile.toString(), - config - ) - } - } - - void createImagePullSecrets() { - if (config.registry.createImagePullSecrets) { - String registryUsername = config.registry.readOnlyUsername ?: config.registry.username - String registryPassword = config.registry.readOnlyPassword ?: config.registry.password - - config.content.namespaces.each { String namespace -> - def registrySecretName = 'registry' - - k8sClient.createNamespace(namespace) - - k8sClient.createImagePullSecret(registrySecretName, namespace, - config.registry.url /* Only domain matters, path would be ignored */, - registryUsername, registryPassword) - - k8sClient.patch('serviceaccount', 'default', namespace, - [imagePullSecrets: [[name: registrySecretName]]]) - - if (config.registry.twoRegistries) { - k8sClient.createImagePullSecret('proxy-registry', namespace, - config.registry.proxyUrl, config.registry.proxyUsername, - config.registry.proxyPassword) - } - } - } - } - - void createContentRepos() { - if (cachedRepoCoordinates.empty) { - cachedRepoCoordinates = cloneContentRepos() - } - pushTargetRepos(cachedRepoCoordinates) - // after all, clean folders and list - clearCache() - } - - - protected List cloneContentRepos() { - mergedReposFolder = File.createTempDir('gitops-playground-based-content-repos-') - List repoCoordinates = [] - - log.debug("Aggregating structure for all ${config.content.repos.size()} repos.") - config.content.repos.each { repoConfig -> - createRepoCoordinates(repoConfig, mergedReposFolder, repoCoordinates) - } - return repoCoordinates - } - - - private TemplatingEngine getTemplatingEngine() { - if (templatingEngine == null) { - templatingEngine = new TemplatingEngine() - } - return templatingEngine - } - - - private void createRepoCoordinates(ContentRepositorySchema repoConfig, File mergedReposFolder, List repoCoordinates) { - def repoTmpDir = File.createTempDir('gitops-playground-content-repo-') - log.debug("Cloning content repo, ${repoConfig.url}, revision ${repoConfig.ref}, path ${repoConfig.path}, overwriteMode ${repoConfig.overwriteMode}") - - - if (repoConfig.credentials?.username != null && repoConfig.credentials?.password != null) { - credentialsProvider = new UsernamePasswordCredentialsProvider(repoConfig.credentials.username, repoConfig.credentials.password) - } else if (repoConfig.credentials?.secretName && repoConfig.credentials?.secretNamespace) { - Credentials credentials = this.k8sClient.k8sJavaApiClient.getCredentialsFromSecret(repoConfig.credentials) - credentialsProvider = new UsernamePasswordCredentialsProvider(credentials.username, credentials.password) - } - - cloneToLocalFolder(repoConfig, repoTmpDir) - - def contentRepoDir = new File(repoTmpDir, repoConfig.path) - applyTemplatingIfApplicable(repoConfig, contentRepoDir) - - - switch (repoConfig.type) { - case ContentRepoType.FOLDER_BASED: - createRepoCoordinatesForTypeFolderBased(repoConfig, repoTmpDir, contentRepoDir, mergedReposFolder, repoCoordinates) - repoTmpDir.deleteDir() - break - case ContentRepoType.COPY: - createRepoCoordinatesForTypeCopy(repoConfig, contentRepoDir, mergedReposFolder, repoTmpDir, repoCoordinates) - repoTmpDir.deleteDir() - break - case ContentRepoType.MIRROR: - createRepoCoordinateForTypeMirror(repoConfig, repoTmpDir, repoCoordinates) - // intentionally not deleting repoTmpDir, it is contained in RepoCoordinates for MIRROR usage - break - } - log.debug("Finished cloning content repos. repoCoordinates=${repoCoordinates}") - } - - private static void createRepoCoordinatesForTypeCopy(ContentRepositorySchema repoConfig, File contentRepoDir, File mergedReposFolder, File repoTmpDir, List repoCoordinates) { - String namespace = repoConfig.target.split('/')[0] - String repoName = repoConfig.target.split('/')[1] - - def repoCoordinate = mergeRepoDirs(contentRepoDir, namespace, repoName, mergedReposFolder, repoConfig) - repoCoordinate.refIsTag = GitRepo.isTag(repoTmpDir, repoConfig.ref) - addRepoCoordinates(repoCoordinates, repoCoordinate) - } - - private static void createRepoCoordinatesForTypeFolderBased(ContentRepositorySchema repoConfig, File repoTmpDir, File contentRepoDir, File mergedReposFolder, List repoCoordinates) { - boolean refIsTag = GitRepo.isTag(repoTmpDir, repoConfig.ref) - findRepoDirectories(contentRepoDir) - .each { contentRepoNamespaceDir -> - findRepoDirectories(contentRepoNamespaceDir) - .each { contentRepoFolder -> - String namespace = contentRepoNamespaceDir.name - String repoName = contentRepoFolder.name - def repoCoordinate = mergeRepoDirs(contentRepoFolder, namespace, repoName, mergedReposFolder, repoConfig) - repoCoordinate.refIsTag = refIsTag - addRepoCoordinates(repoCoordinates, repoCoordinate) - } - } - } - - private static void createRepoCoordinateForTypeMirror(ContentRepositorySchema repoConfig, File repoTmpDir, List repoCoordinates) { - // Don't merge but keep these in separate dirs. - // This avoids messing up .git folders with possible confusing exceptions for the user - String namespace = repoConfig.target.split('/')[0] - String repoName = repoConfig.target.split('/')[1] - def repoCoordinate = new RepoCoordinate( - namespace: namespace, - repoName: repoName, - clonedContentRepo: repoTmpDir, - repoConfig: repoConfig, - refIsTag: GitRepo.isTag(repoTmpDir, repoConfig.ref) - ) - addRepoCoordinates(repoCoordinates, repoCoordinate) - } - - /** - * Merges the files of src into the mergeRepoFolder/namespace/name and adds a new object to repoCoordinates. - * - * Note that existing repoCoordinate objects with different overwriteMode are overwritten. The last repo to be mentioned within config.content.repos wins! - */ - private static RepoCoordinate mergeRepoDirs(File src, String namespace, String repoName, File mergedRepoFolder, - ContentRepositorySchema repoConfig) { - File target = new File(new File(mergedRepoFolder, namespace), repoName) - log.debug("Merging content repo, namespace ${namespace}, repoName ${repoName} from ${src} to ${target}") - FileUtils.copyDirectory(src, target, new FileSystemUtils.IgnoreDotGitFolderFilter()) - - def repoCoordinate = new RepoCoordinate( - namespace: namespace, - repoName: repoName, - clonedContentRepo: target, - repoConfig: repoConfig, - ) - return repoCoordinate - } - - private static List findRepoDirectories(File srcRepo) { - srcRepo.listFiles().findAll { - it.isDirectory() && - // Exclude .git for example - !it.name.startsWith('.') - } - } - - - private void applyTemplatingIfApplicable(ContentRepositorySchema repoConfig, File srcPath) { - if (repoConfig.templating) { - def engine = getTemplatingEngine() - - GitRepo repo = this.repoProvider.getRepo(repoConfig.target, this.gitHandler.tenant) - - engine.replaceTemplates(srcPath, [ - config : config, - scm : [ - baseUrl : repo.gitProvider.url, - host : repo.gitProvider.host, - protocol: repo.gitProvider.protocol, - repoUrl : repo.gitProvider.repoPrefix(), - ], - // Allow for using static classes inside the templates - statics: !config.content.useWhitelist ? new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_32).build().getStaticModels() : - new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, config.content.getAllowedStaticsWhitelist()).getStaticModels() - ]) - } - } - - private void cloneToLocalFolder(ContentRepositorySchema repoConfig, File repoTmpDir) { - - - def cloneCommand = gitClone() - .setURI(repoConfig.url) - .setDirectory(repoTmpDir) - .setNoCheckout(false)// Checkout default branch - - if (credentialsProvider) { - cloneCommand.setCredentialsProvider(credentialsProvider) - } - - def git = cloneCommand.call() - - if (ContentRepoType.MIRROR == repoConfig.type) { - def fetch = git.fetch() - - if (credentialsProvider) { - fetch.setCredentialsProvider(credentialsProvider) - } - fetch.setRefSpecs("+refs/*:refs/*").call() // Fetch all branches and tags - } - - if (repoConfig.ref) { - def actualRef = findRef(repoConfig, git.repository) - git.checkout().setName(actualRef).call() - } - } - - private static String findRef(ContentRepositorySchema repoConfig, Repository gitRepo) { - // Check if ref exists first to avoid InvalidRefNameException - // Note that this works for commits and shortname tags but not shortname branches 🙄 - if (gitRepo.resolve(repoConfig.ref)) { - return repoConfig.ref - } - - // Check branches or tags - def remoteCommand = Git.lsRemoteRepository() - .setRemote(repoConfig.url) - .setHeads(true) - .setTags(true) - - Collection refs = remoteCommand.call() - String potentialRef = refs.find { it.name.endsWith(repoConfig.ref) }?.name - - if (!potentialRef) { - // Jgit silently ignores some missing refs and just continues with default branch. - // This might lead to unexpected surprises for our users, so better fail explicitly - throw new RuntimeException("Reference '${repoConfig.ref}' not found in content repository '${repoConfig.url}'") - } - - // Jgit only checks out remote branches when they start in origin/ 🙄 - return potentialRef.replace('refs/heads/', 'origin/') - } - - - private void pushTargetRepos(List repoCoordinates) { - repoCoordinates.each { repoCoordinate -> - - GitRepo targetRepo = repoProvider.getRepo(repoCoordinate.fullRepoName, this.gitHandler.tenant) - boolean isNewRepo = targetRepo.createRepositoryAndSetPermission("", false) - - if (isValidForPush(isNewRepo, repoCoordinate)) { - targetRepo.cloneRepo() - - switch (repoCoordinate.repoConfig.type) { - case ContentRepoType.MIRROR: - handleRepoMirroring(repoCoordinate, targetRepo) - break - // COPY and FOLDER_BASED same treatment - case ContentRepoType.FOLDER_BASED: - case ContentRepoType.COPY: - handleRepoCopyingOrFolderBased(repoCoordinate, targetRepo, isNewRepo) - break - } - - createJenkinsJobIfApplicable(repoCoordinate, targetRepo) - - // cleaning tmp folders - repoCoordinate.clonedContentRepo.deleteDir() - new File(targetRepo.absoluteLocalRepoTmpDir).deleteDir() - } // no else needed - } - - } - - /** - * Copies repoCoordinate to targetRepo, commits and pushes - * Same logic for both FOLDER_BASED and COPY repo types. - */ - private static void handleRepoCopyingOrFolderBased(RepoCoordinate repoCoordinate, GitRepo targetRepo, boolean isNewRepo) { - if (!isNewRepo) { - clearTargetRepoIfApplicable(repoCoordinate, targetRepo) - } - // Avoid overwriting .git in target to avoid, because we don't need it for copying and - // git pack files are typically read-only, leading to IllegalArgumentException: - // File parameter 'destFile is not writable: .git/objects/pack/pack-123.pack - targetRepo.copyDirectoryContents(repoCoordinate.clonedContentRepo.absolutePath, new FileSystemUtils.IgnoreDotGitFolderFilter()) - - String commitMessage = "Initialize content repo ${repoCoordinate.namespace}/${repoCoordinate.repoName}" - String targetRefShort = repoCoordinate.repoConfig.targetRef.replace('refs/heads/', '').replace('refs/tags/', '') - if (targetRefShort) { - String refSpec = setRefSpec(repoCoordinate, targetRefShort) - targetRepo.commitAndPush(commitMessage, targetRefShort, refSpec) - } else { - targetRepo.commitAndPush(commitMessage) - } - - } - - private static String setRefSpec(RepoCoordinate repoCoordinate, String targetRefShort) { - String refSpec - if ((repoCoordinate.refIsTag && !repoCoordinate.repoConfig.targetRef.startsWith('refs/heads')) - || repoCoordinate.repoConfig.targetRef.startsWith('refs/tags')) { - refSpec = "refs/tags/${targetRefShort}:refs/tags/${targetRefShort}" - } else { - refSpec = "HEAD:refs/heads/${targetRefShort}" - } - refSpec - } - - private static void clearTargetRepoIfApplicable(RepoCoordinate repoCoordinate, GitRepo targetRepo) { - if (OverwriteMode.INIT != repoCoordinate.repoConfig.overwriteMode) { - if (OverwriteMode.RESET == repoCoordinate.repoConfig.overwriteMode) { - log.info("OverwriteMode ${OverwriteMode.RESET} set for repo '${repoCoordinate.fullRepoName}': " + - "Deleting existing files in repo and replacing them with new content.") - targetRepo.clearRepo() - } else { - log.debug("OverwriteMode ${OverwriteMode.UPGRADE} set for repo '${repoCoordinate.fullRepoName}': " + - "Merging new content into existing repo. ") - } - } - } - - /** - * Force pushes repoCoordinate.repoConfig.ref or all refs to targetRepo - */ - private static void handleRepoMirroring(RepoCoordinate repoCoordinate, GitRepo targetRepo) { - try (def targetGit = Git.open(new File(targetRepo.absoluteLocalRepoTmpDir))) { - def remoteUrl = targetGit.repository.config.getString('remote', 'origin', 'url') - - // In mirror mode, we mainly need the .git folder to push the whole git history, branches and tags. - // So copying source to target repo, .git folders are merged. - // git pack files are typically read-only, leading to - // IllegalArgumentException: File parameter 'destFile is not writable: .git/objects/pack/pack-123.pack - // Workaround: make .git writable. - // Note: Setting target remote in source repo and pushing from there causes other problems like - // IOException: Source ref someBranch doesn't resolve to any object. - FileSystemUtils.makeWritable(new File(targetRepo.absoluteLocalRepoTmpDir, '.git')) - - targetRepo.copyDirectoryContents(repoCoordinate.clonedContentRepo.absolutePath) - - // Restore remote, it could have been overwritten due to a copied .git folder in MIRROR mode - targetGit.repository.config.setString('remote', 'origin', 'url', remoteUrl) - targetGit.repository.config.save() - } - - if (repoCoordinate.repoConfig.ref) { - validateCommitReferences(repoCoordinate) - if (repoCoordinate.repoConfig.targetRef) { - log.debug("Mirroring repo '${repoCoordinate.repoConfig.url}' ref '${repoCoordinate.repoConfig.ref}' to target repo ${repoCoordinate.fullRepoName}, targetRef: '${repoCoordinate.repoConfig.targetRef}'") - targetRepo.pushRef(repoCoordinate.repoConfig.ref, repoCoordinate.repoConfig.targetRef, true) - } else { - log.debug("Mirroring repo '${repoCoordinate.repoConfig.url}' ref '${repoCoordinate.repoConfig.ref}' to target repo ${repoCoordinate.fullRepoName}") - targetRepo.pushRef(repoCoordinate.repoConfig.ref, true) - } - } else { - log.debug("Mirroring whole repo '${repoCoordinate.repoConfig.url}' to target repo ${repoCoordinate.fullRepoName}") - targetRepo.pushAll(true) - } - } - - private static void validateCommitReferences(RepoCoordinate repoCoordinate) { - if (GitRepo.isCommit(repoCoordinate.clonedContentRepo, repoCoordinate.repoConfig.ref)) { - // Mirroring detached commits does not make a lot of sense and is complicated - // We would have to branch, push, delete remote branch. Considering this an edge case at the moment! - throw new RuntimeException("Mirroring commit references is not supported for content repos at the moment. content repository '${repoCoordinate.repoConfig.url}', ref: ${repoCoordinate.repoConfig.ref}") - } - } - - private void createJenkinsJobIfApplicable(RepoCoordinate repoCoordinate, GitRepo repo) { - if (repoCoordinate.repoConfig.createJenkinsJob && jenkins.isEnabled()) { - if (GitRepo.existFileInSomeBranch(repo.absoluteLocalRepoTmpDir, 'Jenkinsfile')) { - jenkins.createJenkinsjob(repoCoordinate.namespace, repoCoordinate.namespace) - } - } - } - - /** - * Overwrite for testing purposes - */ - protected CloneCommand gitClone() { - Git.cloneRepository() - } - - /** - * Add new repoCoordinates to repos and ensure, newest one override last one. - * Except for MIRROR, which will have to run separately from COPY/FOLDER_BASED in order to allow overriding by COPY/FOLDER_BASED repoCoordinates for the same repo. - */ - static void addRepoCoordinates(List repoCoordinates, RepoCoordinate newRepoCoordinate) { - def existingRepoCoordinates = newRepoCoordinate.findSame(repoCoordinates) - - if (!existingRepoCoordinates.isEmpty()) { - log.debug("Found existing repo coordinates for ${newRepoCoordinate}: ${existingRepoCoordinates}") - - // Don't replace MIRROR coordinates, they are separate git operations - def repoCoordinateToOverwrite = newRepoCoordinate.findSameNotMirror(existingRepoCoordinates) - if (repoCoordinateToOverwrite) { - repoCoordinates.remove(repoCoordinateToOverwrite) - log.debug("Replacing existing repo coordinate ${existingRepoCoordinates} with new one: ${newRepoCoordinate}") - } - } - repoCoordinates << newRepoCoordinate - } - - /** - * Checks whether the repo already exists and overwrite Mode matches. - */ - static boolean isValidForPush(boolean isNewRepo, RepoCoordinate repoCoordinate) { - - if (!isNewRepo && OverwriteMode.INIT == repoCoordinate.repoConfig.overwriteMode) { - log.warn("OverwriteMode ${OverwriteMode.INIT} set for repo '${repoCoordinate.fullRepoName}' " + - "and repo already exists in target: Not pushing content!" + - "If you want to override, set ${OverwriteMode.UPGRADE} or ${OverwriteMode.RESET} .") - return false - } - return true - } - - private void clearCache() { - if (mergedReposFolder) { - mergedReposFolder.deleteDir() - } - cachedRepoCoordinates.clear() - mergedReposFolder = null - } - - static class RepoCoordinate { - String namespace - String repoName - File clonedContentRepo - ContentRepositorySchema repoConfig - boolean refIsTag - - @Override - String toString() { - return "RepoCoordinates{ namespace='$namespace', repoName='$repoName', repoConfig.type='${repoConfig.type}', repoConfig.overwriteMode='${repoConfig.overwriteMode}', clonedContentRepo=$clonedContentRepo', refIsTag='${refIsTag}' }" - } - - String getFullRepoName() { - return "${namespace}/${repoName}" - } - - /** - * @return all epoCoordinate with the same fullRepoName. There can be one with either COPY/FOLDER_BASED and many MIRRORs. - */ - List findSame(List repoCoordinates) { - repoCoordinates.findAll() { it.fullRepoName == fullRepoName } - } - - /** - * @return RepoCoordinate with the same fullRepoName and repoConfig.type not MIRROR. There can only ever be one! - */ - RepoCoordinate findSameNotMirror(List repoCoordinates) { - repoCoordinates.find() { - it.fullRepoName == fullRepoName - && ContentRepoType.MIRROR != it.repoConfig.type - } - } - } + private Config config + private K8sClient k8sClient + private GitRepoFactory repoProvider + private Jenkins jenkins + // set by lazy initialisation + private TemplatingEngine templatingEngine + // used to clone repos in validation phase + private List cachedRepoCoordinates = new ArrayList<>() + private GitHandler gitHandler + + protected File mergedReposFolder + + //For security reasons we safe the credentialsProvider for each repo here and not in config pro each repo + @JsonIgnore + UsernamePasswordCredentialsProvider credentialsProvider + + ContentLoader( + Config config, + K8sClient k8sClient, + GitRepoFactory repoProvider, + Jenkins jenkins, + GitHandler gitHandler, + FileSystemUtils fileSystemUtils, + Deployer deployer) { + this.config = config + this.k8sClient = k8sClient + this.repoProvider = repoProvider + this.jenkins = jenkins + this.gitHandler = gitHandler + this.fileSystemUtils = fileSystemUtils + this.deployer = deployer + } + + @Override + boolean isEnabled() { + return true // for now always on. Once we refactor from Argo CD class we add a param to enable + } + + @Override + void enable() { + // ensure cache is cleaned + clearCache() + // clones repo to check valid configuration and reuse result for further step. + cachedRepoCoordinates = cloneContentRepos() + createImagePullSecrets() + createContentRepos() + deployHelmReleasesFromContent() + } + + @Override + void validate() { + + } + + @Override + void preConfigInit(Config configToSet) { + config.content.repos.each { repo -> + + if (!repo.url) { + throw new RuntimeException("content.repos requires a url parameter.") + } + if (repo.target) { + if (repo.target.count('/') == 0) { + throw new RuntimeException("content.target needs / to separate namespace/group from repo name. Repo: ${repo.url}") + } + } + + switch (repo.type) { + case ContentRepoType.COPY: + if (!repo.target) { + throw new RuntimeException("content.repos.type ${ContentRepoType.COPY} requires content.repos.target to be set. Repo: ${repo.url}") + } + break + case ContentRepoType.FOLDER_BASED: + if (repo.target) { + throw new RuntimeException("content.repos.type ${ContentRepoType.FOLDER_BASED} does not support target parameter. Repo: ${repo.url}") + } + if (repo.targetRef) { + throw new RuntimeException("content.repos.type ${ContentRepoType.FOLDER_BASED} does not support targetRef parameter. Repo: ${repo.url}") + } + break + case ContentRepoType.MIRROR: + if (!repo.target) { + throw new RuntimeException("content.repos.type ${ContentRepoType.MIRROR} requires content.repos.target to be set. Repo: ${repo.url}") + } + if (repo.path != ContentRepositorySchema.DEFAULT_PATH) { + throw new RuntimeException("content.repos.type ${ContentRepoType.MIRROR} does not support path. Current path: ${repo.path}. Repo: ${repo.url}") + } + if (repo.templating) { + throw new RuntimeException("content.repos.type ${ContentRepoType.MIRROR} does not support templating. Repo: ${repo.url}") + } + break + } + } + } + + protected void deployHelmReleasesFromContent() { + if (!config.content?.helmReleases) { + log.debug("No content.helmReleases configured - skipping.") + return + } + + config.content.helmReleases.each { helmRelease -> + String version = helmRelease.version?.trim() + if (!version) { + version = "*" + } + + Config.HelmConfigWithValues helmConfig = new Config.HelmConfigWithValues(repoURL: helmRelease.repoURL, + chart: helmRelease.chart, + version: version, + values: [:] as Map // IMPORTANT: we will pass merged values via a file + ) + + Map fileValues = [:] + if (helmRelease.valuesPath?.trim()) { + // This is a plain YAML file (NOT a .ftl template) + fileValues = (fileSystemUtils.readYaml(Path.of(helmRelease.valuesPath)) ?: [:]) as Map + } + + Map inlineValues = (helmRelease.values ?: [:]) as Map + + // merge: file first, inline overrides + Map mergedValues = MapUtils.deepMerge(inlineValues, fileValues) + + // always write a temp values file and pass its path to deployHelmChart + Path mergedValuesFile = fileSystemUtils.writeTempFile(mergedValues) + + deployHelmChart(helmRelease.name, + helmRelease.releaseName ?: helmRelease.name, + helmRelease.namespace, + helmConfig, + mergedValuesFile.toString(), + config) + } + } + + void createImagePullSecrets() { + if (config.registry.createImagePullSecrets) { + String registryUsername = config.registry.readOnlyUsername ?: config.registry.username + String registryPassword = config.registry.readOnlyPassword ?: config.registry.password + + config.content.namespaces.each { String namespace -> + def registrySecretName = 'registry' + + k8sClient.createNamespace(namespace) + + k8sClient.createImagePullSecret(registrySecretName, namespace, + config.registry.url /* Only domain matters, path would be ignored */, + registryUsername, registryPassword) + + k8sClient.patch('serviceaccount', 'default', namespace, + [imagePullSecrets: [[name: registrySecretName]]]) + + if (config.registry.twoRegistries) { + k8sClient.createImagePullSecret('proxy-registry', namespace, + config.registry.proxyUrl, config.registry.proxyUsername, + config.registry.proxyPassword) + } + } + } + } + + void createContentRepos() { + if (cachedRepoCoordinates.empty) { + cachedRepoCoordinates = cloneContentRepos() + } + pushTargetRepos(cachedRepoCoordinates) + // after all, clean folders and list + clearCache() + } + + protected List cloneContentRepos() { + mergedReposFolder = File.createTempDir('gitops-playground-based-content-repos-') + List repoCoordinates = [] + + log.debug("Aggregating structure for all ${config.content.repos.size()} repos.") + config.content.repos.each { repoConfig -> createRepoCoordinates(repoConfig, mergedReposFolder, repoCoordinates) + } + return repoCoordinates + } + + private TemplatingEngine getTemplatingEngine() { + if (templatingEngine == null) { + templatingEngine = new TemplatingEngine() + } + return templatingEngine + } + + private void createRepoCoordinates(ContentRepositorySchema repoConfig, File mergedReposFolder, List repoCoordinates) { + def repoTmpDir = File.createTempDir('gitops-playground-content-repo-') + log.debug("Cloning content repo, ${repoConfig.url}, revision ${repoConfig.ref}, path ${repoConfig.path}, overwriteMode ${repoConfig.overwriteMode}") + + if (repoConfig.credentials?.username != null && repoConfig.credentials?.password != null) { + credentialsProvider = new UsernamePasswordCredentialsProvider(repoConfig.credentials.username, repoConfig.credentials.password) + } else if (repoConfig.credentials?.secretName && repoConfig.credentials?.secretNamespace) { + Credentials credentials = this.k8sClient.k8sJavaApiClient.getCredentialsFromSecret(repoConfig.credentials) + credentialsProvider = new UsernamePasswordCredentialsProvider(credentials.username, credentials.password) + } + + cloneToLocalFolder(repoConfig, repoTmpDir) + + def contentRepoDir = new File(repoTmpDir, repoConfig.path) + applyTemplatingIfApplicable(repoConfig, contentRepoDir) + + switch (repoConfig.type) { + case ContentRepoType.FOLDER_BASED: + createRepoCoordinatesForTypeFolderBased(repoConfig, repoTmpDir, contentRepoDir, mergedReposFolder, repoCoordinates) + repoTmpDir.deleteDir() + break + case ContentRepoType.COPY: + createRepoCoordinatesForTypeCopy(repoConfig, contentRepoDir, mergedReposFolder, repoTmpDir, repoCoordinates) + repoTmpDir.deleteDir() + break + case ContentRepoType.MIRROR: + createRepoCoordinateForTypeMirror(repoConfig, repoTmpDir, repoCoordinates) + // intentionally not deleting repoTmpDir, it is contained in RepoCoordinates for MIRROR usage + break + } + log.debug("Finished cloning content repos. repoCoordinates=${repoCoordinates}") + } + + private static void createRepoCoordinatesForTypeCopy( + ContentRepositorySchema repoConfig, File contentRepoDir, File mergedReposFolder, File repoTmpDir, List repoCoordinates) { + String namespace = repoConfig.target.split('/')[0] + String repoName = repoConfig.target.split('/')[1] + + def repoCoordinate = mergeRepoDirs(contentRepoDir, namespace, repoName, mergedReposFolder, repoConfig) + repoCoordinate.refIsTag = GitRepo.isTag(repoTmpDir, repoConfig.ref) + addRepoCoordinates(repoCoordinates, repoCoordinate) + } + + private static void createRepoCoordinatesForTypeFolderBased( + ContentRepositorySchema repoConfig, File repoTmpDir, File contentRepoDir, File mergedReposFolder, List repoCoordinates) { + boolean refIsTag = GitRepo.isTag(repoTmpDir, repoConfig.ref) + findRepoDirectories(contentRepoDir) + .each { contentRepoNamespaceDir -> + findRepoDirectories(contentRepoNamespaceDir) + .each { contentRepoFolder -> + String namespace = contentRepoNamespaceDir.name + String repoName = contentRepoFolder.name + def repoCoordinate = mergeRepoDirs(contentRepoFolder, namespace, repoName, mergedReposFolder, repoConfig) + repoCoordinate.refIsTag = refIsTag + addRepoCoordinates(repoCoordinates, repoCoordinate) + } + } + } + + private static void createRepoCoordinateForTypeMirror(ContentRepositorySchema repoConfig, File repoTmpDir, List repoCoordinates) { + // Don't merge but keep these in separate dirs. + // This avoids messing up .git folders with possible confusing exceptions for the user + String namespace = repoConfig.target.split('/')[0] + String repoName = repoConfig.target.split('/')[1] + def repoCoordinate = new RepoCoordinate(namespace: namespace, + repoName: repoName, + clonedContentRepo: repoTmpDir, + repoConfig: repoConfig, + refIsTag: GitRepo.isTag(repoTmpDir, repoConfig.ref)) + addRepoCoordinates(repoCoordinates, repoCoordinate) + } + + /** + * Merges the files of src into the mergeRepoFolder/namespace/name and adds a new object to repoCoordinates. + * + * Note that existing repoCoordinate objects with different overwriteMode are overwritten. The last repo to be mentioned within config.content.repos wins!*/ + private static RepoCoordinate mergeRepoDirs( + File src, String namespace, String repoName, File mergedRepoFolder, + ContentRepositorySchema repoConfig) { + File target = new File(new File(mergedRepoFolder, namespace), repoName) + log.debug("Merging content repo, namespace ${namespace}, repoName ${repoName} from ${src} to ${target}") + FileUtils.copyDirectory(src, target, new FileSystemUtils.IgnoreDotGitFolderFilter()) + + def repoCoordinate = new RepoCoordinate(namespace: namespace, + repoName: repoName, + clonedContentRepo: target, + repoConfig: repoConfig,) + return repoCoordinate + } + + private static List findRepoDirectories(File srcRepo) { + srcRepo.listFiles().findAll { + it.isDirectory() && // Exclude .git for example + !it.name.startsWith('.') + } + } + + private void applyTemplatingIfApplicable(ContentRepositorySchema repoConfig, File srcPath) { + if (repoConfig.templating) { + def engine = getTemplatingEngine() + + GitRepo repo = this.repoProvider.getRepo(repoConfig.target, this.gitHandler.tenant) + + engine.replaceTemplates(srcPath, [config : config, + scm : [baseUrl : repo.gitProvider.url, + host : repo.gitProvider.host, + protocol: repo.gitProvider.protocol, + repoUrl : repo.gitProvider.repoPrefix(),], + // Allow for using static classes inside the templates + statics: !config.content.useWhitelist ? new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_32).build().getStaticModels() : + new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, config.content.getAllowedStaticsWhitelist()).getStaticModels()]) + } + } + + private void cloneToLocalFolder(ContentRepositorySchema repoConfig, File repoTmpDir) { + + def cloneCommand = gitClone() + .setURI(repoConfig.url) + .setDirectory(repoTmpDir) + .setNoCheckout(false) + // Checkout default branch + + if (credentialsProvider) { + cloneCommand.setCredentialsProvider(credentialsProvider) + } + + def git = cloneCommand.call() + + if (ContentRepoType.MIRROR == repoConfig.type) { + def fetch = git.fetch() + + if (credentialsProvider) { + fetch.setCredentialsProvider(credentialsProvider) + } + fetch.setRefSpecs("+refs/*:refs/*").call() // Fetch all branches and tags + } + + if (repoConfig.ref) { + def actualRef = findRef(repoConfig, git.repository) + git.checkout().setName(actualRef).call() + } + } + + private static String findRef(ContentRepositorySchema repoConfig, Repository gitRepo) { + // Check if ref exists first to avoid InvalidRefNameException + // Note that this works for commits and shortname tags but not shortname branches 🙄 + if (gitRepo.resolve(repoConfig.ref)) { + return repoConfig.ref + } + + // Check branches or tags + def remoteCommand = Git.lsRemoteRepository() + .setRemote(repoConfig.url) + .setHeads(true) + .setTags(true) + + Collection refs = remoteCommand.call() + String potentialRef = refs.find { it.name.endsWith(repoConfig.ref) }?.name + + if (!potentialRef) { + // Jgit silently ignores some missing refs and just continues with default branch. + // This might lead to unexpected surprises for our users, so better fail explicitly + throw new RuntimeException("Reference '${repoConfig.ref}' not found in content repository '${repoConfig.url}'") + } + + // Jgit only checks out remote branches when they start in origin/ 🙄 + return potentialRef.replace('refs/heads/', 'origin/') + } + + private void pushTargetRepos(List repoCoordinates) { + repoCoordinates.each { repoCoordinate -> + + GitRepo targetRepo = repoProvider.getRepo(repoCoordinate.fullRepoName, this.gitHandler.tenant) + boolean isNewRepo = targetRepo.createRepositoryAndSetPermission("", false) + + if (isValidForPush(isNewRepo, repoCoordinate)) { + targetRepo.cloneRepo() + + switch (repoCoordinate.repoConfig.type) { + case ContentRepoType.MIRROR: + handleRepoMirroring(repoCoordinate, targetRepo) + break + // COPY and FOLDER_BASED same treatment + case ContentRepoType.FOLDER_BASED: + case ContentRepoType.COPY: + handleRepoCopyingOrFolderBased(repoCoordinate, targetRepo, isNewRepo) + break + } + + createJenkinsJobIfApplicable(repoCoordinate, targetRepo) + + // cleaning tmp folders + repoCoordinate.clonedContentRepo.deleteDir() + new File(targetRepo.absoluteLocalRepoTmpDir).deleteDir() + } // no else needed + } + + } + + /** + * Copies repoCoordinate to targetRepo, commits and pushes + * Same logic for both FOLDER_BASED and COPY repo types.*/ + private static void handleRepoCopyingOrFolderBased(RepoCoordinate repoCoordinate, GitRepo targetRepo, boolean isNewRepo) { + if (!isNewRepo) { + clearTargetRepoIfApplicable(repoCoordinate, targetRepo) + } + // Avoid overwriting .git in target to avoid, because we don't need it for copying and + // git pack files are typically read-only, leading to IllegalArgumentException: + // File parameter 'destFile is not writable: .git/objects/pack/pack-123.pack + targetRepo.copyDirectoryContents(repoCoordinate.clonedContentRepo.absolutePath, new FileSystemUtils.IgnoreDotGitFolderFilter()) + + String commitMessage = "Initialize content repo ${repoCoordinate.namespace}/${repoCoordinate.repoName}" + String targetRefShort = repoCoordinate.repoConfig.targetRef.replace('refs/heads/', '').replace('refs/tags/', '') + if (targetRefShort) { + String refSpec = setRefSpec(repoCoordinate, targetRefShort) + targetRepo.commitAndPush(commitMessage, targetRefShort, refSpec) + } else { + targetRepo.commitAndPush(commitMessage) + } + + } + + private static String setRefSpec(RepoCoordinate repoCoordinate, String targetRefShort) { + String refSpec + if ((repoCoordinate.refIsTag && !repoCoordinate.repoConfig.targetRef.startsWith('refs/heads')) || repoCoordinate.repoConfig.targetRef.startsWith('refs/tags')) { + refSpec = "refs/tags/${targetRefShort}:refs/tags/${targetRefShort}" + } else { + refSpec = "HEAD:refs/heads/${targetRefShort}" + } + refSpec + } + + private static void clearTargetRepoIfApplicable(RepoCoordinate repoCoordinate, GitRepo targetRepo) { + if (OverwriteMode.INIT != repoCoordinate.repoConfig.overwriteMode) { + if (OverwriteMode.RESET == repoCoordinate.repoConfig.overwriteMode) { + log.info("OverwriteMode ${OverwriteMode.RESET} set for repo '${repoCoordinate.fullRepoName}': " + + "Deleting existing files in repo and replacing them with new content.") + targetRepo.clearRepo() + } else { + log.debug("OverwriteMode ${OverwriteMode.UPGRADE} set for repo '${repoCoordinate.fullRepoName}': " + "Merging new content into existing repo. ") + } + } + } + + /** + * Force pushes repoCoordinate.repoConfig.ref or all refs to targetRepo*/ + private static void handleRepoMirroring(RepoCoordinate repoCoordinate, GitRepo targetRepo) { + try (def targetGit = Git.open(new File(targetRepo.absoluteLocalRepoTmpDir))) { + def remoteUrl = targetGit.repository.config.getString('remote', 'origin', 'url') + + // In mirror mode, we mainly need the .git folder to push the whole git history, branches and tags. + // So copying source to target repo, .git folders are merged. + // git pack files are typically read-only, leading to + // IllegalArgumentException: File parameter 'destFile is not writable: .git/objects/pack/pack-123.pack + // Workaround: make .git writable. + // Note: Setting target remote in source repo and pushing from there causes other problems like + // IOException: Source ref someBranch doesn't resolve to any object. + FileSystemUtils.makeWritable(new File(targetRepo.absoluteLocalRepoTmpDir, '.git')) + + targetRepo.copyDirectoryContents(repoCoordinate.clonedContentRepo.absolutePath) + + // Restore remote, it could have been overwritten due to a copied .git folder in MIRROR mode + targetGit.repository.config.setString('remote', 'origin', 'url', remoteUrl) + targetGit.repository.config.save() + } + + if (repoCoordinate.repoConfig.ref) { + validateCommitReferences(repoCoordinate) + if (repoCoordinate.repoConfig.targetRef) { + log.debug("Mirroring repo '${repoCoordinate.repoConfig.url}' ref '${repoCoordinate.repoConfig.ref}' to target repo ${repoCoordinate.fullRepoName}, targetRef: '${repoCoordinate.repoConfig.targetRef}'") + targetRepo.pushRef(repoCoordinate.repoConfig.ref, repoCoordinate.repoConfig.targetRef, true) + } else { + log.debug("Mirroring repo '${repoCoordinate.repoConfig.url}' ref '${repoCoordinate.repoConfig.ref}' to target repo ${repoCoordinate.fullRepoName}") + targetRepo.pushRef(repoCoordinate.repoConfig.ref, true) + } + } else { + log.debug("Mirroring whole repo '${repoCoordinate.repoConfig.url}' to target repo ${repoCoordinate.fullRepoName}") + targetRepo.pushAll(true) + } + } + + private static void validateCommitReferences(RepoCoordinate repoCoordinate) { + if (GitRepo.isCommit(repoCoordinate.clonedContentRepo, repoCoordinate.repoConfig.ref)) { + // Mirroring detached commits does not make a lot of sense and is complicated + // We would have to branch, push, delete remote branch. Considering this an edge case at the moment! + throw new RuntimeException("Mirroring commit references is not supported for content repos at the moment. content repository '${repoCoordinate.repoConfig.url}', ref: ${repoCoordinate.repoConfig.ref}") + } + } + + private void createJenkinsJobIfApplicable(RepoCoordinate repoCoordinate, GitRepo repo) { + if (repoCoordinate.repoConfig.createJenkinsJob && jenkins.isEnabled()) { + if (GitRepo.existFileInSomeBranch(repo.absoluteLocalRepoTmpDir, 'Jenkinsfile')) { + jenkins.createJenkinsjob(repoCoordinate.namespace, repoCoordinate.namespace) + } + } + } + + /** + * Overwrite for testing purposes*/ + protected CloneCommand gitClone() { + Git.cloneRepository() + } + + /** + * Add new repoCoordinates to repos and ensure, newest one override last one. + * Except for MIRROR, which will have to run separately from COPY/FOLDER_BASED in order to allow overriding by COPY/FOLDER_BASED repoCoordinates for the same repo.*/ + static void addRepoCoordinates(List repoCoordinates, RepoCoordinate newRepoCoordinate) { + def existingRepoCoordinates = newRepoCoordinate.findSame(repoCoordinates) + + if (!existingRepoCoordinates.isEmpty()) { + log.debug("Found existing repo coordinates for ${newRepoCoordinate}: ${existingRepoCoordinates}") + + // Don't replace MIRROR coordinates, they are separate git operations + def repoCoordinateToOverwrite = newRepoCoordinate.findSameNotMirror(existingRepoCoordinates) + if (repoCoordinateToOverwrite) { + repoCoordinates.remove(repoCoordinateToOverwrite) + log.debug("Replacing existing repo coordinate ${existingRepoCoordinates} with new one: ${newRepoCoordinate}") + } + } + repoCoordinates << newRepoCoordinate + } + + /** + * Checks whether the repo already exists and overwrite Mode matches.*/ + static boolean isValidForPush(boolean isNewRepo, RepoCoordinate repoCoordinate) { + + if (!isNewRepo && OverwriteMode.INIT == repoCoordinate.repoConfig.overwriteMode) { + log.warn("OverwriteMode ${OverwriteMode.INIT} set for repo '${repoCoordinate.fullRepoName}' " + "and repo already exists in target: Not pushing content!" + + "If you want to override, set ${OverwriteMode.UPGRADE} or ${OverwriteMode.RESET} .") + return false + } + return true + } + + private void clearCache() { + if (mergedReposFolder) { + mergedReposFolder.deleteDir() + } + cachedRepoCoordinates.clear() + mergedReposFolder = null + } + + static class RepoCoordinate { + String namespace + String repoName + File clonedContentRepo + ContentRepositorySchema repoConfig + boolean refIsTag + + @Override + String toString() { + return "RepoCoordinates{ namespace='$namespace', repoName='$repoName', repoConfig.type='${repoConfig.type}', repoConfig.overwriteMode='${repoConfig.overwriteMode}', clonedContentRepo=$clonedContentRepo', refIsTag='${refIsTag}' }" + } + + String getFullRepoName() { + return "${namespace}/${repoName}" + } + + /** + * @return all epoCoordinate with the same fullRepoName. There can be one with either COPY/FOLDER_BASED and many MIRRORs. + */ + List findSame(List repoCoordinates) { + repoCoordinates.findAll() { it.fullRepoName == fullRepoName } + } + + /** + * @return RepoCoordinate with the same fullRepoName and repoConfig.type not MIRROR. There can only ever be one! + */ + RepoCoordinate findSameNotMirror(List repoCoordinates) { + repoCoordinates.find() { + it.fullRepoName == fullRepoName && ContentRepoType.MIRROR != it.repoConfig.type + } + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/ExternalSecretsOperator.groovy b/src/main/groovy/com/cloudogu/gitops/features/ExternalSecretsOperator.groovy index 4ae974dd8..ffcb01358 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/ExternalSecretsOperator.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/ExternalSecretsOperator.groovy @@ -3,7 +3,7 @@ package com.cloudogu.gitops.features import com.cloudogu.gitops.Feature import com.cloudogu.gitops.FeatureWithImage import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.AirGappedUtils @@ -28,7 +28,7 @@ class ExternalSecretsOperator extends Feature implements FeatureWithImage { ExternalSecretsOperator( Config config, FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer, + Deployer deployer, K8sClient k8sClient, AirGappedUtils airGappedUtils, GitHandler gitHandler) { diff --git a/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy b/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy index d6e1ee69b..b769a9887 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy @@ -3,7 +3,7 @@ package com.cloudogu.gitops.features import com.cloudogu.gitops.Feature import com.cloudogu.gitops.FeatureWithImage import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.AirGappedUtils @@ -28,7 +28,7 @@ class Ingress extends Feature implements FeatureWithImage { Ingress( Config config, FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer, + Deployer deployer, K8sClient k8sClient, AirGappedUtils airGappedUtils, GitHandler gitHandler) { diff --git a/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy b/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy index d5add725f..24d1b4292 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy @@ -3,7 +3,7 @@ package com.cloudogu.gitops.features import com.cloudogu.gitops.Feature import com.cloudogu.gitops.FeatureWithImage import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.git.GitRepoFactory @@ -38,7 +38,7 @@ class Monitoring extends Feature implements FeatureWithImage { Monitoring( Config config, FileSystemUtils fileSystemUtils, - DeploymentStrategy deployer, + Deployer deployer, K8sClient k8sClient, AirGappedUtils airGappedUtils, GitRepoFactory scmRepoProvider, diff --git a/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy b/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy index c4daf0774..9df284645 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy @@ -3,7 +3,7 @@ package com.cloudogu.gitops.features import com.cloudogu.gitops.Feature import com.cloudogu.gitops.FeatureWithImage import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.AirGappedUtils @@ -30,7 +30,7 @@ class Vault extends Feature implements FeatureWithImage { Config config, FileSystemUtils fileSystemUtils, K8sClient k8sClient, - DeploymentStrategy deployer, + Deployer deployer, AirGappedUtils airGappedUtils, GitHandler gitHandler) { this.deployer = deployer diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy index 63d19dc92..2ed599140 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy @@ -5,7 +5,6 @@ import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepoFactory -import com.cloudogu.gitops.kubernetes.api.HelmClient import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.kubernetes.rbac.RbacDefinition import com.cloudogu.gitops.kubernetes.rbac.Role @@ -30,7 +29,6 @@ class ArgoCD extends Feature { private final String namespace private final Config config private final K8sClient k8sClient - private final HelmClient helmClient private final GitRepoFactory repoProvider private final GitHandler gitHandler private final String password @@ -41,7 +39,6 @@ class ArgoCD extends Feature { ArgoCD( Config config, K8sClient k8sClient, - HelmClient helmClient, Deployer deployer, FileSystemUtils fileSystemUtils, GitRepoFactory repoProvider, @@ -49,7 +46,6 @@ class ArgoCD extends Feature { this.repoProvider = repoProvider this.config = config this.k8sClient = k8sClient - this.helmClient = helmClient this.deployer = deployer this.fileSystemUtils = fileSystemUtils this.gitHandler = gitHandler @@ -207,11 +203,12 @@ rm -Rf ../argocd-operator/ private void deployWithHelm() { - helmClient.addRepo('argo', this.config.features.argocd.helm.repoURL) - helmClient.upgrade('argocd', "argo/argo-cd", [namespace: namespace]) - - // addHelmValuesData('argocd', [host: config.features.argocd.url ? new URL(config.features.argocd.url).host : '']) - // deployHelmChart(this.namespace, this.namespace, namespace, config.features.argocd.helm, HELM_VALUES_PATH, config, true) + deployHelmChart('argo-cd', + 'argo-cd', + namespace, + config.features.argocd.helm, + HELM_VALUES_PATH, + config) log.debug("Setting new argocd admin password") // Set admin password imperatively here instead of values.yaml, because we don't want it to show in git repo diff --git a/src/main/groovy/com/cloudogu/gitops/features/deployment/Deployer.groovy b/src/main/groovy/com/cloudogu/gitops/features/deployment/Deployer.groovy index 5646914d7..36b424b7a 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/deployment/Deployer.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/deployment/Deployer.groovy @@ -1,26 +1,23 @@ package com.cloudogu.gitops.features.deployment -import com.cloudogu.gitops.config.Config - -import io.micronaut.context.annotation.Primary +import com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType import java.nio.file.Path +import jakarta.inject.Provider import jakarta.inject.Singleton @Singleton -@Primary -class Deployer implements DeploymentStrategy { - Config config - ArgoCdApplicationStrategy argoCdStrategy +class Deployer { + + Provider argoCdStrategyProvider + HelmStrategy helmStrategy - Deployer(Config config, ArgoCdApplicationStrategy argoCdStrategy, HelmStrategy helmStrategy) { + Deployer(Provider argoCdStrategyProvider, HelmStrategy helmStrategy) { + this.argoCdStrategyProvider = argoCdStrategyProvider this.helmStrategy = helmStrategy - this.argoCdStrategy = argoCdStrategy - this.config = config } - @Override void deployFeature( String repoURL, String repoName, String chartOrPath, String version, String namespace, String releaseName, Path helmValuesPath, RepoType repoType, boolean initByHelm = false) { @@ -28,10 +25,6 @@ class Deployer implements DeploymentStrategy { if (initByHelm) { helmStrategy.deployFeature(repoURL, repoName, chartOrPath, version, namespace, releaseName, helmValuesPath, repoType) } - - if (config.features['argocd']['active']) { - argoCdStrategy.deployFeature(repoURL, repoName, chartOrPath, version, namespace, releaseName, helmValuesPath, repoType) - } + argoCdStrategyProvider.get().deployFeature(repoURL, repoName, chartOrPath, version, namespace, releaseName, helmValuesPath, repoType) } - } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy index 5ff6aae03..ae9b4b00b 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy @@ -13,8 +13,6 @@ import com.cloudogu.gitops.utils.NetworkingUtils import io.micronaut.core.annotation.Order -import jakarta.inject.Inject -import jakarta.inject.Provider import jakarta.inject.Singleton import groovy.util.logging.Slf4j @@ -26,16 +24,17 @@ class GitHandler extends Feature { Config config NetworkingUtils networkingUtils - @Inject - Provider deployerProvider + + Deployer deployer FileSystemUtils fileSystemUtils K8sClient k8sClient GitProvider tenant GitProvider central - GitHandler(Config config, FileSystemUtils fileSystemUtils, K8sClient k8sClient, NetworkingUtils networkingUtils) { + GitHandler(Config config, Deployer deployer, FileSystemUtils fileSystemUtils, K8sClient k8sClient, NetworkingUtils networkingUtils) { this.config = config + this.deployer = deployer this.fileSystemUtils = fileSystemUtils this.k8sClient = k8sClient this.networkingUtils = networkingUtils diff --git a/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy b/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy index 6430559e6..9f723cdc9 100644 --- a/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy @@ -1,639 +1,581 @@ package com.cloudogu.gitops +import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable +import static groovy.test.GroovyAssert.shouldFail +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.ApplicationConfigurator import com.cloudogu.gitops.config.CommonFeatureConfig import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.ContentLoader import com.cloudogu.gitops.features.Jenkins import com.cloudogu.gitops.features.argocd.ArgoCD -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.features.git.config.ScmTenantSchema import com.cloudogu.gitops.git.GitRepoFactory -import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.HelmClient import com.cloudogu.gitops.kubernetes.api.K8sClient +import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.TestLogger import com.cloudogu.gitops.utils.git.GitHandlerForTests import com.cloudogu.gitops.utils.git.ScmManagerMock + import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.Mock import org.mockito.Mockito -import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable -import static groovy.test.GroovyAssert.shouldFail -import static org.assertj.core.api.Assertions.assertThat - class ApplicationConfiguratorTest { - static final String EXPECTED_REGISTRY_URL = 'http://my-reg' - static final int EXPECTED_REGISTRY_INTERNAL_PORT = 33333 - static final Config.VaultMode EXPECTED_VAULT_MODE = Config.VaultMode.dev - public static final String EXPECTED_JENKINS_URL = 'http://my-jenkins' - public static final String EXPECTED_SCMM_URL = 'http://my-scmm' - - private ApplicationConfigurator applicationConfigurator - private FileSystemUtils fileSystemUtils - private TestLogger testLogger - private CommonFeatureConfig commonFeatureConfig - private ContentLoader featureContent - private ArgoCD featureArgoCd - - @Mock - ScmManagerMock scmManagerMock = new ScmManagerMock() - - Config testConfig = Config.fromMap([ - application: [ - localHelmChartFolder: 'someValue', - namePrefix : '' - ], - registry : [ - url : EXPECTED_REGISTRY_URL, - proxyUrl : "proxy-$EXPECTED_REGISTRY_URL", - proxyUsername: "proxy-user", - proxyPassword: "proxy-pw", - internalPort : EXPECTED_REGISTRY_INTERNAL_PORT, - ], - jenkins : [ - url: EXPECTED_JENKINS_URL - ], - scm : [ - scmManager: [ - url: EXPECTED_SCMM_URL - ], - ], - multiTenant: [ - scmManager: [ - url: '' - ] - ], - features : [ - secrets: [ - vault: [ - mode: EXPECTED_VAULT_MODE - ] - ], - ] - ]) - -// // We have to set this value using env vars, which makes tests complicated, so ignore it -// Config almostEmptyConfig = Config.fromMap([ -// application: [ -// localHelmChartFolder: 'someValue', -// ], -// ]) - - @BeforeEach - void setup() { - fileSystemUtils = new FileSystemUtils() - applicationConfigurator = new ApplicationConfigurator(fileSystemUtils) - testLogger = new TestLogger(applicationConfigurator.getClass()) - commonFeatureConfig = new CommonFeatureConfig() - - K8sClient k8sClient = Mockito.mock(K8sClient) - HelmClient helmClient = Mockito.mock(HelmClient) - GitRepoFactory gitRepoFactory = Mockito.mock(GitRepoFactory) - - DeploymentStrategy deploymentStrategy = Mockito.mock(DeploymentStrategy) - - - GitHandler gitHandler = new GitHandlerForTests(testConfig, scmManagerMock) - featureContent = Mockito.spy(new ContentLoader(testConfig, k8sClient, gitRepoFactory, Mockito.mock(Jenkins), gitHandler, fileSystemUtils, deploymentStrategy)) - featureArgoCd = Mockito.spy(new ArgoCD(testConfig, k8sClient, helmClient, fileSystemUtils, gitRepoFactory, gitHandler)) - } - - @Test - void "correct config with no programm arguments"() { - - def actualConfig = applicationConfigurator.initConfig(testConfig) - - assertThat(actualConfig.jenkins.url).isEqualTo(EXPECTED_JENKINS_URL) - assertThat(actualConfig.jenkins.internal).isEqualTo(false) - assertThat(actualConfig.features.secrets.vault.mode).isEqualTo(EXPECTED_VAULT_MODE) - - // Dynamic value (depends on vault mode) - assertThat(actualConfig.features.secrets.active).isEqualTo(true) - } - - @Test - void "sets config application runningInsideK8s"() { - withEnvironmentVariable("KUBERNETES_SERVICE_HOST", "127.0.0.1").execute { - Config actualConfig = applicationConfigurator.initConfig(testConfig) - assertThat(actualConfig.application.runningInsideK8s).isEqualTo(true) - } - } - - @Test - void 'Sets jenkins active if external url is set'() { - testConfig.jenkins.url = 'external' - def actualConfig = applicationConfigurator.initConfig(testConfig) - assertThat(actualConfig.jenkins.active).isEqualTo(true) - } - - @Test - void 'Leaves Jenkins urlForScmm empty, if not active'() { - testConfig.jenkins.url = '' - testConfig.jenkins.active = false - - def actualConfig = applicationConfigurator.initConfig(testConfig) - assertThat(actualConfig.jenkins.urlForScm).isEmpty() - } - - @Test - void 'Fails if monitoring local is not set'() { - testConfig.application.mirrorRepos = true - testConfig.application.localHelmChartFolder = '' - - def exception = shouldFail(RuntimeException) { - commonFeatureConfig.validateConfig(testConfig) - } - assertThat(exception.message).isEqualTo('Missing config for localHelmChartFolder.\n' + - 'Either run inside the official container image or setting env var LOCAL_HELM_CHART_FOLDER=\'charts\' ' + - 'after running \'scripts/downloadHelmCharts.sh\' from the repo') - } - - @Test - void 'Fails if createImagePullSecrets is used without secrets'() { - testConfig.registry.createImagePullSecrets = true - - def exception = shouldFail(RuntimeException) { - applicationConfigurator.initConfig(testConfig) - } - assertThat(exception.message).isEqualTo('createImagePullSecrets needs to be used with either registry username and password or the readOnly variants') - } - - @Test - void 'Fails if content repo is set without mandatory params'() { - - testConfig.content.repos = [ - new Config.ContentSchema.ContentRepositorySchema(url: ''), - ] - def exception = shouldFail(RuntimeException) { - featureContent.preConfigInit(testConfig) - } - assertThat(exception.message).isEqualTo('content.repos requires a url parameter.') - - - testConfig.content.repos = [ - new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.COPY, target: "missing_slash"), - ] - exception = shouldFail(RuntimeException) { - featureContent.preConfigInit(testConfig) - } - assertThat(exception.message).isEqualTo('content.target needs / to separate namespace/group from repo name. Repo: abc') - } - - @Test - void 'Fails if COPY repo misses target parameter'() { - testConfig.content.repos = [ - new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.COPY), - ] - def exception = shouldFail(RuntimeException) { - featureContent.preConfigInit(testConfig) - } - assertThat(exception.message).isEqualTo('content.repos.type COPY requires content.repos.target to be set. Repo: abc') - } - - @Test - void 'Fails if FOLDER_BASED repo has target parameter'() { - testConfig.content.repos = [ - new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.FOLDER_BASED, target: 'namespace/repo'), - ] - def exception = shouldFail(RuntimeException) { - featureContent.preConfigInit(testConfig) - } - assertThat(exception.message).isEqualTo('content.repos.type FOLDER_BASED does not support target parameter. Repo: abc') - - - testConfig.content.repos = [ - new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.FOLDER_BASED, targetRef: 'someRef'), - ] - exception = shouldFail(RuntimeException) { - featureContent.preConfigInit(testConfig) - } - assertThat(exception.message).isEqualTo('content.repos.type FOLDER_BASED does not support targetRef parameter. Repo: abc') - } - - @Test - void 'Fails if MIRROR repo has invalid configuration'() { - // Test missing target parameter - testConfig.content.repos = [ - new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.MIRROR), - ] - def exception = shouldFail(RuntimeException) { - featureContent.preConfigInit(testConfig) - } - assertThat(exception.message).isEqualTo('content.repos.type MIRROR requires content.repos.target to be set. Repo: abc') - - // Test setting path - testConfig.content.repos = [ - new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.MIRROR, - target: 'namespace/repo', path: 'non-default-path'), - ] - exception = shouldFail(RuntimeException) { - featureContent.preConfigInit(testConfig) - } - assertThat(exception.message).isEqualTo("content.repos.type MIRROR does not support path. Current path: non-default-path. Repo: abc") - - // Test templating enabled - testConfig.content.repos = [ - new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.MIRROR, - target: 'namespace/repo', templating: true), - ] - exception = shouldFail(RuntimeException) { - featureContent.preConfigInit(testConfig) - } - assertThat(exception.message).isEqualTo('content.repos.type MIRROR does not support templating. Repo: abc') - } - - @Test - void 'Ignores empty localHemlChartFolder, if mirrorRepos is not set'() { - testConfig.application.mirrorRepos = false - testConfig.application.localHelmChartFolder = '' - - applicationConfigurator.initConfig(testConfig) - // no exceptions means success - } - - @Test - void "base url: evaluates for all tools"() { - testConfig.application.baseUrl = 'http://localhost' - - testConfig.features.argocd.active = true - testConfig.features.monitoring.active = true - testConfig.features.secrets.active = true - - Config actualConfig = applicationConfigurator.initConfig(testConfig) - - assertThat(actualConfig.features.argocd.url).isEqualTo("http://argocd.localhost") - assertThat(actualConfig.features.monitoring.grafanaUrl).isEqualTo("http://grafana.localhost") - assertThat(actualConfig.features.secrets.vault.url).isEqualTo("http://vault.localhost") - assertThat(actualConfig.scm.scmManager.ingress).isEqualTo("scmm.localhost") - assertThat(actualConfig.jenkins.ingress).isEqualTo("jenkins.localhost") - } - - @Test - void "base url with url-hyphens: evaluates for all tools"() { - testConfig.application.baseUrl = 'http://localhost' - testConfig.application.urlSeparatorHyphen = true - - testConfig.features.argocd.active = true - testConfig.features.monitoring.active = true - testConfig.features.secrets.active = true - - def actualConfig = applicationConfigurator.initConfig(testConfig) - - assertThat(actualConfig.features.argocd.url).isEqualTo("http://argocd-localhost") - assertThat(actualConfig.features.monitoring.grafanaUrl).isEqualTo("http://grafana-localhost") - assertThat(actualConfig.features.secrets.vault.url).isEqualTo("http://vault-localhost") - assertThat(actualConfig.scm.scmManager.ingress).isEqualTo("scmm-localhost") - assertThat(actualConfig.jenkins.ingress).isEqualTo("jenkins-localhost") - } - - @Test - void "base url: also works when port is included "() { - testConfig.application.baseUrl = 'http://localhost:8080' - testConfig.features.argocd.active = true - - def actualConfig = applicationConfigurator.initConfig(testConfig) - - assertThat(actualConfig.features.argocd.url).isEqualTo("http://argocd.localhost:8080") - } - - @Test - void "base url: also works when port is included and use url-hyphens is set"() { - testConfig.application.baseUrl = 'http://localhost:6502' - testConfig.features.argocd.active = true - testConfig.application.urlSeparatorHyphen = true - - def actualConfig = applicationConfigurator.initConfig(testConfig) - - assertThat(actualConfig.features.argocd.url).isEqualTo("http://argocd-localhost:6502") - } - - - @Test - void "base url: does not evaluate for inactive tools"() { - testConfig.features.argocd.active = false - testConfig.features.mail.active = false - testConfig.features.monitoring.active = false - testConfig.features.secrets.active = false - - - def actualConfig = applicationConfigurator.initConfig(testConfig) - - assertThat(actualConfig.features.argocd.url).isEqualTo('') - assertThat(actualConfig.features.monitoring.grafanaUrl).isEqualTo('') - assertThat(actualConfig.features.secrets.vault.url).isEqualTo('') - } - - @Test - void "base url: individual url params take precedence"() { - testConfig.application.baseUrl = 'http://localhost' - - testConfig.features.argocd.active = true - testConfig.features.mail.active = true - testConfig.features.monitoring.active = true - testConfig.features.secrets.active = true - - testConfig.features.argocd.url = 'argocd' - testConfig.features.monitoring.grafanaUrl = 'grafana' - testConfig.features.secrets.vault.url = 'vault' - - def actualConfig = applicationConfigurator.initConfig(testConfig) - - assertThat(actualConfig.features.argocd.url).isEqualTo("argocd") - assertThat(actualConfig.features.monitoring.grafanaUrl).isEqualTo("grafana") - assertThat(actualConfig.features.secrets.vault.url).isEqualTo("vault") - } - - @Test - void "Sets namePrefix"() { - testConfig.application.namePrefix = 'my-prefix' - - def actualConfig = applicationConfigurator.initConfig(testConfig) - assertThat(actualConfig.application.namePrefix.toString()).isEqualTo('my-prefix-') - assertThat(actualConfig.application.namePrefixForEnvVars.toString()).isEqualTo('MY_PREFIX_') - } - - @Test - void "Sets namePrefix when ending in hyphen"() { - testConfig.application.namePrefix = 'my-prefix-' - - def actualConfig = applicationConfigurator.initConfig(testConfig) - assertThat(actualConfig.application.namePrefix.toString()).isEqualTo('my-prefix-') - assertThat(actualConfig.application.namePrefixForEnvVars.toString()).isEqualTo('MY_PREFIX_') - } - - @Test - void "Registry: Sets to external when only registry URL set"() { - testConfig.registry.proxyUrl = null - - def actualConfig = applicationConfigurator.initConfig(testConfig) - - assertThat(actualConfig.registry.internal).isEqualTo(false) - assertThat(actualConfig.registry.active).isEqualTo(true) - } - - @Test - void "Registry: Fails when proxy but no username and password set"() { - def expectedException = 'Proxy URL needs to be used with proxy-username and proxy-password' - - testConfig.registry.proxyUsername = null - def exception = shouldFail(RuntimeException) { - applicationConfigurator.initConfig(testConfig) - } - assertThat(exception.message).isEqualTo(expectedException) - - testConfig.registry.proxyUsername = 'something' - testConfig.registry.proxyPassword = null - exception = shouldFail(RuntimeException) { - applicationConfigurator.initConfig(testConfig) - } - assertThat(exception.message).isEqualTo(expectedException) - - testConfig.registry.proxyUsername = null - exception = shouldFail(RuntimeException) { - applicationConfigurator.initConfig(testConfig) - } - assertThat(exception.message).isEqualTo(expectedException) - } - - @Test - void "validateEnvConfig allows valid env entries"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' - testConfig.features.argocd.env = [ - [name: "ENV_VAR_1", value: "value1"], - [name: "ENV_VAR_2", value: "value2"] - ] as List> - - // No exception should be thrown - applicationConfigurator.initConfig(testConfig) - } - - @Test - void "validateEnvConfig throws exception for missing 'name' in env entry"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' - testConfig.features.argocd.env = [ - [name: "ENV_VAR_1", value: "value1"], - [value: "value2"] // Missing 'name' - ] as List> - - def exception = shouldFail(IllegalArgumentException) { - applicationConfigurator.initConfig(testConfig) - featureArgoCd.postConfigInit(testConfig) - } - - assertThat(exception.message).contains("Each env variable in features.argocd.env must be a map with 'name' and 'value'. Invalid entry found: [value:value2]") - } - - @Test - void "validateEnvConfig throws exception for missing 'value' in env entry"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' - testConfig.features.argocd.env = [ - [name: "ENV_VAR_1", value: "value1"], - [name: "ENV_VAR_2"] // Missing 'value' - ] as List> - - def exception = shouldFail(IllegalArgumentException) { - applicationConfigurator.initConfig(testConfig) - featureArgoCd.postConfigInit(testConfig) - } - - assertThat(exception.message).contains("Each env variable in features.argocd.env must be a map with 'name' and 'value'. Invalid entry found: [name:ENV_VAR_2]") - } - - @Test - void "validateEnvConfig throws exception for non-map env entry"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' - testConfig.features.argocd.env = [ - [name: "ENV_VAR_1", value: "value1"], - "invalid_entry" // Invalid entry - ] as List> - - def exception = shouldFail(IllegalArgumentException) { - applicationConfigurator.initConfig(testConfig) - featureArgoCd.postConfigInit(testConfig) - } - - assertThat(exception.message).contains("Each env variable in features.argocd.env must be a map with 'name' and 'value'. Invalid entry found: invalid_entry") - } - - @Test - void "validateEnvConfig allows empty env list"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' - testConfig.features.argocd.env - - // No exception should be thrown - applicationConfigurator.initConfig(testConfig) - } - - @Test - void "validateEnvConfig skips validation when operator is false"() { - testConfig.features.argocd.operator = false - testConfig.features.argocd.env = [ - [name: "ENV_VAR_1", value: "value1"], - [value: "value2"] // Invalid entry, but should be ignored - ] as List> - - // No exception should be thrown - applicationConfigurator.initConfig(testConfig) - } - - @Test - void "should skip resourceInclusionsCluster setup when ArgoCD operator is not enabled"() { - testConfig.features.argocd.operator = false - - // Calling the method should not make any changes to the config - applicationConfigurator.initConfig(testConfig) - - assertThat(testLogger.getLogs().search("ArgoCD operator is not enabled. Skipping features.argocd.resourceInclusionsCluster setup.")) - .isNotEmpty() - } - - @Test - void "should validate and accept user-provided valid resourceInclusionsCluster URL"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = "https://valid-url.com" - - // Calling the method should accept the valid URL and not throw any exception - applicationConfigurator.initConfig(testConfig) - - assertThat(testConfig.features.argocd.resourceInclusionsCluster).isEqualTo("https://valid-url.com") - assertThat(testLogger.getLogs().search("Validating user-provided features.argocd.resourceInclusionsCluster URL: https://valid-url.com")) - .isNotEmpty() - assertThat(testLogger.getLogs().search("Found valid URL in features.argocd.resourceInclusionsCluster: https://valid-url.com")) - .isNotEmpty() - } - - @Test - void "should throw exception for user-provided invalid resourceInclusionsCluster URL"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = "invalid-url" - - def exception = shouldFail(IllegalArgumentException) { - applicationConfigurator.initConfig(testConfig) - } - - assertThat(exception.message).contains("Invalid URL for 'features.argocd.resourceInclusionsCluster': invalid-url.") - } - - @Test - void "should set resourceInclusionsCluster using Kubernetes ENV variables when not provided by user"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = null - - // Set Kubernetes ENV variables - withEnvironmentVariable("KUBERNETES_SERVICE_HOST", "127.0.0.1") - .and("KUBERNETES_SERVICE_PORT", "6443") - .execute { - Config actualConfig = applicationConfigurator.initConfig(testConfig) - - assertThat(actualConfig.features.argocd.resourceInclusionsCluster).isEqualTo("https://127.0.0.1:6443") - - assertThat(testLogger.getLogs().search("Successfully set features.argocd.resourceInclusionsCluster via Kubernetes ENV to: https://127.0.0.1:6443")) - .isNotEmpty() - } - } - - @Test - void "MultiTenant Mode Central SCM Url"() { - testConfig.multiTenant.scmManager.url = "scmm.localhost/scm" - testConfig.application.namePrefix = "foo" - applicationConfigurator.initConfig(testConfig) - assertThat(testConfig.multiTenant.scmManager.url).toString() == "scmm.localhost/scm/" - } - - @Test - void "should throw exception when Kubernetes ENV variables are not set and resourceInclusionsCluster is null"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = null - - def exception = shouldFail(RuntimeException) { - applicationConfigurator.initConfig(testConfig) - } - - assertThat(exception.message).contains("Could not determine 'features.argocd.resourceInclusionsCluster' which is required when argocd.operator=true. Ensure Kubernetes environment variables 'KUBERNETES_SERVICE_HOST' and 'KUBERNETES_SERVICE_PORT' are set properly.") - } - - @Test - void "should throw exception when Kubernetes ENV variables are not set and resourceInclusionsCluster is empty"() { - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = '' - - def exception = shouldFail(RuntimeException) { - applicationConfigurator.initConfig(testConfig) - } - - assertThat(exception.message).contains("Could not determine 'features.argocd.resourceInclusionsCluster' which is required when argocd.operator=true. Ensure Kubernetes environment variables 'KUBERNETES_SERVICE_HOST' and 'KUBERNETES_SERVICE_PORT' are set properly.") - } - - @Test - void "should throw exception for invalid Kubernetes constructed URL"() { - // Set ArgoCD operator to true - testConfig.features.argocd.operator = true - testConfig.features.argocd.resourceInclusionsCluster = null - - // Set invalid Kubernetes ENV variables - withEnvironmentVariable("KUBERNETES_SERVICE_HOST", "invalid_host") - .and("KUBERNETES_SERVICE_PORT", "not_a_port") - .execute { - def exception = shouldFail(RuntimeException) { - applicationConfigurator.initConfig(testConfig) - } - - assertThat(exception.message).contains("Could not determine 'features.argocd.resourceInclusionsCluster' which is required when argocd.operator=true.") - } - - assertThat(testLogger.getLogs().search("Constructed internal Kubernetes API Server URL: https://invalid_host:not_a_port")).isNotEmpty() - } - - List getAllFieldNames(Class clazz, String parentField = '', List fieldNames = []) { - clazz.declaredFields.each { field -> - def currentField = parentField + field.name - if (field.type instanceof Class - && !field.type.isArray() - && field.type.name.startsWith(Config.class.getPackageName())) { - println "nested class $field.type, $currentField + '.', $fieldNames" - getAllFieldNames(field.type, currentField + '.', fieldNames) - } else { - if (!field.name.startsWith('_') && !field.name.startsWith('$') && field.name != 'metaClass') { - fieldNames.add(currentField) - } - } - } - return fieldNames - } - - List getAllKeys(Map map, String parentKey = '', List keysList = []) { - map.each { key, value -> - def currentKey = parentKey + key - if (value instanceof Map && !value.isEmpty()) { - getAllKeys(value, currentKey + '.', keysList) - } else { - keysList.add(currentKey) - } - } - return keysList - } - - private static Config minimalConfig() { - def config = new Config() - config.application = new Config.ApplicationSchema( - localHelmChartFolder: 'someValue', - namePrefix: '' - ) - config.scm = new ScmTenantSchema( - scmManager: new ScmTenantSchema.ScmManagerTenantConfig( - url: '' - ) - ) - return config - } -} + static final String EXPECTED_REGISTRY_URL = 'http://my-reg' + static final int EXPECTED_REGISTRY_INTERNAL_PORT = 33333 + static final Config.VaultMode EXPECTED_VAULT_MODE = Config.VaultMode.dev + public static final String EXPECTED_JENKINS_URL = 'http://my-jenkins' + public static final String EXPECTED_SCMM_URL = 'http://my-scmm' + + private ApplicationConfigurator applicationConfigurator + private FileSystemUtils fileSystemUtils + private TestLogger testLogger + private CommonFeatureConfig commonFeatureConfig + private ContentLoader featureContent + private ArgoCD featureArgoCd + + @Mock + ScmManagerMock scmManagerMock = new ScmManagerMock() + + Config testConfig = Config.fromMap([application: [localHelmChartFolder: 'someValue', + namePrefix : ''], + registry : [url : EXPECTED_REGISTRY_URL, + proxyUrl : "proxy-$EXPECTED_REGISTRY_URL", + proxyUsername: "proxy-user", + proxyPassword: "proxy-pw", + internalPort : EXPECTED_REGISTRY_INTERNAL_PORT,], + jenkins : [url: EXPECTED_JENKINS_URL], + scm : [scmManager: [url: EXPECTED_SCMM_URL],], + multiTenant: [scmManager: [url: '']], + features : [secrets: [vault: [mode: EXPECTED_VAULT_MODE]],]]) + + // // We have to set this value using env vars, which makes tests complicated, so ignore it + // Config almostEmptyConfig = Config.fromMap([ + // application: [ + // localHelmChartFolder: 'someValue', + // ], + // ]) + + @BeforeEach + void setup() { + fileSystemUtils = new FileSystemUtils() + applicationConfigurator = new ApplicationConfigurator(fileSystemUtils) + testLogger = new TestLogger(applicationConfigurator.getClass()) + commonFeatureConfig = new CommonFeatureConfig() + + K8sClient k8sClient = Mockito.mock(K8sClient) + GitRepoFactory gitRepoFactory = Mockito.mock(GitRepoFactory) + + Deployer deployer = Mockito.mock(Deployer) + + GitHandler gitHandler = new GitHandlerForTests(testConfig, scmManagerMock) + featureContent = Mockito.spy(new ContentLoader(testConfig, k8sClient, gitRepoFactory, Mockito.mock(Jenkins), gitHandler, fileSystemUtils, deployer)) + featureArgoCd = Mockito.spy(new ArgoCD(testConfig, k8sClient, deployer, fileSystemUtils, gitRepoFactory, gitHandler)) + } + + @Test + void "correct config with no programm arguments"() { + + def actualConfig = applicationConfigurator.initConfig(testConfig) + + assertThat(actualConfig.jenkins.url).isEqualTo(EXPECTED_JENKINS_URL) + assertThat(actualConfig.jenkins.internal).isEqualTo(false) + assertThat(actualConfig.features.secrets.vault.mode).isEqualTo(EXPECTED_VAULT_MODE) + + // Dynamic value (depends on vault mode) + assertThat(actualConfig.features.secrets.active).isEqualTo(true) + } + + @Test + void "sets config application runningInsideK8s"() { + withEnvironmentVariable("KUBERNETES_SERVICE_HOST", "127.0.0.1").execute { + Config actualConfig = applicationConfigurator.initConfig(testConfig) + assertThat(actualConfig.application.runningInsideK8s).isEqualTo(true) + } + } + + @Test + void 'Sets jenkins active if external url is set'() { + testConfig.jenkins.url = 'external' + def actualConfig = applicationConfigurator.initConfig(testConfig) + assertThat(actualConfig.jenkins.active).isEqualTo(true) + } + + @Test + void 'Leaves Jenkins urlForScmm empty, if not active'() { + testConfig.jenkins.url = '' + testConfig.jenkins.active = false + + def actualConfig = applicationConfigurator.initConfig(testConfig) + assertThat(actualConfig.jenkins.urlForScm).isEmpty() + } + + @Test + void 'Fails if monitoring local is not set'() { + testConfig.application.mirrorRepos = true + testConfig.application.localHelmChartFolder = '' + + def exception = shouldFail(RuntimeException) { + commonFeatureConfig.validateConfig(testConfig) + } + assertThat(exception.message).isEqualTo('Missing config for localHelmChartFolder.\n' + + 'Either run inside the official container image or setting env var LOCAL_HELM_CHART_FOLDER=\'charts\' ' + + 'after running \'scripts/downloadHelmCharts.sh\' from the repo') + } + + @Test + void 'Fails if createImagePullSecrets is used without secrets'() { + testConfig.registry.createImagePullSecrets = true + + def exception = shouldFail(RuntimeException) { + applicationConfigurator.initConfig(testConfig) + } + assertThat(exception.message).isEqualTo('createImagePullSecrets needs to be used with either registry username and password or the readOnly variants') + } + + @Test + void 'Fails if content repo is set without mandatory params'() { + + testConfig.content.repos = [new Config.ContentSchema.ContentRepositorySchema(url: ''),] + def exception = shouldFail(RuntimeException) { + featureContent.preConfigInit(testConfig) + } + assertThat(exception.message).isEqualTo('content.repos requires a url parameter.') + + testConfig.content.repos = [new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.COPY, target: "missing_slash"),] + exception = shouldFail(RuntimeException) { + featureContent.preConfigInit(testConfig) + } + assertThat(exception.message).isEqualTo('content.target needs / to separate namespace/group from repo name. Repo: abc') + } + + @Test + void 'Fails if COPY repo misses target parameter'() { + testConfig.content.repos = [new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.COPY),] + def exception = shouldFail(RuntimeException) { + featureContent.preConfigInit(testConfig) + } + assertThat(exception.message).isEqualTo('content.repos.type COPY requires content.repos.target to be set. Repo: abc') + } + + @Test + void 'Fails if FOLDER_BASED repo has target parameter'() { + testConfig.content.repos = [new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.FOLDER_BASED, target: 'namespace/repo'),] + def exception = shouldFail(RuntimeException) { + featureContent.preConfigInit(testConfig) + } + assertThat(exception.message).isEqualTo('content.repos.type FOLDER_BASED does not support target parameter. Repo: abc') + + testConfig.content.repos = [new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.FOLDER_BASED, targetRef: 'someRef'),] + exception = shouldFail(RuntimeException) { + featureContent.preConfigInit(testConfig) + } + assertThat(exception.message).isEqualTo('content.repos.type FOLDER_BASED does not support targetRef parameter. Repo: abc') + } + + @Test + void 'Fails if MIRROR repo has invalid configuration'() { + // Test missing target parameter + testConfig.content.repos = [new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.MIRROR),] + def exception = shouldFail(RuntimeException) { + featureContent.preConfigInit(testConfig) + } + assertThat(exception.message).isEqualTo('content.repos.type MIRROR requires content.repos.target to be set. Repo: abc') + + // Test setting path + testConfig.content.repos = [new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.MIRROR, + target: 'namespace/repo', path: 'non-default-path'),] + exception = shouldFail(RuntimeException) { + featureContent.preConfigInit(testConfig) + } + assertThat(exception.message).isEqualTo("content.repos.type MIRROR does not support path. Current path: non-default-path. Repo: abc") + + // Test templating enabled + testConfig.content.repos = [new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.MIRROR, + target: 'namespace/repo', templating: true),] + exception = shouldFail(RuntimeException) { + featureContent.preConfigInit(testConfig) + } + assertThat(exception.message).isEqualTo('content.repos.type MIRROR does not support templating. Repo: abc') + } + + @Test + void 'Ignores empty localHemlChartFolder, if mirrorRepos is not set'() { + testConfig.application.mirrorRepos = false + testConfig.application.localHelmChartFolder = '' + + applicationConfigurator.initConfig(testConfig) + // no exceptions means success + } + + @Test + void "base url: evaluates for all tools"() { + testConfig.application.baseUrl = 'http://localhost' + + testConfig.features.argocd.active = true + testConfig.features.monitoring.active = true + testConfig.features.secrets.active = true + + Config actualConfig = applicationConfigurator.initConfig(testConfig) + + assertThat(actualConfig.features.argocd.url).isEqualTo("http://argocd.localhost") + assertThat(actualConfig.features.monitoring.grafanaUrl).isEqualTo("http://grafana.localhost") + assertThat(actualConfig.features.secrets.vault.url).isEqualTo("http://vault.localhost") + assertThat(actualConfig.scm.scmManager.ingress).isEqualTo("scmm.localhost") + assertThat(actualConfig.jenkins.ingress).isEqualTo("jenkins.localhost") + } + + @Test + void "base url with url-hyphens: evaluates for all tools"() { + testConfig.application.baseUrl = 'http://localhost' + testConfig.application.urlSeparatorHyphen = true + + testConfig.features.argocd.active = true + testConfig.features.monitoring.active = true + testConfig.features.secrets.active = true + + def actualConfig = applicationConfigurator.initConfig(testConfig) + + assertThat(actualConfig.features.argocd.url).isEqualTo("http://argocd-localhost") + assertThat(actualConfig.features.monitoring.grafanaUrl).isEqualTo("http://grafana-localhost") + assertThat(actualConfig.features.secrets.vault.url).isEqualTo("http://vault-localhost") + assertThat(actualConfig.scm.scmManager.ingress).isEqualTo("scmm-localhost") + assertThat(actualConfig.jenkins.ingress).isEqualTo("jenkins-localhost") + } + + @Test + void "base url: also works when port is included "() { + testConfig.application.baseUrl = 'http://localhost:8080' + testConfig.features.argocd.active = true + + def actualConfig = applicationConfigurator.initConfig(testConfig) + + assertThat(actualConfig.features.argocd.url).isEqualTo("http://argocd.localhost:8080") + } + + @Test + void "base url: also works when port is included and use url-hyphens is set"() { + testConfig.application.baseUrl = 'http://localhost:6502' + testConfig.features.argocd.active = true + testConfig.application.urlSeparatorHyphen = true + + def actualConfig = applicationConfigurator.initConfig(testConfig) + + assertThat(actualConfig.features.argocd.url).isEqualTo("http://argocd-localhost:6502") + } + + @Test + void "base url: does not evaluate for inactive tools"() { + testConfig.features.argocd.active = false + testConfig.features.mail.active = false + testConfig.features.monitoring.active = false + testConfig.features.secrets.active = false + + def actualConfig = applicationConfigurator.initConfig(testConfig) + + assertThat(actualConfig.features.argocd.url).isEqualTo('') + assertThat(actualConfig.features.monitoring.grafanaUrl).isEqualTo('') + assertThat(actualConfig.features.secrets.vault.url).isEqualTo('') + } + + @Test + void "base url: individual url params take precedence"() { + testConfig.application.baseUrl = 'http://localhost' + + testConfig.features.argocd.active = true + testConfig.features.mail.active = true + testConfig.features.monitoring.active = true + testConfig.features.secrets.active = true + + testConfig.features.argocd.url = 'argocd' + testConfig.features.monitoring.grafanaUrl = 'grafana' + testConfig.features.secrets.vault.url = 'vault' + + def actualConfig = applicationConfigurator.initConfig(testConfig) + + assertThat(actualConfig.features.argocd.url).isEqualTo("argocd") + assertThat(actualConfig.features.monitoring.grafanaUrl).isEqualTo("grafana") + assertThat(actualConfig.features.secrets.vault.url).isEqualTo("vault") + } + + @Test + void "Sets namePrefix"() { + testConfig.application.namePrefix = 'my-prefix' + + def actualConfig = applicationConfigurator.initConfig(testConfig) + assertThat(actualConfig.application.namePrefix.toString()).isEqualTo('my-prefix-') + assertThat(actualConfig.application.namePrefixForEnvVars.toString()).isEqualTo('MY_PREFIX_') + } + + @Test + void "Sets namePrefix when ending in hyphen"() { + testConfig.application.namePrefix = 'my-prefix-' + + def actualConfig = applicationConfigurator.initConfig(testConfig) + assertThat(actualConfig.application.namePrefix.toString()).isEqualTo('my-prefix-') + assertThat(actualConfig.application.namePrefixForEnvVars.toString()).isEqualTo('MY_PREFIX_') + } + + @Test + void "Registry: Sets to external when only registry URL set"() { + testConfig.registry.proxyUrl = null + + def actualConfig = applicationConfigurator.initConfig(testConfig) + + assertThat(actualConfig.registry.internal).isEqualTo(false) + assertThat(actualConfig.registry.active).isEqualTo(true) + } + + @Test + void "Registry: Fails when proxy but no username and password set"() { + def expectedException = 'Proxy URL needs to be used with proxy-username and proxy-password' + + testConfig.registry.proxyUsername = null + def exception = shouldFail(RuntimeException) { + applicationConfigurator.initConfig(testConfig) + } + assertThat(exception.message).isEqualTo(expectedException) + + testConfig.registry.proxyUsername = 'something' + testConfig.registry.proxyPassword = null + exception = shouldFail(RuntimeException) { + applicationConfigurator.initConfig(testConfig) + } + assertThat(exception.message).isEqualTo(expectedException) + + testConfig.registry.proxyUsername = null + exception = shouldFail(RuntimeException) { + applicationConfigurator.initConfig(testConfig) + } + assertThat(exception.message).isEqualTo(expectedException) + } + + @Test + void "validateEnvConfig allows valid env entries"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' + testConfig.features.argocd.env = [[name: "ENV_VAR_1", value: "value1"], + [name: "ENV_VAR_2", value: "value2"]] as List> + + // No exception should be thrown + applicationConfigurator.initConfig(testConfig) + } + + @Test + void "validateEnvConfig throws exception for missing 'name' in env entry"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' + testConfig.features.argocd.env = [[name: "ENV_VAR_1", value: "value1"], + [value: "value2"] // Missing 'name' + ] as List> + + def exception = shouldFail(IllegalArgumentException) { + applicationConfigurator.initConfig(testConfig) + featureArgoCd.postConfigInit(testConfig) + } + + assertThat(exception.message).contains("Each env variable in features.argocd.env must be a map with 'name' and 'value'. Invalid entry found: [value:value2]") + } + + @Test + void "validateEnvConfig throws exception for missing 'value' in env entry"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' + testConfig.features.argocd.env = [[name: "ENV_VAR_1", value: "value1"], + [name: "ENV_VAR_2"] // Missing 'value' + ] as List> + + def exception = shouldFail(IllegalArgumentException) { + applicationConfigurator.initConfig(testConfig) + featureArgoCd.postConfigInit(testConfig) + } + + assertThat(exception.message).contains("Each env variable in features.argocd.env must be a map with 'name' and 'value'. Invalid entry found: [name:ENV_VAR_2]") + } + + @Test + void "validateEnvConfig throws exception for non-map env entry"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' + testConfig.features.argocd.env = [[name: "ENV_VAR_1", value: "value1"], + "invalid_entry" // Invalid entry + ] as List> + + def exception = shouldFail(IllegalArgumentException) { + applicationConfigurator.initConfig(testConfig) + featureArgoCd.postConfigInit(testConfig) + } + + assertThat(exception.message).contains("Each env variable in features.argocd.env must be a map with 'name' and 'value'. Invalid entry found: invalid_entry") + } + + @Test + void "validateEnvConfig allows empty env list"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = 'https://100.125.0.1:443' + testConfig.features.argocd.env + + // No exception should be thrown + applicationConfigurator.initConfig(testConfig) + } + + @Test + void "validateEnvConfig skips validation when operator is false"() { + testConfig.features.argocd.operator = false + testConfig.features.argocd.env = [[name: "ENV_VAR_1", value: "value1"], + [value: "value2"] // Invalid entry, but should be ignored + ] as List> + + // No exception should be thrown + applicationConfigurator.initConfig(testConfig) + } + + @Test + void "should skip resourceInclusionsCluster setup when ArgoCD operator is not enabled"() { + testConfig.features.argocd.operator = false + + // Calling the method should not make any changes to the config + applicationConfigurator.initConfig(testConfig) + + assertThat(testLogger.getLogs().search("ArgoCD operator is not enabled. Skipping features.argocd.resourceInclusionsCluster setup.")) + .isNotEmpty() + } + + @Test + void "should validate and accept user-provided valid resourceInclusionsCluster URL"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = "https://valid-url.com" + + // Calling the method should accept the valid URL and not throw any exception + applicationConfigurator.initConfig(testConfig) + + assertThat(testConfig.features.argocd.resourceInclusionsCluster).isEqualTo("https://valid-url.com") + assertThat(testLogger.getLogs().search("Validating user-provided features.argocd.resourceInclusionsCluster URL: https://valid-url.com")) + .isNotEmpty() + assertThat(testLogger.getLogs().search("Found valid URL in features.argocd.resourceInclusionsCluster: https://valid-url.com")) + .isNotEmpty() + } + + @Test + void "should throw exception for user-provided invalid resourceInclusionsCluster URL"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = "invalid-url" + + def exception = shouldFail(IllegalArgumentException) { + applicationConfigurator.initConfig(testConfig) + } + + assertThat(exception.message).contains("Invalid URL for 'features.argocd.resourceInclusionsCluster': invalid-url.") + } + + @Test + void "should set resourceInclusionsCluster using Kubernetes ENV variables when not provided by user"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = null + + // Set Kubernetes ENV variables + withEnvironmentVariable("KUBERNETES_SERVICE_HOST", "127.0.0.1") + .and("KUBERNETES_SERVICE_PORT", "6443") + .execute { + Config actualConfig = applicationConfigurator.initConfig(testConfig) + + assertThat(actualConfig.features.argocd.resourceInclusionsCluster).isEqualTo("https://127.0.0.1:6443") + + assertThat(testLogger.getLogs().search("Successfully set features.argocd.resourceInclusionsCluster via Kubernetes ENV to: https://127.0.0.1:6443")) + .isNotEmpty() + } + } + + @Test + void "MultiTenant Mode Central SCM Url"() { + testConfig.multiTenant.scmManager.url = "scmm.localhost/scm" + testConfig.application.namePrefix = "foo" + applicationConfigurator.initConfig(testConfig) + assertThat(testConfig.multiTenant.scmManager.url).toString() == "scmm.localhost/scm/" + } + + @Test + void "should throw exception when Kubernetes ENV variables are not set and resourceInclusionsCluster is null"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = null + + def exception = shouldFail(RuntimeException) { + applicationConfigurator.initConfig(testConfig) + } + + assertThat(exception.message).contains("Could not determine 'features.argocd.resourceInclusionsCluster' which is required when argocd.operator=true. Ensure Kubernetes environment variables 'KUBERNETES_SERVICE_HOST' and 'KUBERNETES_SERVICE_PORT' are set properly.") + } + + @Test + void "should throw exception when Kubernetes ENV variables are not set and resourceInclusionsCluster is empty"() { + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = '' + + def exception = shouldFail(RuntimeException) { + applicationConfigurator.initConfig(testConfig) + } + + assertThat(exception.message).contains("Could not determine 'features.argocd.resourceInclusionsCluster' which is required when argocd.operator=true. Ensure Kubernetes environment variables 'KUBERNETES_SERVICE_HOST' and 'KUBERNETES_SERVICE_PORT' are set properly.") + } + + @Test + void "should throw exception for invalid Kubernetes constructed URL"() { + // Set ArgoCD operator to true + testConfig.features.argocd.operator = true + testConfig.features.argocd.resourceInclusionsCluster = null + + // Set invalid Kubernetes ENV variables + withEnvironmentVariable("KUBERNETES_SERVICE_HOST", "invalid_host") + .and("KUBERNETES_SERVICE_PORT", "not_a_port") + .execute { + def exception = shouldFail(RuntimeException) { + applicationConfigurator.initConfig(testConfig) + } + + assertThat(exception.message).contains("Could not determine 'features.argocd.resourceInclusionsCluster' which is required when argocd.operator=true.") + } + + assertThat(testLogger.getLogs().search("Constructed internal Kubernetes API Server URL: https://invalid_host:not_a_port")).isNotEmpty() + } + + List getAllFieldNames(Class clazz, String parentField = '', List fieldNames = []) { + clazz.declaredFields.each { field -> + def currentField = parentField + field.name + if (field.type instanceof Class && !field.type.isArray() && field.type.name.startsWith(Config.class.getPackageName())) { + println "nested class $field.type, $currentField + '.', $fieldNames" + getAllFieldNames(field.type, currentField + '.', fieldNames) + } else { + if (!field.name.startsWith('_') && !field.name.startsWith('$') && field.name != 'metaClass') { + fieldNames.add(currentField) + } + } + } + return fieldNames + } + + List getAllKeys(Map map, String parentKey = '', List keysList = []) { + map.each { key, value -> + def currentKey = parentKey + key + if (value instanceof Map && !value.isEmpty()) { + getAllKeys(value, currentKey + '.', keysList) + } else { + keysList.add(currentKey) + } + } + return keysList + } + + private static Config minimalConfig() { + def config = new Config() + config.application = new Config.ApplicationSchema(localHelmChartFolder: 'someValue', + namePrefix: '') + config.scm = new ScmTenantSchema(scmManager: new ScmTenantSchema.ScmManagerTenantConfig(url: '')) + return config + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/CertManagerTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/CertManagerTest.groovy index 6e0d25fd2..08aa8496e 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/CertManagerTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/CertManagerTest.groovy @@ -7,7 +7,7 @@ import static org.mockito.Mockito.verify import static org.mockito.Mockito.when import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.utils.AirGappedUtils @@ -36,7 +36,7 @@ class CertManagerTest { FileSystemUtils fileSystemUtils = new FileSystemUtils() @Mock - DeploymentStrategy deploymentStrategy + Deployer deploymentStrategy @Mock AirGappedUtils airGappedUtils @Mock diff --git a/src/test/groovy/com/cloudogu/gitops/features/ContentLoaderTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/ContentLoaderTest.groovy index 1adb4d746..40cd7b7f4 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/ContentLoaderTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/ContentLoaderTest.groovy @@ -1,8 +1,18 @@ package com.cloudogu.gitops.features +import static ContentLoader.RepoCoordinate +import static com.cloudogu.gitops.config.Config.ContentRepoType +import static com.cloudogu.gitops.config.Config.ContentSchema.ContentRepositorySchema +import static com.cloudogu.gitops.config.Config.OverwriteMode +import static groovy.test.GroovyAssert.shouldFail +import static org.assertj.core.api.Assertions.assertThat +import static org.mockito.ArgumentMatchers.any +import static org.mockito.ArgumentMatchers.eq +import static org.mockito.Mockito.* + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.Credentials -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.kubernetes.api.K8sClient @@ -14,8 +24,12 @@ import com.cloudogu.gitops.utils.git.GitHandlerForTests import com.cloudogu.gitops.utils.git.ScmManagerMock import com.cloudogu.gitops.utils.git.TestGitRepoFactory import com.cloudogu.gitops.utils.git.TestScmManagerApiClient + +import java.nio.file.Files +import java.nio.file.Path import groovy.util.logging.Slf4j import groovy.yaml.YamlSlurper + import io.fabric8.kubernetes.api.model.Secret import io.fabric8.kubernetes.api.model.SecretBuilder import io.fabric8.kubernetes.client.KubernetesClient @@ -32,1186 +46,1043 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.mockito.ArgumentCaptor -import java.nio.file.Files -import java.nio.file.Path - -import static ContentLoader.RepoCoordinate -import static com.cloudogu.gitops.config.Config.ContentRepoType -import static com.cloudogu.gitops.config.Config.ContentSchema.ContentRepositorySchema -import static com.cloudogu.gitops.config.Config.OverwriteMode -import static groovy.test.GroovyAssert.shouldFail -import static org.assertj.core.api.Assertions.assertThat -import static org.mockito.ArgumentMatchers.any -import static org.mockito.ArgumentMatchers.eq -import static org.mockito.Mockito.* - @Slf4j -@EnableKubernetesMockClient(crud=true) +@EnableKubernetesMockClient(crud = true) class ContentLoaderTest { - static List foldersToDelete = new ArrayList() - - Config config = new Config([ - application: [ - namePrefix: 'foo-' - ], - scm : [ - scmManager: [ - url: '' - ] - ], - registry : [ - url : 'reg-url', - path : 'reg-path', - username : 'reg-user', - password : 'reg-pw', - createImagePullSecrets: false - ] - ]) - - KubernetesClient client - CommandExecutorForTest k8sCommands = new CommandExecutorForTest() - K8sClientForTest k8sClient = new K8sClientForTest(config, k8sCommands) - TestGitRepoFactory scmmRepoProvider = new TestGitRepoFactory(config, new FileSystemUtils()) - TestScmManagerApiClient scmmApiClient = new TestScmManagerApiClient(config) - Jenkins jenkins = mock(Jenkins.class) - ScmManagerMock scmManagerMock = new ScmManagerMock() - GitHandler gitHandler = new GitHandlerForTests(config, scmManagerMock) - DeploymentStrategy deploymentStrategy = mock(DeploymentStrategy) - FileSystemUtils fileSystemUtils = new FileSystemUtils() - - @TempDir - File tmpDir - - - List expectedTargetRepos = [ - new RepoCoordinate(namespace: "common", repoName: "repo"), - new RepoCoordinate(namespace: "ns1a", repoName: "repo1a1"), - new RepoCoordinate(namespace: "ns1a", repoName: "repo1a2"), - new RepoCoordinate(namespace: "ns1b", repoName: "repo1b1"), - new RepoCoordinate(namespace: "ns1b", repoName: "repo1b2"), - new RepoCoordinate(namespace: "ns2a", repoName: "repo2a1"), - new RepoCoordinate(namespace: "ns2a", repoName: "repo2a2"), - new RepoCoordinate(namespace: "ns2b", repoName: "repo2b1"), - new RepoCoordinate(namespace: "ns2b", repoName: "repo2b2"), - new RepoCoordinate(namespace: "copy", repoName: "repo1"), - new RepoCoordinate(namespace: "copy", repoName: "repo2"), - ] - - List contentRepos = [ - // copy-typed repo writing to their own target - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), type: ContentRepoType.COPY, target: 'copy/repo1'), - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'copy/repo2', path: 'subPath'), - - // Same folder as in copyRepos -> Should be combined - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'), - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath'), - - // Contains ftl - new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), type: ContentRepoType.FOLDER_BASED, templating: true), - // Contains a templated file that should be ignored - new ContentRepositorySchema(url: createContentRepo('folderBasedRepo2'), type: ContentRepoType.FOLDER_BASED, path: 'subPath'), - - ] - - @AfterAll - static void cleanFolders() { - foldersToDelete.each { it.deleteDir() } - - } - - - @Test - void 'deploys image pull secrets'() { - config.registry.createImagePullSecrets = true - config.content.namespaces = ['example-apps-staging', 'example-apps-production'] - - createContent(config).install() - - assertRegistrySecrets('reg-user', 'reg-pw') - } - - @Test - void 'deploys image pull secrets from read-only vars'() { - config.registry.createImagePullSecrets = true - config.content.namespaces = ['example-apps-staging', 'example-apps-production'] - config.registry.readOnlyUsername = 'other-user' - config.registry.readOnlyPassword = 'other-pw' - - createContent(config).install() - - assertRegistrySecrets('other-user', 'other-pw') - } - - @Test - void 'deploys additional image pull secrets for proxy registry'() { - config.registry.createImagePullSecrets = true - config.content.namespaces = ['example-apps-staging', 'example-apps-production'] - config.registry.twoRegistries = true - config.registry.proxyUrl = 'proxy-url' - config.registry.proxyUsername = 'proxy-user' - config.registry.proxyPassword = 'proxy-pw' - - // Simulate argocd Namespace does not exist - k8sCommands.enqueueOutput(new CommandExecutor.Output('namespace not found', '', 1)) // Namespace not exit - k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 0)) // other kubectl - k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 0)) // other kubectl - k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 0)) // other kubectl - k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 0)) // other kubectl - k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 1)) // Namespace not exit - - createContent(config).install() - - assertRegistrySecrets('reg-user', 'reg-pw') - - k8sClient.commandExecutorForTest.assertExecuted('kubectl create namespace example-apps-staging') - k8sClient.commandExecutorForTest.assertExecuted('kubectl create namespace example-apps-production') - k8sClient.commandExecutorForTest.assertExecuted( - 'kubectl create secret docker-registry proxy-registry -n example-apps-staging' + - ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') - k8sClient.commandExecutorForTest.assertExecuted( - 'kubectl create secret docker-registry proxy-registry -n example-apps-production' + - ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') - } - - @Test - void 'Combines content repos successfully'() { - - config.content.repos = contentRepos - - def repos = createContent(config).cloneContentRepos() - - expectedTargetRepos.each { expected -> - assertThat(new File(findRoot(repos), "${expected.namespace}/${expected.repoName}/file")).exists().isFile() - } - - assertThat(new File(findRoot(repos), "common/repo/file").text).contains("folderBasedRepo2") // Last repo "wins" - - assertThat(new File(findRoot(repos), "common/repo/folderBasedRepo1")).exists().isFile() - assertThat(new File(findRoot(repos), "common/repo/folderBasedRepo2")).exists().isFile() - assertThat(new File(findRoot(repos), "common/repo/copyRepo1")).exists().isFile() - assertThat(new File(findRoot(repos), "common/repo/copyRepo2")).exists().isFile() - - // Assert Templating - assertThat(new File(findRoot(repos), "common/repo/some.yaml")).exists() - assertThat(new File(findRoot(repos), "common/repo/some.yaml").text).contains("namePrefix: foo-") - // Assert not templating for this folder-based repo - assertThat(new File(findRoot(repos), "common/repo/someOther.yaml.ftl")).exists() - assertThat(new File(findRoot(repos), "common/repo/someOther.yaml.ftl").text).contains('namePrefix: ${config.application.namePrefix}') - } - - @Test - void 'supports content variables'() { - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), type: ContentRepoType.FOLDER_BASED, templating: true) - ] - config.content.variables.someapp = [somevalue: 'this is a custom variable'] - - def repos = createContent(config).cloneContentRepos() - - // Assert Templating - assertThat(new File(findRoot(repos), "common/repo/some.yaml")).exists() - assertThat(new File(findRoot(repos), "common/repo/some.yaml").text).contains("namePrefix: foo-") - assertThat(new File(findRoot(repos), "common/repo/some.yaml").text).contains("myvar: this is a custom variable") - } - - @Test - void 'Authenticates content Repos'() { - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo', credentials: new Credentials('user', 'pw')) - ] - - def content = createContent(config) - content.cloneContentRepos() - - ArgumentCaptor captor = ArgumentCaptor.forClass(UsernamePasswordCredentialsProvider) - verify(content.cloneSpy).setCredentialsProvider(captor.capture()) - - - def value = captor.value - assertThat(value.properties.username).isEqualTo('user') - assertThat(value.properties.password).isEqualTo('pw'.toCharArray()) - } - - @Test - @DisplayName("Authenticates content Repos with secret") - void authenticatesContentReposWithSecret() { - this.k8sClient.k8sJavaApiClient.client=client - Secret secret = new SecretBuilder() - .withNewMetadata() - .withName("secret-test-name") - .withNamespace("default") - .endMetadata() - .withType("Opaque") - .withData(Map.of( - "username", "YWRtaW4=", - "password", "czNjcjN0" - )) - .build() - - - this.k8sClient.k8sJavaApiClient.client.secrets() - .inNamespace("default") - .resource(secret) - .create() - - config.content.repos = [ - new ContentRepositorySchema( - url: createContentRepo('copyRepo1'), - ref: 'main', type: ContentRepoType.COPY, - target: 'common/repo', - credentials: new Credentials(null,null,'secret-test-name','default')) - ] - - def content = createContent(config) - content.cloneContentRepos() - - ArgumentCaptor captor = ArgumentCaptor.forClass(UsernamePasswordCredentialsProvider) - verify(content.cloneSpy).setCredentialsProvider(captor.capture()) - def value = captor.value - assertThat(value.properties.username).isEqualTo('admin') - assertThat(value.properties.password).isEqualTo('s3cr3t'.toCharArray()) - } - - @Test - void 'Checks out commit refs, tags and non-default branches for content repos'() { - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: 'someTag', type: ContentRepoType.COPY, target: 'common/tag'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: '8bc1d1165468359b16d9771d4a9a3df26afc03e8', type: ContentRepoType.COPY, target: 'common/ref'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: 'someBranch', type: ContentRepoType.COPY, target: 'common/branch') - ] - - def repos = createContent(config).cloneContentRepos() - - assertThat(new File(findRoot(repos), "common/tag/README.md")).exists().isFile() - assertThat(new File(findRoot(repos), "common/tag/README.md").text).contains("someTag") - - assertThat(new File(findRoot(repos), "common/ref/README.md")).exists().isFile() - assertThat(new File(findRoot(repos), "common/ref/README.md").text).contains("main") - - assertThat(new File(findRoot(repos), "common/branch/README.md")).exists().isFile() - assertThat(new File(findRoot(repos), "common/branch/README.md").text).contains("someBranch") - } - - @Test - void 'Checks out default branch when no ref set'() { - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('', 'git-repo-different-default-branch'), target: 'common/default', type: ContentRepoType.COPY), - ] - - def repos = createContent(config).cloneContentRepos() - - assertThat(new File(findRoot(repos), "common/default/README.md")).exists().isFile() - assertThat(new File(findRoot(repos), "common/default/README.md").text).contains("different") - } - - @Test - void 'Fails if commit ref does not exist'() { - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: 'someTag', type: ContentRepoType.COPY, target: 'common/tag'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: 'does/not/exist', type: ContentRepoType.FOLDER_BASED, target: 'does not matter'), - ] - - def exception = shouldFail(RuntimeException) { - createContent(config).cloneContentRepos() - } - - assertThat(exception.message).startsWith("Reference 'does/not/exist' not found in content repository") - } - - @Test - void 'Respects order of folder-based repositories'() { - config.content.repos = [ - // Note the different order! - new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), ref: 'main', type: ContentRepoType.FOLDER_BASED), - new ContentRepositorySchema(url: createContentRepo('folderBasedRepo2'), ref: 'main', type: ContentRepoType.FOLDER_BASED, path: 'subPath'), - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath'), - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'), - ] - - def repos = createContent(config).cloneContentRepos() - - assertThat(new File(findRoot(repos), "common/repo/file").text).contains("copyRepo1") - // Last repo "wins" - } - - @Test - void 'Is able to COPY into MIRRORED repo'() { - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'common/repo'), - new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), type: ContentRepoType.FOLDER_BASED, overwriteMode: OverwriteMode.UPGRADE), - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE, path: 'subPath') - ] - - scmmApiClient.mockRepoApiBehaviour() - - createContent(config).install() - - def expectedRepo = 'common/repo' - // clone target repo, to ensure, changes in remote repo. - try (def git = cloneRepo(expectedRepo, tmpDir)) { - assertThat(new File(tmpDir, "file").text).contains("copyRepo2") // Last repo "wins" - assertThat(new File(tmpDir, "mirrorRepo1")).exists().isFile() - assertThat(new File(tmpDir, "copyRepo2")).exists().isFile() - assertThat(new File(tmpDir, "folderBasedRepo1")).exists().isFile() - - // Assert mirrors branches and tags of non-folderBased repos - // Verify tag exists and points to correct content - git.fetch().setRefSpecs("refs/*:refs/*").call() // Fetch all tags and branches - - assertTag(git, 'someTag') - assertBranch(git, 'someBranch') - } - } - - @Test - void 'Handles mirror and copy together'() { - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), type: ContentRepoType.FOLDER_BASED), - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE, path: 'subPath'), - new ContentRepositorySchema(url: createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, overwriteMode: OverwriteMode.RESET, target: 'common/repo'), - ] - - scmmApiClient.mockRepoApiBehaviour() - - createContent(config).install() - - def expectedRepo = 'common/repo' - // clone target repo, to ensure, changes in remote repo. - try (def git = cloneRepo(expectedRepo, tmpDir)) { - assertThat(new File(tmpDir, "file").text).contains("mirrorRepo1") // Last repo "wins" - assertThat(new File(tmpDir, "folderBasedRepo1")).doesNotExist() - assertThat(new File(tmpDir, "copyRepo2")).doesNotExist() - - // Assert mirrors branches and tags of non-folderBased repos - // Verify tag exists and points to correct content - git.fetch().setRefSpecs("refs/*:refs/*").call() // Fetch all tags and branches - - assertTag(git, 'someTag') - assertBranch(git, 'someBranch') - } - } - - @Test - void 'Handles multiple mirrors of the same repo with different refs'() { - def repoToMirror = createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags') - config.content.repos = [ - new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'main', target: 'common/repo'), - new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'someBranch', target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE), - new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'someTag', target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE), - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE, path: 'subPath') - ] - - scmmApiClient.mockRepoApiBehaviour() - - createContent(config).install() - - def expectedRepo = 'common/repo' - // clone target repo, to ensure, changes in remote repo. - try (def git = cloneRepo(expectedRepo, tmpDir)) { - assertThat(new File(tmpDir, "file").text).contains("copyRepo2") // Last repo "wins" - assertThat(new File(tmpDir, "mirrorRepo1")).exists().isFile() - - git.fetch().setRefSpecs("refs/*:refs/*").call() // Fetch all tags and branches - - assertTag(git, 'someTag') - assertBranch(git, 'someBranch') - } - } - - @Test - void 'Handles targetRefs'() { - config.content.repos = [ - // From branch to branch or tag to tag - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'mirror/tag', ref: 'someTag', targetRef: 'my-tag'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'mirror/branch', ref: 'someBranch', targetRef: 'my-branch'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.COPY, target: 'copy/tag', ref: 'someTag', targetRef: 'my-tag'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.COPY, target: 'copy/branch', ref: 'someBranch', targetRef: 'my-branch'), - - // From tag to branch or the other way round - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'mirror/tag2branch', ref: 'someTag', targetRef: 'refs/heads/my-branch'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'mirror/branch2tag', ref: 'someBranch', targetRef: 'refs/tags/my-tag'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.COPY, target: 'copy/tag2branch', ref: 'someTag', targetRef: 'refs/heads/my-branch'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.COPY, target: 'copy/branch2tag', ref: 'someBranch', targetRef: 'refs/tags/my-tag'), - ] - - scmmApiClient.mockRepoApiBehaviour() - - createContent(config).install() - - // From branch to branch or tag to tag - assertTagAndReadme('mirror/tag', 'my-tag', "someTag") - assertBranchAndReadme('mirror/branch', 'my-branch', "someBranch") - - assertTagAndReadme('copy/tag', 'my-tag', "someTag") - assertBranchAndReadme('copy/branch', 'my-branch', "someBranch") - - // From tag to branch or the other way round - assertTagAndReadme('mirror/branch2tag', 'my-tag', "someBranch") - assertBranchAndReadme('mirror/tag2branch', 'my-branch', "someTag") - - assertTagAndReadme('copy/branch2tag', 'my-tag', "someBranch") - assertBranchAndReadme('copy/tag2branch', 'my-branch', "someTag") - } - - @Test - void 'Handles multiple mirrors of the same repo with different refs, where one is not pushed'() { - // This test case does not make too much sense but used to cause git problems when we merged all content repos into a single folder, like - // TransportException: Missing unknown 5bcf50f0537bf4d2719a82e9b0950fbac92b3ecc - def repoToMirror = createContentRepo('copyRepo1', 'git-repository-with-branches-tags') - config.content.repos = [ - new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'main', target: 'common/repo'), - new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'someBranch', target: 'common/repo') /* Deliberately not use overwriteMode here !*/, - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE, path: 'subPath') - ] - - scmmApiClient.mockRepoApiBehaviour() - - createContent(config).install() - // No exception means success - } + static List foldersToDelete = new ArrayList() + + Config config = new Config([application: [namePrefix: 'foo-'], + scm : [scmManager: [url: '']], + registry : [url : 'reg-url', + path : 'reg-path', + username : 'reg-user', + password : 'reg-pw', + createImagePullSecrets: false]]) + + KubernetesClient client + CommandExecutorForTest k8sCommands = new CommandExecutorForTest() + K8sClientForTest k8sClient = new K8sClientForTest(config, k8sCommands) + TestGitRepoFactory scmmRepoProvider = new TestGitRepoFactory(config, new FileSystemUtils()) + TestScmManagerApiClient scmmApiClient = new TestScmManagerApiClient(config) + Jenkins jenkins = mock(Jenkins.class) + ScmManagerMock scmManagerMock = new ScmManagerMock() + GitHandler gitHandler = new GitHandlerForTests(config, scmManagerMock) + Deployer deployer = mock(Deployer) + FileSystemUtils fileSystemUtils = new FileSystemUtils() + + @TempDir + File tmpDir + + List expectedTargetRepos = [new RepoCoordinate(namespace: "common", repoName: "repo"), + new RepoCoordinate(namespace: "ns1a", repoName: "repo1a1"), + new RepoCoordinate(namespace: "ns1a", repoName: "repo1a2"), + new RepoCoordinate(namespace: "ns1b", repoName: "repo1b1"), + new RepoCoordinate(namespace: "ns1b", repoName: "repo1b2"), + new RepoCoordinate(namespace: "ns2a", repoName: "repo2a1"), + new RepoCoordinate(namespace: "ns2a", repoName: "repo2a2"), + new RepoCoordinate(namespace: "ns2b", repoName: "repo2b1"), + new RepoCoordinate(namespace: "ns2b", repoName: "repo2b2"), + new RepoCoordinate(namespace: "copy", repoName: "repo1"), + new RepoCoordinate(namespace: "copy", repoName: "repo2"),] + + List contentRepos = [// copy-typed repo writing to their own target + new ContentRepositorySchema(url: createContentRepo('copyRepo1'), type: ContentRepoType.COPY, target: 'copy/repo1'), + new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'copy/repo2', path: 'subPath'), + + // Same folder as in copyRepos -> Should be combined + new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'), + new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath'), + + // Contains ftl + new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), type: ContentRepoType.FOLDER_BASED, templating: true), + // Contains a templated file that should be ignored + new ContentRepositorySchema(url: createContentRepo('folderBasedRepo2'), type: ContentRepoType.FOLDER_BASED, path: 'subPath'), + + ] + + @AfterAll + static void cleanFolders() { + foldersToDelete.each { it.deleteDir() } + + } + + @Test + void 'deploys image pull secrets'() { + config.registry.createImagePullSecrets = true + config.content.namespaces = ['example-apps-staging', 'example-apps-production'] + + createContent(config).install() + + assertRegistrySecrets('reg-user', 'reg-pw') + } + + @Test + void 'deploys image pull secrets from read-only vars'() { + config.registry.createImagePullSecrets = true + config.content.namespaces = ['example-apps-staging', 'example-apps-production'] + config.registry.readOnlyUsername = 'other-user' + config.registry.readOnlyPassword = 'other-pw' + + createContent(config).install() + + assertRegistrySecrets('other-user', 'other-pw') + } + + @Test + void 'deploys additional image pull secrets for proxy registry'() { + config.registry.createImagePullSecrets = true + config.content.namespaces = ['example-apps-staging', 'example-apps-production'] + config.registry.twoRegistries = true + config.registry.proxyUrl = 'proxy-url' + config.registry.proxyUsername = 'proxy-user' + config.registry.proxyPassword = 'proxy-pw' + + // Simulate argocd Namespace does not exist + k8sCommands.enqueueOutput(new CommandExecutor.Output('namespace not found', '', 1)) // Namespace not exit + k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 0)) // other kubectl + k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 0)) // other kubectl + k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 0)) // other kubectl + k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 0)) // other kubectl + k8sCommands.enqueueOutput(new CommandExecutor.Output('', '', 1)) // Namespace not exit + + createContent(config).install() + + assertRegistrySecrets('reg-user', 'reg-pw') + + k8sClient.commandExecutorForTest.assertExecuted('kubectl create namespace example-apps-staging') + k8sClient.commandExecutorForTest.assertExecuted('kubectl create namespace example-apps-production') + k8sClient.commandExecutorForTest.assertExecuted('kubectl create secret docker-registry proxy-registry -n example-apps-staging' + + ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') + k8sClient.commandExecutorForTest.assertExecuted('kubectl create secret docker-registry proxy-registry -n example-apps-production' + + ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') + } + + @Test + void 'Combines content repos successfully'() { + + config.content.repos = contentRepos + + def repos = createContent(config).cloneContentRepos() + + expectedTargetRepos.each { expected -> assertThat(new File(findRoot(repos), "${expected.namespace}/${expected.repoName}/file")).exists().isFile() + } + + assertThat(new File(findRoot(repos), "common/repo/file").text).contains("folderBasedRepo2") // Last repo "wins" + + assertThat(new File(findRoot(repos), "common/repo/folderBasedRepo1")).exists().isFile() + assertThat(new File(findRoot(repos), "common/repo/folderBasedRepo2")).exists().isFile() + assertThat(new File(findRoot(repos), "common/repo/copyRepo1")).exists().isFile() + assertThat(new File(findRoot(repos), "common/repo/copyRepo2")).exists().isFile() + + // Assert Templating + assertThat(new File(findRoot(repos), "common/repo/some.yaml")).exists() + assertThat(new File(findRoot(repos), "common/repo/some.yaml").text).contains("namePrefix: foo-") + // Assert not templating for this folder-based repo + assertThat(new File(findRoot(repos), "common/repo/someOther.yaml.ftl")).exists() + assertThat(new File(findRoot(repos), "common/repo/someOther.yaml.ftl").text).contains('namePrefix: ${config.application.namePrefix}') + } + + @Test + void 'supports content variables'() { + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), type: ContentRepoType.FOLDER_BASED, templating: true)] + config.content.variables.someapp = [somevalue: 'this is a custom variable'] + + def repos = createContent(config).cloneContentRepos() + + // Assert Templating + assertThat(new File(findRoot(repos), "common/repo/some.yaml")).exists() + assertThat(new File(findRoot(repos), "common/repo/some.yaml").text).contains("namePrefix: foo-") + assertThat(new File(findRoot(repos), "common/repo/some.yaml").text).contains("myvar: this is a custom variable") + } + + @Test + void 'Authenticates content Repos'() { + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo', credentials: new Credentials('user', 'pw'))] + + def content = createContent(config) + content.cloneContentRepos() + + ArgumentCaptor captor = ArgumentCaptor.forClass(UsernamePasswordCredentialsProvider) + verify(content.cloneSpy).setCredentialsProvider(captor.capture()) + + def value = captor.value + assertThat(value.properties.username).isEqualTo('user') + assertThat(value.properties.password).isEqualTo('pw'.toCharArray()) + } + + @Test + @DisplayName("Authenticates content Repos with secret") + void authenticatesContentReposWithSecret() { + this.k8sClient.k8sJavaApiClient.client = client + Secret secret = new SecretBuilder() + .withNewMetadata() + .withName("secret-test-name") + .withNamespace("default") + .endMetadata() + .withType("Opaque") + .withData(Map.of("username", "YWRtaW4=", + "password", "czNjcjN0")) + .build() + + this.k8sClient.k8sJavaApiClient.client.secrets() + .inNamespace("default") + .resource(secret) + .create() + + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), + ref: 'main', type: ContentRepoType.COPY, + target: 'common/repo', + credentials: new Credentials(null, null, 'secret-test-name', 'default'))] + + def content = createContent(config) + content.cloneContentRepos() + + ArgumentCaptor captor = ArgumentCaptor.forClass(UsernamePasswordCredentialsProvider) + verify(content.cloneSpy).setCredentialsProvider(captor.capture()) + def value = captor.value + assertThat(value.properties.username).isEqualTo('admin') + assertThat(value.properties.password).isEqualTo('s3cr3t'.toCharArray()) + } + + @Test + void 'Checks out commit refs, tags and non-default branches for content repos'() { + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: 'someTag', type: ContentRepoType.COPY, target: 'common/tag'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: '8bc1d1165468359b16d9771d4a9a3df26afc03e8', type: ContentRepoType.COPY, target: 'common/ref'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: 'someBranch', type: ContentRepoType.COPY, target: 'common/branch')] + + def repos = createContent(config).cloneContentRepos() + + assertThat(new File(findRoot(repos), "common/tag/README.md")).exists().isFile() + assertThat(new File(findRoot(repos), "common/tag/README.md").text).contains("someTag") + + assertThat(new File(findRoot(repos), "common/ref/README.md")).exists().isFile() + assertThat(new File(findRoot(repos), "common/ref/README.md").text).contains("main") + + assertThat(new File(findRoot(repos), "common/branch/README.md")).exists().isFile() + assertThat(new File(findRoot(repos), "common/branch/README.md").text).contains("someBranch") + } + + @Test + void 'Checks out default branch when no ref set'() { + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('', 'git-repo-different-default-branch'), target: 'common/default', type: ContentRepoType.COPY),] + + def repos = createContent(config).cloneContentRepos() + + assertThat(new File(findRoot(repos), "common/default/README.md")).exists().isFile() + assertThat(new File(findRoot(repos), "common/default/README.md").text).contains("different") + } + + @Test + void 'Fails if commit ref does not exist'() { + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: 'someTag', type: ContentRepoType.COPY, target: 'common/tag'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), ref: 'does/not/exist', type: ContentRepoType.FOLDER_BASED, target: 'does not matter'),] + + def exception = shouldFail(RuntimeException) { + createContent(config).cloneContentRepos() + } + + assertThat(exception.message).startsWith("Reference 'does/not/exist' not found in content repository") + } + + @Test + void 'Respects order of folder-based repositories'() { + config.content.repos = [// Note the different order! + new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), ref: 'main', type: ContentRepoType.FOLDER_BASED), + new ContentRepositorySchema(url: createContentRepo('folderBasedRepo2'), ref: 'main', type: ContentRepoType.FOLDER_BASED, path: 'subPath'), + new ContentRepositorySchema(url: createContentRepo('copyRepo2'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath'), + new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'),] + + def repos = createContent(config).cloneContentRepos() + + assertThat(new File(findRoot(repos), "common/repo/file").text).contains("copyRepo1") + // Last repo "wins" + } + + @Test + void 'Is able to COPY into MIRRORED repo'() { + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'common/repo'), + new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), type: ContentRepoType.FOLDER_BASED, overwriteMode: OverwriteMode.UPGRADE), + new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE, path: 'subPath')] + + scmmApiClient.mockRepoApiBehaviour() + + createContent(config).install() + + def expectedRepo = 'common/repo' + // clone target repo, to ensure, changes in remote repo. + try (def git = cloneRepo(expectedRepo, tmpDir)) { + assertThat(new File(tmpDir, "file").text).contains("copyRepo2") // Last repo "wins" + assertThat(new File(tmpDir, "mirrorRepo1")).exists().isFile() + assertThat(new File(tmpDir, "copyRepo2")).exists().isFile() + assertThat(new File(tmpDir, "folderBasedRepo1")).exists().isFile() + + // Assert mirrors branches and tags of non-folderBased repos + // Verify tag exists and points to correct content + git.fetch().setRefSpecs("refs/*:refs/*").call() // Fetch all tags and branches + + assertTag(git, 'someTag') + assertBranch(git, 'someBranch') + } + } + + @Test + void 'Handles mirror and copy together'() { + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('folderBasedRepo1'), type: ContentRepoType.FOLDER_BASED), + new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE, path: 'subPath'), + new ContentRepositorySchema(url: createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, overwriteMode: OverwriteMode.RESET, target: 'common/repo'),] + + scmmApiClient.mockRepoApiBehaviour() + + createContent(config).install() + + def expectedRepo = 'common/repo' + // clone target repo, to ensure, changes in remote repo. + try (def git = cloneRepo(expectedRepo, tmpDir)) { + assertThat(new File(tmpDir, "file").text).contains("mirrorRepo1") // Last repo "wins" + assertThat(new File(tmpDir, "folderBasedRepo1")).doesNotExist() + assertThat(new File(tmpDir, "copyRepo2")).doesNotExist() + + // Assert mirrors branches and tags of non-folderBased repos + // Verify tag exists and points to correct content + git.fetch().setRefSpecs("refs/*:refs/*").call() // Fetch all tags and branches + + assertTag(git, 'someTag') + assertBranch(git, 'someBranch') + } + } + @Test + void 'Handles multiple mirrors of the same repo with different refs'() { + def repoToMirror = createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags') + config.content.repos = [new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'main', target: 'common/repo'), + new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'someBranch', target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE), + new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'someTag', target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE), + new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE, path: 'subPath')] + + scmmApiClient.mockRepoApiBehaviour() - @Test - void 'Is able to MIRROR into repo that has same commits'() { - // This test case does not make too much sense but used to cause git problems when copying .git from source to target - // java.lang.IllegalArgumentException: File parameter 'destFile is not writable: '/tmp/../.git/objects/pack/pack-524e3f54c7b28a98a4995948dfc8e75f1642840f.pack' - // This only occurs when the same .pack files exists in .git because they are read-only - // So for our testcase we just mirror the same repo twice - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'common/repo'), - new ContentRepositorySchema(url: createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'common/repo', overwriteMode: OverwriteMode.RESET), - ] + createContent(config).install() + + def expectedRepo = 'common/repo' + // clone target repo, to ensure, changes in remote repo. + try (def git = cloneRepo(expectedRepo, tmpDir)) { + assertThat(new File(tmpDir, "file").text).contains("copyRepo2") // Last repo "wins" + assertThat(new File(tmpDir, "mirrorRepo1")).exists().isFile() + + git.fetch().setRefSpecs("refs/*:refs/*").call() // Fetch all tags and branches - scmmApiClient.mockRepoApiBehaviour() - - createContent(config).install() - // No exception means success - } - - @Test - void 'Parses Repo coordinates'() { + assertTag(git, 'someTag') + assertBranch(git, 'someBranch') + } + } - config.content.repos = contentRepos - - def content = createContent(config) - - def actualTargetRepos = content.cloneContentRepos() - def repos = actualTargetRepos + @Test + void 'Handles targetRefs'() { + config.content.repos = [// From branch to branch or tag to tag + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'mirror/tag', ref: 'someTag', targetRef: 'my-tag'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'mirror/branch', ref: 'someBranch', targetRef: 'my-branch'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.COPY, target: 'copy/tag', ref: 'someTag', targetRef: 'my-tag'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.COPY, target: 'copy/branch', ref: 'someBranch', targetRef: 'my-branch'), - assertThat(actualTargetRepos).hasSameSizeAs(expectedTargetRepos) + // From tag to branch or the other way round + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'mirror/tag2branch', ref: 'someTag', targetRef: 'refs/heads/my-branch'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'mirror/branch2tag', ref: 'someBranch', targetRef: 'refs/tags/my-tag'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.COPY, target: 'copy/tag2branch', ref: 'someTag', targetRef: 'refs/heads/my-branch'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.COPY, target: 'copy/branch2tag', ref: 'someBranch', targetRef: 'refs/tags/my-tag'),] - expectedTargetRepos.each { expected -> - - def actual = actualTargetRepos.findAll { actual -> - actual.namespace == expected.namespace && actual.repoName == expected.repoName - } - assertThat(actual).withFailMessage( - "Could not find repo with namespace=${expected.namespace} and repo=${expected.repoName} in ${actualTargetRepos}" - ).hasSize(1) - - assertThat(actual[0].clonedContentRepo.absolutePath).isEqualTo( - new File(findRoot(repos), "${expected.namespace}/${expected.repoName}").absolutePath) - } - } - - @Test - void 'Creates and pushes content repos, whole flow '() { - config.content.repos = contentRepos + - [ - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'common/mirror'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, ref: 'main', target: 'common/mirrorWithBranchRef'), - new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, ref: 'someTag', target: 'common/mirrorWithTagRef'), - ] - - scmmApiClient.mockRepoApiBehaviour() - - createContent(config).install() - - def expectedRepo = 'copy/repo1' - // clone target repo, to ensure, changes in remote repo. - try (def git = cloneRepo(expectedRepo, tmpDir)) { - - def commitMsg = git.log().call().iterator().next().getFullMessage() - assertThat(commitMsg).isEqualTo("Initialize content repo ${expectedRepo}".toString()) - - assertThat(new File(tmpDir, "file").text).contains("copyRepo1") - assertThat(new File(tmpDir, "copyRepo1")).exists().isFile() - } - - expectedRepo = 'common/mirror' - try (def git = cloneRepo(expectedRepo, createRandomSubDir())) { - // Assert mirrors branches and tags of non-folderBased repos - // Verify tag exists and points to correct content - git.fetch().setRefSpecs("refs/*:refs/*").call() // Fetch all tags and branches - - assertTag(git, 'someTag') - assertBranch(git, 'someBranch') - } - - expectedRepo = 'common/mirrorWithBranchRef' - try (def git = cloneRepo(expectedRepo, createRandomSubDir())) { - - git.fetch().setRefSpecs("refs/*:refs/*").call() - - assertNoTags(git) - assertOnlyBranch(git, 'main') - } - - expectedRepo = 'common/mirrorWithTagRef' - try (def git = cloneRepo(expectedRepo, createRandomSubDir())) { - - git.fetch().setRefSpecs("refs/*:refs/*").call() - - assertTag(git, 'someTag') - assertOnlyBranch(git, 'main') - } - - // Mirroring commit references is not supported - config.content.repos = [new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, ref: '8bc1d1165468359b16d9771d4a9a3df26afc03e8', target: 'common/mirrorWithCommitRef')] - - def exception = shouldFail(RuntimeException) { - createContent(config).install() - } - assertThat(exception.message).startsWith('Mirroring commit references is not supported for content repos at the moment. content repository') - assertThat(exception.message).endsWith('ref: 8bc1d1165468359b16d9771d4a9a3df26afc03e8') - - - // Mirroring short commit references is not supported as well - config.content.repos = [new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, ref: '8bc1d11', target: 'common/mirrorWithShortCommitRef')] - - exception = shouldFail(RuntimeException) { - createContent(config).install() - } - assertThat(exception.message).startsWith('Mirroring commit references is not supported for content repos at the moment. content repository') - assertThat(exception.message).endsWith('ref: 8bc1d11') - - // Don't bother validating all other repos here. - // If it works for the most complex one, the other ones will work as well. - // The other tests are already asserting correct combining (including order) and parsing of the repos. - } - - static void assertOnlyBranch(Git git, String branch) { - def branches = assertBranch(git, branch) - def otherBranches = branches.findAll { !it.name.contains(branch) } - assertThat(otherBranches) - .withFailMessage("More than the expected branch main found. Available branches: ${otherBranches.collect { it.name }}") - .hasSize(0) - } - - static void assertNoTags(Git git) { - def tags = git.tagList().call() - assertThat(tags) - .withFailMessage("No tags in mirrored repo with ref expected. Available tags: ${tags.collect { it.name }}") - .hasSize(0) - } - - static List assertBranch(Git git, String someBranch) { - def branches = git.branchList().call() - assertThat(branches.findAll { it.name == "refs/heads/${someBranch}" }) - .withFailMessage("Branch '${someBranch}' not found in git repository. Available branches: ${branches.collect { it.name }}") - .hasSize(1) - return branches - } - - static void assertTag(Git git, String expectedTag) { - def tags = git.tagList().call() - assertThat(tags.findAll { it.name == "refs/tags/$expectedTag" }) - .withFailMessage("Tag '$expectedTag' not found in git repository. Available tags: ${tags.collect { it.name }}") - .hasSize(1) - } - - @Test - void 'Reset common repo to repo '() { - /** - * Prepare Testcase - * using all defined repos -> common/repo is used by copyRepo1 + 2 - * file content after that: copyRepo2 - * - * Then again "RESET" to copyRepo1. - * file content after that should be: copyRepo1 - */ - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'), - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath') - - ] - def expectedRepo = 'common/repo' - def repo = scmmRepoProvider.getRepo(expectedRepo, scmManagerMock) - scmManagerMock.initOnceRepo(repo.repoTarget) - createContent(config).install() - - String url = repo.getGitRepositoryUrl() - // clone repo, to ensure, changes in remote repo. - try (def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(tmpDir).call()) { - - - verify(repo).createRepositoryAndSetPermission(any(String.class), eq(false)) - - def commitMsg = git.log().call().iterator().next().getFullMessage() - assertThat(commitMsg).isEqualTo("Initialize content repo ${expectedRepo}".toString()) - - assertThat(new File(tmpDir, "file").text).contains("copyRepo2") - assertThat(new File(tmpDir, "copyRepo2")).exists().isFile() - } - - /** - * End of preparation - * - * Now Reset to an copied repo - */ - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.RESET), - ] - - createContent(config).install() - scmManagerMock.clearInitOnce() - - def folderAfterReset = File.createTempDir('second-cloned-repo') - folderAfterReset.deleteOnExit() - // clone repo, to ensure, changes in remote repo. - try (def git2 = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(folderAfterReset).call()) { - - assertThat(git2).isNotNull() - // because copyRepo1 is only part of repo1 - assertThat(new File(folderAfterReset, "file").text).contains("copyRepo1") - // should not exists, if RESET to first repo - assertThat(new File(folderAfterReset, "copyRepo2").exists()).isFalse() - - } - - - } - - @Test - void 'Update common repo test '() { - /** - * Prepare Testcase - * using all defined repos -> common/repo is used by copyRepo1 + 2 - * file content after that: copyRepo2 - * - * Then again "RESET" to copyRepo1. - * file content after that should be: copyRepo1 - */ - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'), - ] - - scmmApiClient.mockRepoApiBehaviour() - - createContent(config).install() - - def expectedRepo = 'common/repo' - def repo = scmmRepoProvider.getRepo(expectedRepo, new ScmManagerMock()) - - def url = repo.getGitRepositoryUrl() - // clone repo, to ensure, changes in remote repo. - try (def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(tmpDir).call()) { - - - verify(repo).createRepositoryAndSetPermission(any(String.class), eq(false)) - - def commitMsg = git.log().call().iterator().next().getFullMessage() - assertThat(commitMsg).isEqualTo("Initialize content repo ${expectedRepo}".toString()) - - assertThat(new File(tmpDir, "file").text).contains("copyRepo1") - assertThat(new File(tmpDir, "copyRepo1")).exists().isFile() - - } - /** - * End of preparation - * - * Now Upgrade to type copy - */ - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath', overwriteMode: OverwriteMode.UPGRADE) - ] - - - createContent(config).install() - - def folderAfterReset = File.createTempDir('second-cloned-repo') - folderAfterReset.deleteOnExit() - // clone repo, to ensure, changes in remote repo. - try (def git2 = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(folderAfterReset).call()) { - - assertThat(git2).isNotNull() - // because copyRepo1 is only part of repo1 - assertThat(new File(folderAfterReset, "file").text).contains("copyRepo2") - // should not exists, if RESET to first repo - assertThat(new File(folderAfterReset, "copyRepo2").exists()).isTrue() - - } - } - - @Test - void 'init common repo, expect unchanged repo'() { - /** - * Prepare Testcase - * using all defined repos -> common/repo is used by copyRepo1 + 2 - * file content after that: copyRepo2 - * - * Then again "RESET" to copyRepo1. - * file content after that should be: copyRepo1 - */ - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'), - new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath') - - ] - def expectedRepo = 'common/repo' - def repo = scmmRepoProvider.getRepo(expectedRepo, scmManagerMock) - scmManagerMock.initOnceRepo(repo.repoTarget) - createContent(config).install() - - def url = repo.getGitRepositoryUrl() - // clone repo, to ensure, changes in remote repo. - try (def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(tmpDir).call()) { - - verify(repo).createRepositoryAndSetPermission(any(String.class), eq(false)) - - def commitMsg = git.log().call().iterator().next().getFullMessage() - assertThat(commitMsg).isEqualTo("Initialize content repo ${expectedRepo}".toString()) - - assertThat(new File(tmpDir, "file").text).contains("copyRepo2") - assertThat(new File(tmpDir, "copyRepo2")).exists().isFile() - } - - /** - * End of preparation - * - * Now INit to a copied repo - * no changes expected, file still has copyRepo2 and so on - */ - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.INIT), - ] - - createContent(config).install() - scmManagerMock.clearInitOnce() - - def folderAfterReset = File.createTempDir('second-cloned-repo') - folderAfterReset.deleteOnExit() - // clone repo, to ensure, changes in remote repo. - try (def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(folderAfterReset).call()) { - - assertThat(git).isNotNull() - // because copyRepo1 is only part of repo1 - assertThat(new File(folderAfterReset, "file").text).contains("copyRepo2") - // should not exists, if RESET to first repo - assertThat(new File(folderAfterReset, "copyRepo2").exists()).isTrue() - - } - - } - - @Test - void 'ensure Jenkinsjob will be created'() { - /** - * Prepare Testcase - * using all defined repos -> common/repo is used by copyRepo1 + 2 - * file content after that: copyRepo2 - * - * Then again "RESET" to copyRepo1. - * file content after that should be: copyRepo1 - */ - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, createJenkinsJob: true, target: 'common/repo'), - ] - scmmApiClient.mockRepoApiBehaviour() - when(jenkins.isEnabled()).thenReturn(true) - - createContent(config).install() - verify(jenkins).createJenkinsjob(any(), any()) - } - - @Test - void 'ensure Jenkinsjob creation will be ignored'() { - /** - * Prepare Testcase - * using all defined repos -> common/repo is used by copyRepo1 + 2 - * file content after that: copyRepo2 - * - * Then again "RESET" to copyRepo1. - * file content after that should be: copyRepo1 - */ - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, createJenkinsJob: false, target: 'common/repo'), - ] - scmmApiClient.mockRepoApiBehaviour() - when(jenkins.isEnabled()).thenReturn(false) - createContent(config).install() - verify(jenkins, never()).createJenkinsjob(any(), any()) - } - - @Test - void 'ensure Jenkinsjob will not be created, if jenkins is not enables'() { - /** - * Prepare Testcase - * using all defined repos -> common/repo is used by copyRepo1 + 2 - * file content after that: copyRepo2 - * - * Then again "RESET" to copyRepo1. - * file content after that should be: copyRepo1 - */ - config.content.repos = [ - new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, createJenkinsJob: false, target: 'common/repo'), - ] - scmmApiClient.mockRepoApiBehaviour() - when(jenkins.isEnabled()).thenReturn(false) - - createContent(config).install() - verify(jenkins, never()).createJenkinsjob(any(), any()) - } - - @Test - void 'deployHelmReleasesFromContent skips when helmReleases missing or empty'() { - def contentLoader = createContent(config) - contentLoader.install() - - assertThat(contentLoader.deployCalls).isEmpty() - } - - @Test - void 'deployHelmReleasesFromContent calls deployHelmChart with valuesPath and helm config'() { - // Arrange: create a real values file on disk - Path valuesFile = Files.createTempFile("harbor-values-", ".yaml") - Files.writeString(valuesFile, """ + scmmApiClient.mockRepoApiBehaviour() + + createContent(config).install() + + // From branch to branch or tag to tag + assertTagAndReadme('mirror/tag', 'my-tag', "someTag") + assertBranchAndReadme('mirror/branch', 'my-branch', "someBranch") + + assertTagAndReadme('copy/tag', 'my-tag', "someTag") + assertBranchAndReadme('copy/branch', 'my-branch', "someBranch") + + // From tag to branch or the other way round + assertTagAndReadme('mirror/branch2tag', 'my-tag', "someBranch") + assertBranchAndReadme('mirror/tag2branch', 'my-branch', "someTag") + + assertTagAndReadme('copy/branch2tag', 'my-tag', "someBranch") + assertBranchAndReadme('copy/tag2branch', 'my-branch', "someTag") + } + + @Test + void 'Handles multiple mirrors of the same repo with different refs, where one is not pushed'() { + // This test case does not make too much sense but used to cause git problems when we merged all content repos into a single folder, like + // TransportException: Missing unknown 5bcf50f0537bf4d2719a82e9b0950fbac92b3ecc + def repoToMirror = createContentRepo('copyRepo1', 'git-repository-with-branches-tags') + config.content.repos = [new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'main', target: 'common/repo'), + new ContentRepositorySchema(url: repoToMirror, type: ContentRepoType.MIRROR, ref: 'someBranch', target: 'common/repo') /* Deliberately not use overwriteMode here !*/, + new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.UPGRADE, path: 'subPath')] + + scmmApiClient.mockRepoApiBehaviour() + + createContent(config).install() + // No exception means success + } + + @Test + void 'Is able to MIRROR into repo that has same commits'() { + // This test case does not make too much sense but used to cause git problems when copying .git from source to target + // java.lang.IllegalArgumentException: File parameter 'destFile is not writable: '/tmp/../.git/objects/pack/pack-524e3f54c7b28a98a4995948dfc8e75f1642840f.pack' + // This only occurs when the same .pack files exists in .git because they are read-only + // So for our testcase we just mirror the same repo twice + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'common/repo'), + new ContentRepositorySchema(url: createContentRepo('mirrorRepo1', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'common/repo', overwriteMode: OverwriteMode.RESET),] + + scmmApiClient.mockRepoApiBehaviour() + + createContent(config).install() + // No exception means success + } + + @Test + void 'Parses Repo coordinates'() { + + config.content.repos = contentRepos + + def content = createContent(config) + + def actualTargetRepos = content.cloneContentRepos() + def repos = actualTargetRepos + + assertThat(actualTargetRepos).hasSameSizeAs(expectedTargetRepos) + + expectedTargetRepos.each { expected -> + + def actual = actualTargetRepos.findAll { actual -> actual.namespace == expected.namespace && actual.repoName == expected.repoName + } + assertThat(actual).withFailMessage("Could not find repo with namespace=${expected.namespace} and repo=${expected.repoName} in ${actualTargetRepos}").hasSize(1) + + assertThat(actual[0].clonedContentRepo.absolutePath).isEqualTo(new File(findRoot(repos), "${expected.namespace}/${expected.repoName}").absolutePath) + } + } + + @Test + void 'Creates and pushes content repos, whole flow '() { + config.content.repos = contentRepos + [new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, target: 'common/mirror'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, ref: 'main', target: 'common/mirrorWithBranchRef'), + new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, ref: 'someTag', target: 'common/mirrorWithTagRef'),] + + scmmApiClient.mockRepoApiBehaviour() + + createContent(config).install() + + def expectedRepo = 'copy/repo1' + // clone target repo, to ensure, changes in remote repo. + try (def git = cloneRepo(expectedRepo, tmpDir)) { + + def commitMsg = git.log().call().iterator().next().getFullMessage() + assertThat(commitMsg).isEqualTo("Initialize content repo ${expectedRepo}".toString()) + + assertThat(new File(tmpDir, "file").text).contains("copyRepo1") + assertThat(new File(tmpDir, "copyRepo1")).exists().isFile() + } + + expectedRepo = 'common/mirror' + try (def git = cloneRepo(expectedRepo, createRandomSubDir())) { + // Assert mirrors branches and tags of non-folderBased repos + // Verify tag exists and points to correct content + git.fetch().setRefSpecs("refs/*:refs/*").call() // Fetch all tags and branches + + assertTag(git, 'someTag') + assertBranch(git, 'someBranch') + } + + expectedRepo = 'common/mirrorWithBranchRef' + try (def git = cloneRepo(expectedRepo, createRandomSubDir())) { + + git.fetch().setRefSpecs("refs/*:refs/*").call() + + assertNoTags(git) + assertOnlyBranch(git, 'main') + } + + expectedRepo = 'common/mirrorWithTagRef' + try (def git = cloneRepo(expectedRepo, createRandomSubDir())) { + + git.fetch().setRefSpecs("refs/*:refs/*").call() + + assertTag(git, 'someTag') + assertOnlyBranch(git, 'main') + } + + // Mirroring commit references is not supported + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, ref: '8bc1d1165468359b16d9771d4a9a3df26afc03e8', target: 'common/mirrorWithCommitRef')] + + def exception = shouldFail(RuntimeException) { + createContent(config).install() + } + assertThat(exception.message).startsWith('Mirroring commit references is not supported for content repos at the moment. content repository') + assertThat(exception.message).endsWith('ref: 8bc1d1165468359b16d9771d4a9a3df26afc03e8') + + + // Mirroring short commit references is not supported as well + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('', 'git-repository-with-branches-tags'), type: ContentRepoType.MIRROR, ref: '8bc1d11', target: 'common/mirrorWithShortCommitRef')] + + exception = shouldFail(RuntimeException) { + createContent(config).install() + } + assertThat(exception.message).startsWith('Mirroring commit references is not supported for content repos at the moment. content repository') + assertThat(exception.message).endsWith('ref: 8bc1d11') + + // Don't bother validating all other repos here. + // If it works for the most complex one, the other ones will work as well. + // The other tests are already asserting correct combining (including order) and parsing of the repos. + } + + static void assertOnlyBranch(Git git, String branch) { + def branches = assertBranch(git, branch) + def otherBranches = branches.findAll { !it.name.contains(branch) } + assertThat(otherBranches) + .withFailMessage("More than the expected branch main found. Available branches: ${otherBranches.collect { it.name }}") + .hasSize(0) + } + + static void assertNoTags(Git git) { + def tags = git.tagList().call() + assertThat(tags) + .withFailMessage("No tags in mirrored repo with ref expected. Available tags: ${tags.collect { it.name }}") + .hasSize(0) + } + + static List assertBranch(Git git, String someBranch) { + def branches = git.branchList().call() + assertThat(branches.findAll { it.name == "refs/heads/${someBranch}" }) + .withFailMessage("Branch '${someBranch}' not found in git repository. Available branches: ${branches.collect { it.name }}") + .hasSize(1) + return branches + } + + static void assertTag(Git git, String expectedTag) { + def tags = git.tagList().call() + assertThat(tags.findAll { it.name == "refs/tags/$expectedTag" }) + .withFailMessage("Tag '$expectedTag' not found in git repository. Available tags: ${tags.collect { it.name }}") + .hasSize(1) + } + + @Test + void 'Reset common repo to repo '() { + /** + * Prepare Testcase + * using all defined repos -> common/repo is used by copyRepo1 + 2 + * file content after that: copyRepo2 + * + * Then again "RESET" to copyRepo1. + * file content after that should be: copyRepo1*/ + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'), + new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath') + + ] + def expectedRepo = 'common/repo' + def repo = scmmRepoProvider.getRepo(expectedRepo, scmManagerMock) + scmManagerMock.initOnceRepo(repo.repoTarget) + createContent(config).install() + + String url = repo.getGitRepositoryUrl() + // clone repo, to ensure, changes in remote repo. + try (def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(tmpDir).call()) { + + verify(repo).createRepositoryAndSetPermission(any(String.class), eq(false)) + + def commitMsg = git.log().call().iterator().next().getFullMessage() + assertThat(commitMsg).isEqualTo("Initialize content repo ${expectedRepo}".toString()) + + assertThat(new File(tmpDir, "file").text).contains("copyRepo2") + assertThat(new File(tmpDir, "copyRepo2")).exists().isFile() + } + + /** + * End of preparation + * + * Now Reset to an copied repo*/ + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.RESET),] + + createContent(config).install() + scmManagerMock.clearInitOnce() + + def folderAfterReset = File.createTempDir('second-cloned-repo') + folderAfterReset.deleteOnExit() + // clone repo, to ensure, changes in remote repo. + try (def git2 = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(folderAfterReset).call()) { + + assertThat(git2).isNotNull() + // because copyRepo1 is only part of repo1 + assertThat(new File(folderAfterReset, "file").text).contains("copyRepo1") + // should not exists, if RESET to first repo + assertThat(new File(folderAfterReset, "copyRepo2").exists()).isFalse() + + } + + } + + @Test + void 'Update common repo test '() { + /** + * Prepare Testcase + * using all defined repos -> common/repo is used by copyRepo1 + 2 + * file content after that: copyRepo2 + * + * Then again "RESET" to copyRepo1. + * file content after that should be: copyRepo1*/ + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'),] + + scmmApiClient.mockRepoApiBehaviour() + + createContent(config).install() + + def expectedRepo = 'common/repo' + def repo = scmmRepoProvider.getRepo(expectedRepo, new ScmManagerMock()) + + def url = repo.getGitRepositoryUrl() + // clone repo, to ensure, changes in remote repo. + try (def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(tmpDir).call()) { + + verify(repo).createRepositoryAndSetPermission(any(String.class), eq(false)) + + def commitMsg = git.log().call().iterator().next().getFullMessage() + assertThat(commitMsg).isEqualTo("Initialize content repo ${expectedRepo}".toString()) + + assertThat(new File(tmpDir, "file").text).contains("copyRepo1") + assertThat(new File(tmpDir, "copyRepo1")).exists().isFile() + + } + /** + * End of preparation + * + * Now Upgrade to type copy*/ + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath', overwriteMode: OverwriteMode.UPGRADE)] + + createContent(config).install() + + def folderAfterReset = File.createTempDir('second-cloned-repo') + folderAfterReset.deleteOnExit() + // clone repo, to ensure, changes in remote repo. + try (def git2 = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(folderAfterReset).call()) { + + assertThat(git2).isNotNull() + // because copyRepo1 is only part of repo1 + assertThat(new File(folderAfterReset, "file").text).contains("copyRepo2") + // should not exists, if RESET to first repo + assertThat(new File(folderAfterReset, "copyRepo2").exists()).isTrue() + + } + } + + @Test + void 'init common repo, expect unchanged repo'() { + /** + * Prepare Testcase + * using all defined repos -> common/repo is used by copyRepo1 + 2 + * file content after that: copyRepo2 + * + * Then again "RESET" to copyRepo1. + * file content after that should be: copyRepo1*/ + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo'), + new ContentRepositorySchema(url: createContentRepo('copyRepo2'), type: ContentRepoType.COPY, target: 'common/repo', path: 'subPath') + + ] + def expectedRepo = 'common/repo' + def repo = scmmRepoProvider.getRepo(expectedRepo, scmManagerMock) + scmManagerMock.initOnceRepo(repo.repoTarget) + createContent(config).install() + + def url = repo.getGitRepositoryUrl() + // clone repo, to ensure, changes in remote repo. + try (def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(tmpDir).call()) { + + verify(repo).createRepositoryAndSetPermission(any(String.class), eq(false)) + + def commitMsg = git.log().call().iterator().next().getFullMessage() + assertThat(commitMsg).isEqualTo("Initialize content repo ${expectedRepo}".toString()) + + assertThat(new File(tmpDir, "file").text).contains("copyRepo2") + assertThat(new File(tmpDir, "copyRepo2")).exists().isFile() + } + + /** + * End of preparation + * + * Now INit to a copied repo + * no changes expected, file still has copyRepo2 and so on*/ + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, target: 'common/repo', overwriteMode: OverwriteMode.INIT),] + + createContent(config).install() + scmManagerMock.clearInitOnce() + + def folderAfterReset = File.createTempDir('second-cloned-repo') + folderAfterReset.deleteOnExit() + // clone repo, to ensure, changes in remote repo. + try (def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(folderAfterReset).call()) { + + assertThat(git).isNotNull() + // because copyRepo1 is only part of repo1 + assertThat(new File(folderAfterReset, "file").text).contains("copyRepo2") + // should not exists, if RESET to first repo + assertThat(new File(folderAfterReset, "copyRepo2").exists()).isTrue() + + } + + } + + @Test + void 'ensure Jenkinsjob will be created'() { + /** + * Prepare Testcase + * using all defined repos -> common/repo is used by copyRepo1 + 2 + * file content after that: copyRepo2 + * + * Then again "RESET" to copyRepo1. + * file content after that should be: copyRepo1*/ + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, createJenkinsJob: true, target: 'common/repo'),] + scmmApiClient.mockRepoApiBehaviour() + when(jenkins.isEnabled()).thenReturn(true) + + createContent(config).install() + verify(jenkins).createJenkinsjob(any(), any()) + } + + @Test + void 'ensure Jenkinsjob creation will be ignored'() { + /** + * Prepare Testcase + * using all defined repos -> common/repo is used by copyRepo1 + 2 + * file content after that: copyRepo2 + * + * Then again "RESET" to copyRepo1. + * file content after that should be: copyRepo1*/ + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, createJenkinsJob: false, target: 'common/repo'),] + scmmApiClient.mockRepoApiBehaviour() + when(jenkins.isEnabled()).thenReturn(false) + createContent(config).install() + verify(jenkins, never()).createJenkinsjob(any(), any()) + } + + @Test + void 'ensure Jenkinsjob will not be created, if jenkins is not enables'() { + /** + * Prepare Testcase + * using all defined repos -> common/repo is used by copyRepo1 + 2 + * file content after that: copyRepo2 + * + * Then again "RESET" to copyRepo1. + * file content after that should be: copyRepo1*/ + config.content.repos = [new ContentRepositorySchema(url: createContentRepo('copyRepo1'), ref: 'main', type: ContentRepoType.COPY, createJenkinsJob: false, target: 'common/repo'),] + scmmApiClient.mockRepoApiBehaviour() + when(jenkins.isEnabled()).thenReturn(false) + + createContent(config).install() + verify(jenkins, never()).createJenkinsjob(any(), any()) + } + + @Test + void 'deployHelmReleasesFromContent skips when helmReleases missing or empty'() { + def contentLoader = createContent(config) + contentLoader.install() + + assertThat(contentLoader.deployCalls).isEmpty() + } + + @Test + void 'deployHelmReleasesFromContent calls deployHelmChart with valuesPath and helm config'() { + // Arrange: create a real values file on disk + Path valuesFile = Files.createTempFile("harbor-values-", ".yaml") + Files.writeString(valuesFile, """ expose: type: ingress """.stripIndent()) - def cfg = Config.fromMap( - content: [ - helmReleases: [ - [ - name : 'harbor', - repoURL : 'https://helm.goharbor.io', - chart : 'harbor', - version : '1.18.2', - namespace : 'my-prefix-harbor', - releaseName: 'harbor', - valuesPath : valuesFile.toString() - ] - ] - ] - ) - - def contentLoader = createContent(cfg) - contentLoader.install() - - assertThat(contentLoader.deployCalls).hasSize(1) - def call = contentLoader.deployCalls[0] - - assertThat(call.featureName).isEqualTo('harbor') - assertThat(call.releaseName).isEqualTo('harbor') - assertThat(call.namespace).isEqualTo('my-prefix-harbor') - - // IMPORTANT: With the new implementation you likely pass a merged temp file, - // not the original valuesPath. So assert it's a file that exists. - assertThat(call.valuesPath).isNotBlank() - assertThat(Path.of(call.valuesPath).toFile()).exists() - - assertThat(call.helmConfig.repoURL).isEqualTo('https://helm.goharbor.io') - assertThat(call.helmConfig.chart).isEqualTo('harbor') - assertThat(call.helmConfig.version).isEqualTo('1.18.2') - assertThat(call.config).isSameAs(cfg) - } - - @Test - void 'deployHelmReleasesFromContent reads values file and inline values override file values'(@TempDir Path tempDir) { - // values file: replicas=1 - Path valuesFile = tempDir.resolve("harbor-values.yaml") - Files.writeString(valuesFile, """ + def cfg = Config.fromMap(content: [helmReleases: [[name : 'harbor', + repoURL : 'https://helm.goharbor.io', + chart : 'harbor', + version : '1.18.2', + namespace : 'my-prefix-harbor', + releaseName: 'harbor', + valuesPath : valuesFile.toString()]]]) + + def contentLoader = createContent(cfg) + contentLoader.install() + + assertThat(contentLoader.deployCalls).hasSize(1) + def call = contentLoader.deployCalls[0] + + assertThat(call.featureName).isEqualTo('harbor') + assertThat(call.releaseName).isEqualTo('harbor') + assertThat(call.namespace).isEqualTo('my-prefix-harbor') + + // IMPORTANT: With the new implementation you likely pass a merged temp file, + // not the original valuesPath. So assert it's a file that exists. + assertThat(call.valuesPath).isNotBlank() + assertThat(Path.of(call.valuesPath).toFile()).exists() + + assertThat(call.helmConfig.repoURL).isEqualTo('https://helm.goharbor.io') + assertThat(call.helmConfig.chart).isEqualTo('harbor') + assertThat(call.helmConfig.version).isEqualTo('1.18.2') + assertThat(call.config).isSameAs(cfg) + } + + @Test + void 'deployHelmReleasesFromContent reads values file and inline values override file values'(@TempDir Path tempDir) { + // values file: replicas=1 + Path valuesFile = tempDir.resolve("harbor-values.yaml") + Files.writeString(valuesFile, """ replicas: 1 service: type: ClusterIP """.stripIndent()) - def cfg = Config.fromMap( - content: [ - helmReleases: [ - [ - name : 'harbor', - repoURL : 'https://helm.goharbor.io', - chart : 'harbor', - version : '1.18.2', - namespace : 'my-prefix-harbor', - releaseName: 'harbor', - valuesPath : valuesFile.toString(), - values : [ - replicas: 2, // override file - service : [type: 'NodePort'] // override nested - ] - ] - ] - ] - ) - - def contentLoader = createContent(cfg) - contentLoader.install() - - assertThat(contentLoader.deployCalls).hasSize(1) - - def call = contentLoader.deployCalls[0] - - // IMPORTANT: valuesPath is a temp file created by writeTempFile(...) - Path mergedTemp = Path.of(call.valuesPath) - assertThat(mergedTemp).exists() - - def mergedYaml = new YamlSlurper().parse(mergedTemp.toFile()) as Map - - // inline overrides file - assertThat(mergedYaml['replicas']).isEqualTo(2) - assertThat(((Map) mergedYaml['service'])['type']).isEqualTo('NodePort') - } - - @Test - void 'deployHelmReleasesFromContent uses values file when inline values are empty'(@TempDir Path tempDir) { - Path valuesFile = tempDir.resolve("values.yaml") - Files.writeString(valuesFile, """ + def cfg = Config.fromMap(content: [helmReleases: [[name : 'harbor', + repoURL : 'https://helm.goharbor.io', + chart : 'harbor', + version : '1.18.2', + namespace : 'my-prefix-harbor', + releaseName: 'harbor', + valuesPath : valuesFile.toString(), + values : [replicas: 2, // override file + service : [type: 'NodePort'] // override nested + ]]]]) + + def contentLoader = createContent(cfg) + contentLoader.install() + + assertThat(contentLoader.deployCalls).hasSize(1) + + def call = contentLoader.deployCalls[0] + + // IMPORTANT: valuesPath is a temp file created by writeTempFile(...) + Path mergedTemp = Path.of(call.valuesPath) + assertThat(mergedTemp).exists() + + def mergedYaml = new YamlSlurper().parse(mergedTemp.toFile()) as Map + + // inline overrides file + assertThat(mergedYaml['replicas']).isEqualTo(2) + assertThat(((Map) mergedYaml['service'])['type']).isEqualTo('NodePort') + } + + @Test + void 'deployHelmReleasesFromContent uses values file when inline values are empty'(@TempDir Path tempDir) { + Path valuesFile = tempDir.resolve("values.yaml") + Files.writeString(valuesFile, """ replicas: 1 """.stripIndent()) - def cfg = Config.fromMap( - content: [ - helmReleases: [ - [ - name : 'elasticsearch', - repoURL : 'https://helm.elastic.co', - chart : 'elasticsearch', - version : '8.5.1', - namespace : 'my-prefix-elasticsearch', - valuesPath: valuesFile.toString() - // no values - ] - ] - ] - ) - - def contentLoader = createContent(cfg) - contentLoader.install() - - assertThat(contentLoader.deployCalls).hasSize(1) - - def call = contentLoader.deployCalls[0] - Path mergedTemp = Path.of(call.valuesPath) - assertThat(mergedTemp).exists() - - def mergedYaml = new YamlSlurper().parse(mergedTemp.toFile()) as Map - assertThat(mergedYaml['replicas']).isEqualTo(1) - } - - @Test - void 'deployHelmReleasesFromContent uses inline values when no helmValuesPath is set'() { - def cfg = Config.fromMap( - content: [ - helmReleases: [ - [ - name : 'elasticsearch', - repoURL : 'https://helm.elastic.co', - chart : 'elasticsearch', - version : '8.5.1', - namespace: 'my-prefix-elasticsearch', - values : [ - replicas: 2 - ] - // helmValuesPath empty / missing - ] - ] - ] - ) - - def contentLoader = createContent(cfg) - contentLoader.install() - - assertThat(contentLoader.deployCalls).hasSize(1) - - def call = contentLoader.deployCalls[0] - Path mergedTemp = Path.of(call.valuesPath) - assertThat(mergedTemp).exists() - - def mergedYaml = new YamlSlurper().parse(mergedTemp.toFile()) as Map - assertThat(mergedYaml['replicas']).isEqualTo(2) - } - - @Test - void 'deployHelmReleasesFromContent defaults chart version to wildcard when missing'() { - def cfg = Config.fromMap( - content: [ - helmReleases: [ - [ - name : 'harbor', - repoURL : 'https://helm.goharbor.io', - chart : 'harbor', - version : ' ', // blank - namespace : 'my-prefix-harbor', - releaseName: 'harbor', - values : [foo: 'bar'] - ] - ] - ] - ) - - def contentLoader = createContent(cfg) - contentLoader.install() - - assertThat(contentLoader.deployCalls).hasSize(1) - def call = contentLoader.deployCalls[0] - - assertThat(call.helmConfig.version).isEqualTo('*') - } - - static String createContentRepo(String initPath = '', String baseBareRepo = 'git-repository') { - // The bare repo works as the "remote" - def bareRepoDir = File.createTempDir('gitops-playground-test-content-repo') - bareRepoDir.deleteOnExit() - foldersToDelete << bareRepoDir - // init with bare repo - FileUtils.copyDirectory(new File(System.getProperty("user.dir") + "/src/test/groovy/com/cloudogu/gitops/utils/data/${baseBareRepo}/"), bareRepoDir) - def bareRepoUri = 'file://' + bareRepoDir.absolutePath - log.debug("Repo $initPath: bare repo $bareRepoUri") - - if (initPath) { - // Add initPath to bare repo - def tempRepo = File.createTempDir('gitops-playground-temp-repo') - tempRepo.deleteOnExit() - foldersToDelete << tempRepo - log.debug("Repo $initPath: cloned bare repo to $tempRepo") - try (def git = Git.cloneRepository() - .setURI(bareRepoUri) - .setBranch('main') - .setDirectory(tempRepo) - .call()) { - - - FileUtils.copyDirectory(new File(System.getProperty("user.dir") + '/src/test/groovy/com/cloudogu/gitops/utils/data/contentRepos/' + initPath), tempRepo) - - git.add().addFilepattern(".").call() - - // Avoid complications with local developer's git config, e.g. when git config --global commit.gpgSign true - SystemReader.getInstance().userConfig.clear() - git.commit().setMessage("Initialize with $initPath").call() - git.push().call() - tempRepo.delete() - } - } - - return bareRepoUri - } - - private Map parseYaml(String path) { - return new YamlSlurper().parse(new File(path)) as Map - } - - private void assertRegistrySecrets(String regUser, String regPw) { - List expectedNamespaces = ["example-apps-staging", "example-apps-production"] - expectedNamespaces.each { - - k8sClient.commandExecutorForTest.assertExecuted( - "kubectl create secret docker-registry registry -n ${it}" + - " --docker-server reg-url --docker-username $regUser --docker-password ${regPw}" + - ' --dry-run=client -oyaml | kubectl apply -f-') - - def patchCommand = k8sClient.commandExecutorForTest.assertExecuted( - "kubectl patch serviceaccount default -n ${it}") - String patchFile = (patchCommand =~ /--patch-file=([\S]+)/)?.findResult { (it as List)[1] } - assertThat(parseActualYaml(new File(patchFile))['imagePullSecrets'] as List).hasSize(1) - assertThat((parseActualYaml(new File(patchFile))['imagePullSecrets'] as List)[0] as Map).containsEntry('name', 'registry') - } - } - - private ContentLoaderForTest createContent(Config config) { - new ContentLoaderForTest(config, k8sClient, scmmRepoProvider, jenkins, gitHandler, fileSystemUtils, deploymentStrategy) - } - - private static parseActualYaml(File pathToYamlFile) { - def ys = new YamlSlurper() - return ys.parse(pathToYamlFile) - } - - private static String findRoot(List repos) { - def result = new File(repos.get(0).getClonedContentRepo().getParent()).getParent() - return result; - - } - - Git cloneRepo(String expectedRepo, File repoFolder) { - def repo = scmmRepoProvider.getRepo(expectedRepo, new ScmManagerMock()) - def url = repo.getGitRepositoryUrl() - - def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(repoFolder).call() - git.getRepository().getConfig().setBoolean("gc", null, "autoDetach", false) - return git - } - - - private File createRandomSubDir(String prefix = '') { - def randomDir = tmpDir.toPath().resolve("${prefix ? "${prefix}-" : ''}${System.currentTimeMillis()}").toFile() - randomDir.mkdirs() - return randomDir - } - - void assertTagAndReadme(String repo, String expectedTag, String expectedReadmeContent) { - def repoFolder = createRandomSubDir() - try (def git = cloneRepo(repo, repoFolder)) { - git.fetch().setRefSpecs("refs/*:refs/*").call() - assertTag(git, expectedTag) - - git.checkout().setName(expectedTag).call() - assertThat(new File(repoFolder, "README.md")).exists().isFile() - assertThat(new File(repoFolder, "README.md").text).contains(expectedReadmeContent) - } - } - - void assertBranchAndReadme(String repo, String expectedBranch, String expectedReadmeContent) { - def repoFolder = createRandomSubDir() - try (def git = cloneRepo(repo, repoFolder)) { - git.fetch().setRefSpecs("refs/*:refs/*").call() - assertBranch(git, expectedBranch) - - git.checkout().setName(expectedBranch).call() - assertThat(new File(repoFolder, "README.md")).exists().isFile() - assertThat(new File(repoFolder, "README.md").text).contains(expectedReadmeContent) - } - } - - class ContentLoaderForTest extends ContentLoader { - List deployCalls = [] - CloneCommand cloneSpy - - ContentLoaderForTest(Config config, K8sClient k8sClient, GitRepoFactory repoProvider, Jenkins jenkins, GitHandler gitHandler, FileSystemUtils fileSystemUtils, DeploymentStrategy deploymentStrategy) { - super(config, k8sClient, repoProvider, jenkins, gitHandler, fileSystemUtils, deploymentStrategy) - } - - @Override - protected void deployHelmChart(String featureName, - String releaseName, - String namespace, - Config.HelmConfigWithValues helmConfig, - String helmValuesTemplatePath, - Config config) { - deployCalls << new DeployCall( - featureName: featureName, - releaseName: releaseName, - namespace: namespace, - helmConfig: helmConfig, - valuesPath: helmValuesTemplatePath, - config: config - ) - } - - @Override - protected CloneCommand gitClone() { - cloneSpy = spy(super.gitClone().setNoCheckout(true)) - } - } - static class DeployCall { - String featureName - String releaseName - String namespace - Config.HelmConfigWithValues helmConfig - String valuesPath - Config config - } -} + def cfg = Config.fromMap(content: [helmReleases: [[name : 'elasticsearch', + repoURL : 'https://helm.elastic.co', + chart : 'elasticsearch', + version : '8.5.1', + namespace : 'my-prefix-elasticsearch', + valuesPath: valuesFile.toString() + // no values + ]]]) + + def contentLoader = createContent(cfg) + contentLoader.install() + + assertThat(contentLoader.deployCalls).hasSize(1) + + def call = contentLoader.deployCalls[0] + Path mergedTemp = Path.of(call.valuesPath) + assertThat(mergedTemp).exists() + + def mergedYaml = new YamlSlurper().parse(mergedTemp.toFile()) as Map + assertThat(mergedYaml['replicas']).isEqualTo(1) + } + + @Test + void 'deployHelmReleasesFromContent uses inline values when no helmValuesPath is set'() { + def cfg = Config.fromMap(content: [helmReleases: [[name : 'elasticsearch', + repoURL : 'https://helm.elastic.co', + chart : 'elasticsearch', + version : '8.5.1', + namespace: 'my-prefix-elasticsearch', + values : [replicas: 2] + // helmValuesPath empty / missing + ]]]) + + def contentLoader = createContent(cfg) + contentLoader.install() + + assertThat(contentLoader.deployCalls).hasSize(1) + + def call = contentLoader.deployCalls[0] + Path mergedTemp = Path.of(call.valuesPath) + assertThat(mergedTemp).exists() + + def mergedYaml = new YamlSlurper().parse(mergedTemp.toFile()) as Map + assertThat(mergedYaml['replicas']).isEqualTo(2) + } + + @Test + void 'deployHelmReleasesFromContent defaults chart version to wildcard when missing'() { + def cfg = Config.fromMap(content: [helmReleases: [[name : 'harbor', + repoURL : 'https://helm.goharbor.io', + chart : 'harbor', + version : ' ', // blank + namespace : 'my-prefix-harbor', + releaseName: 'harbor', + values : [foo: 'bar']]]]) + + def contentLoader = createContent(cfg) + contentLoader.install() + + assertThat(contentLoader.deployCalls).hasSize(1) + def call = contentLoader.deployCalls[0] + + assertThat(call.helmConfig.version).isEqualTo('*') + } + + static String createContentRepo(String initPath = '', String baseBareRepo = 'git-repository') { + // The bare repo works as the "remote" + def bareRepoDir = File.createTempDir('gitops-playground-test-content-repo') + bareRepoDir.deleteOnExit() + foldersToDelete << bareRepoDir + // init with bare repo + FileUtils.copyDirectory(new File(System.getProperty("user.dir") + "/src/test/groovy/com/cloudogu/gitops/utils/data/${baseBareRepo}/"), bareRepoDir) + def bareRepoUri = 'file://' + bareRepoDir.absolutePath + log.debug("Repo $initPath: bare repo $bareRepoUri") + + if (initPath) { + // Add initPath to bare repo + def tempRepo = File.createTempDir('gitops-playground-temp-repo') + tempRepo.deleteOnExit() + foldersToDelete << tempRepo + log.debug("Repo $initPath: cloned bare repo to $tempRepo") + try (def git = Git.cloneRepository() + .setURI(bareRepoUri) + .setBranch('main') + .setDirectory(tempRepo) + .call()) { + + FileUtils.copyDirectory(new File(System.getProperty("user.dir") + '/src/test/groovy/com/cloudogu/gitops/utils/data/contentRepos/' + initPath), tempRepo) + + git.add().addFilepattern(".").call() + + // Avoid complications with local developer's git config, e.g. when git config --global commit.gpgSign true + SystemReader.getInstance().userConfig.clear() + git.commit().setMessage("Initialize with $initPath").call() + git.push().call() + tempRepo.delete() + } + } + + return bareRepoUri + } + + private Map parseYaml(String path) { + return new YamlSlurper().parse(new File(path)) as Map + } + + private void assertRegistrySecrets(String regUser, String regPw) { + List expectedNamespaces = ["example-apps-staging", "example-apps-production"] + expectedNamespaces.each { + + k8sClient.commandExecutorForTest.assertExecuted("kubectl create secret docker-registry registry -n ${it}" + " --docker-server reg-url --docker-username $regUser --docker-password ${regPw}" + + ' --dry-run=client -oyaml | kubectl apply -f-') + + def patchCommand = k8sClient.commandExecutorForTest.assertExecuted("kubectl patch serviceaccount default -n ${it}") + String patchFile = (patchCommand =~ /--patch-file=([\S]+)/)?.findResult { (it as List)[1] } + assertThat(parseActualYaml(new File(patchFile))['imagePullSecrets'] as List).hasSize(1) + assertThat((parseActualYaml(new File(patchFile))['imagePullSecrets'] as List)[0] as Map).containsEntry('name', 'registry') + } + } + + private ContentLoaderForTest createContent(Config config) { + new ContentLoaderForTest(config, k8sClient, scmmRepoProvider, jenkins, gitHandler, fileSystemUtils, deployer) + } + + private static parseActualYaml(File pathToYamlFile) { + def ys = new YamlSlurper() + return ys.parse(pathToYamlFile) + } + + private static String findRoot(List repos) { + def result = new File(repos.get(0).getClonedContentRepo().getParent()).getParent() + return result; + + } + + Git cloneRepo(String expectedRepo, File repoFolder) { + def repo = scmmRepoProvider.getRepo(expectedRepo, new ScmManagerMock()) + def url = repo.getGitRepositoryUrl() + + def git = Git.cloneRepository().setURI(url).setBranch('main').setDirectory(repoFolder).call() + git.getRepository().getConfig().setBoolean("gc", null, "autoDetach", false) + return git + } + + private File createRandomSubDir(String prefix = '') { + def randomDir = tmpDir.toPath().resolve("${prefix ? "${prefix}-" : ''}${System.currentTimeMillis()}").toFile() + randomDir.mkdirs() + return randomDir + } + + void assertTagAndReadme(String repo, String expectedTag, String expectedReadmeContent) { + def repoFolder = createRandomSubDir() + try (def git = cloneRepo(repo, repoFolder)) { + git.fetch().setRefSpecs("refs/*:refs/*").call() + assertTag(git, expectedTag) + + git.checkout().setName(expectedTag).call() + assertThat(new File(repoFolder, "README.md")).exists().isFile() + assertThat(new File(repoFolder, "README.md").text).contains(expectedReadmeContent) + } + } + + void assertBranchAndReadme(String repo, String expectedBranch, String expectedReadmeContent) { + def repoFolder = createRandomSubDir() + try (def git = cloneRepo(repo, repoFolder)) { + git.fetch().setRefSpecs("refs/*:refs/*").call() + assertBranch(git, expectedBranch) + + git.checkout().setName(expectedBranch).call() + assertThat(new File(repoFolder, "README.md")).exists().isFile() + assertThat(new File(repoFolder, "README.md").text).contains(expectedReadmeContent) + } + } + + class ContentLoaderForTest extends ContentLoader { + List deployCalls = [] + CloneCommand cloneSpy + + ContentLoaderForTest( + Config config, K8sClient k8sClient, GitRepoFactory repoProvider, Jenkins jenkins, GitHandler gitHandler, FileSystemUtils fileSystemUtils, + Deployer deployer) { + super(config, k8sClient, repoProvider, jenkins, gitHandler, fileSystemUtils, deployer) + } + + @Override + protected void deployHelmChart( + String featureName, + String releaseName, + String namespace, + Config.HelmConfigWithValues helmConfig, + String helmValuesTemplatePath, + Config config) { + deployCalls << new DeployCall(featureName: featureName, + releaseName: releaseName, + namespace: namespace, + helmConfig: helmConfig, + valuesPath: helmValuesTemplatePath, + config: config) + } + + @Override + protected CloneCommand gitClone() { + cloneSpy = spy(super.gitClone().setNoCheckout(true)) + } + } + + static class DeployCall { + String featureName + String releaseName + String namespace + Config.HelmConfigWithValues helmConfig + String valuesPath + Config config + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/ExternalSecretsOperatorTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/ExternalSecretsOperatorTest.groovy index c8287099b..5c5db1e19 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/ExternalSecretsOperatorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/ExternalSecretsOperatorTest.groovy @@ -1,190 +1,180 @@ package com.cloudogu.gitops.features +import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType +import static org.assertj.core.api.Assertions.assertThat +import static org.mockito.ArgumentMatchers.any +import static org.mockito.Mockito.verify +import static org.mockito.Mockito.when + import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.utils.AirGappedUtils import com.cloudogu.gitops.utils.CommandExecutorForTest import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.K8sClientForTest + +import java.nio.file.Files +import java.nio.file.Path import groovy.yaml.YamlSlurper + import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.ArgumentCaptor import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import java.nio.file.Files -import java.nio.file.Path - -import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.* -import static org.assertj.core.api.Assertions.assertThat -import static org.mockito.ArgumentMatchers.any -import static org.mockito.Mockito.* - @ExtendWith(MockitoExtension.class) class ExternalSecretsOperatorTest { - Config config = new Config( - application: new Config.ApplicationSchema(namePrefix: "foo-"), - registry: new Config.RegistrySchema(), - features: new Config.FeaturesSchema( - secrets: new Config.SecretsSchema(active: true))) - - CommandExecutorForTest commandExecutor = new CommandExecutorForTest() - K8sClientForTest k8sClient = new K8sClientForTest(config) - FileSystemUtils fileSystemUtils = new FileSystemUtils() - Path temporaryYamlFile - - @Mock - DeploymentStrategy deploymentStrategy - @Mock - AirGappedUtils airGappedUtils - @Mock - GitHandler gitHandler - @Mock - GitProvider gitProvider - - @Test - void "is disabled via active flag"() { - config.features.secrets.active = false - createExternalSecretsOperator().install() - assertThat(commandExecutor.actualCommands).isEmpty() - } - - @Test - void 'helm release is installed'() { - createExternalSecretsOperator().install() - - verify(deploymentStrategy).deployFeature( - 'https://charts.external-secrets.io', - 'external-secrets-operator', - 'external-secrets', - '0.9.16', - 'foo-secrets', - 'external-secrets', - temporaryYamlFile, - RepoType.HELM - ) - - assertThat(parseActualYaml()).doesNotContainKeys('resources') - assertThat(parseActualYaml()).doesNotContainKey('imagePullSecrets') - assertThat(parseActualYaml()).doesNotContainKey('certController') - assertThat(parseActualYaml()).doesNotContainKey('webhook') - - assertThat(parseActualYaml()['installCRDs']).isNull() - } - - @Test - void 'Skips CRDs'() { - config.application.skipCrds = true - - createExternalSecretsOperator().install() - - assertThat(parseActualYaml()['installCRDs']).isEqualTo(false) - } - - @Test - void 'helm release is installed with custom images'() { - config.features.secrets.externalSecrets.helm = new Config.SecretsSchema.ESOSchema.ESOHelmSchema([ - image : 'localhost:5000/external-secrets/external-secrets:v0.6.1', - certControllerImage: 'localhost:5000/external-secrets/external-secrets-certcontroller:v0.6.1', - webhookImage : 'localhost:5000/external-secrets/external-secrets-webhook:v0.6.1' - ]) - createExternalSecretsOperator().install() - - - def valuesYaml = parseActualYaml() - assertThat(valuesYaml['image']['repository']).isEqualTo('localhost:5000/external-secrets/external-secrets') - assertThat(valuesYaml['image']['tag']).isEqualTo('v0.6.1') - - assertThat(valuesYaml['certController']['image']['repository']).isEqualTo('localhost:5000/external-secrets/external-secrets-certcontroller') - assertThat(valuesYaml['certController']['image']['tag']).isEqualTo('v0.6.1') - - assertThat(valuesYaml['webhook']['image']['repository']).isEqualTo('localhost:5000/external-secrets/external-secrets-webhook') - assertThat(valuesYaml['webhook']['image']['tag']).isEqualTo('v0.6.1') - } - - @Test - void 'Sets pod resource limits and requests'() { - config.application.podResources = true - - createExternalSecretsOperator().install() - - assertThat(parseActualYaml()['resources'] as Map).containsKeys('limits', 'requests') - assertThat(parseActualYaml()['webhook']['resources'] as Map).containsKeys('limits', 'requests') - assertThat(parseActualYaml()['certController']['resources'] as Map).containsKeys('limits', 'requests') - } - - @Test - void 'helm release is installed in air-gapped mode'() { - when(gitHandler.getResourcesScm()).thenReturn(gitProvider) - when(gitProvider.repoUrl(any())).thenReturn("http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b") - when(airGappedUtils.mirrorHelmRepoToGit(any(Config.HelmConfig))).thenReturn('a/b') - - config.application.mirrorRepos = true - - Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) - config.application.localHelmChartFolder = rootChartsFolder.toString() - - Path SourceChart = rootChartsFolder.resolve('external-secrets') - Files.createDirectories(SourceChart) - - Map ChartYaml = [version: '1.2.3'] - fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) - - createExternalSecretsOperator().install() - - def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) - verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) - assertThat(helmConfig.value.chart).isEqualTo('external-secrets') - assertThat(helmConfig.value.repoURL).isEqualTo('https://charts.external-secrets.io') - assertThat(helmConfig.value.version).isEqualTo('0.9.16') - verify(deploymentStrategy).deployFeature( - 'http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b', - 'external-secrets-operator', '.', '1.2.3', 'foo-secrets', - 'external-secrets', temporaryYamlFile, RepoType.GIT) - } - - @Test - void 'deploys image pull secrets for proxy registry'() { - config.registry.createImagePullSecrets = true - config.registry.proxyUrl = 'proxy-url' - config.registry.proxyUsername = 'proxy-user' - config.registry.proxyPassword = 'proxy-pw' - config.registry.proxyPassword = 'proxy-pw' - config.features.secrets.externalSecrets.helm = new Config.SecretsSchema.ESOSchema.ESOHelmSchema([ - certControllerImage: 'some:thing', - webhookImage : 'some:thing' - ]) - - createExternalSecretsOperator().install() - - k8sClient.commandExecutorForTest.assertExecuted( - 'kubectl create secret docker-registry proxy-registry -n foo-secrets' + - ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') - assertThat(parseActualYaml()['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) - assertThat(parseActualYaml()['certController']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) - assertThat(parseActualYaml()['webhook']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) - } - - private ExternalSecretsOperator createExternalSecretsOperator() { - new ExternalSecretsOperator( - config, - new FileSystemUtils() { - @Override - Path writeTempFile(Map mergeMap) { - def ret = super.writeTempFile(mergeMap) - temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) - // Path after template invocation - return ret - } - }, deploymentStrategy, k8sClient, airGappedUtils, gitHandler) - } - - private Map parseActualYaml() { - def ys = new YamlSlurper() - return ys.parse(temporaryYamlFile) as Map - } + Config config = new Config(application: new Config.ApplicationSchema(namePrefix: "foo-"), + registry: new Config.RegistrySchema(), + features: new Config.FeaturesSchema(secrets: new Config.SecretsSchema(active: true))) + + CommandExecutorForTest commandExecutor = new CommandExecutorForTest() + K8sClientForTest k8sClient = new K8sClientForTest(config) + FileSystemUtils fileSystemUtils = new FileSystemUtils() + Path temporaryYamlFile + + @Mock + Deployer deployer + @Mock + AirGappedUtils airGappedUtils + @Mock + GitHandler gitHandler + @Mock + GitProvider gitProvider + + @Test + void "is disabled via active flag"() { + config.features.secrets.active = false + createExternalSecretsOperator().install() + assertThat(commandExecutor.actualCommands).isEmpty() + } + + @Test + void 'helm release is installed'() { + createExternalSecretsOperator().install() + + verify(deployer).deployFeature('https://charts.external-secrets.io', + 'external-secrets-operator', + 'external-secrets', + '0.9.16', + 'foo-secrets', + 'external-secrets', + temporaryYamlFile, + RepoType.HELM) + + assertThat(parseActualYaml()).doesNotContainKeys('resources') + assertThat(parseActualYaml()).doesNotContainKey('imagePullSecrets') + assertThat(parseActualYaml()).doesNotContainKey('certController') + assertThat(parseActualYaml()).doesNotContainKey('webhook') + + assertThat(parseActualYaml()['installCRDs']).isNull() + } + + @Test + void 'Skips CRDs'() { + config.application.skipCrds = true + + createExternalSecretsOperator().install() + + assertThat(parseActualYaml()['installCRDs']).isEqualTo(false) + } + + @Test + void 'helm release is installed with custom images'() { + config.features.secrets.externalSecrets.helm = new Config.SecretsSchema.ESOSchema.ESOHelmSchema([image : 'localhost:5000/external-secrets/external-secrets:v0.6.1', + certControllerImage: 'localhost:5000/external-secrets/external-secrets-certcontroller:v0.6.1', + webhookImage : 'localhost:5000/external-secrets/external-secrets-webhook:v0.6.1']) + createExternalSecretsOperator().install() + + def valuesYaml = parseActualYaml() + assertThat(valuesYaml['image']['repository']).isEqualTo('localhost:5000/external-secrets/external-secrets') + assertThat(valuesYaml['image']['tag']).isEqualTo('v0.6.1') + + assertThat(valuesYaml['certController']['image']['repository']).isEqualTo('localhost:5000/external-secrets/external-secrets-certcontroller') + assertThat(valuesYaml['certController']['image']['tag']).isEqualTo('v0.6.1') + + assertThat(valuesYaml['webhook']['image']['repository']).isEqualTo('localhost:5000/external-secrets/external-secrets-webhook') + assertThat(valuesYaml['webhook']['image']['tag']).isEqualTo('v0.6.1') + } + + @Test + void 'Sets pod resource limits and requests'() { + config.application.podResources = true + + createExternalSecretsOperator().install() + + assertThat(parseActualYaml()['resources'] as Map).containsKeys('limits', 'requests') + assertThat(parseActualYaml()['webhook']['resources'] as Map).containsKeys('limits', 'requests') + assertThat(parseActualYaml()['certController']['resources'] as Map).containsKeys('limits', 'requests') + } + + @Test + void 'helm release is installed in air-gapped mode'() { + when(gitHandler.getResourcesScm()).thenReturn(gitProvider) + when(gitProvider.repoUrl(any())).thenReturn("http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b") + when(airGappedUtils.mirrorHelmRepoToGit(any(Config.HelmConfig))).thenReturn('a/b') + + config.application.mirrorRepos = true + + Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) + config.application.localHelmChartFolder = rootChartsFolder.toString() + + Path SourceChart = rootChartsFolder.resolve('external-secrets') + Files.createDirectories(SourceChart) + + Map ChartYaml = [version: '1.2.3'] + fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) + + createExternalSecretsOperator().install() + + def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) + verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) + assertThat(helmConfig.value.chart).isEqualTo('external-secrets') + assertThat(helmConfig.value.repoURL).isEqualTo('https://charts.external-secrets.io') + assertThat(helmConfig.value.version).isEqualTo('0.9.16') + verify(deployer).deployFeature('http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b', + 'external-secrets-operator', '.', '1.2.3', 'foo-secrets', + 'external-secrets', temporaryYamlFile, RepoType.GIT) + } + + @Test + void 'deploys image pull secrets for proxy registry'() { + config.registry.createImagePullSecrets = true + config.registry.proxyUrl = 'proxy-url' + config.registry.proxyUsername = 'proxy-user' + config.registry.proxyPassword = 'proxy-pw' + config.registry.proxyPassword = 'proxy-pw' + config.features.secrets.externalSecrets.helm = new Config.SecretsSchema.ESOSchema.ESOHelmSchema([certControllerImage: 'some:thing', + webhookImage : 'some:thing']) + + createExternalSecretsOperator().install() + + k8sClient.commandExecutorForTest.assertExecuted('kubectl create secret docker-registry proxy-registry -n foo-secrets' + + ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') + assertThat(parseActualYaml()['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) + assertThat(parseActualYaml()['certController']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) + assertThat(parseActualYaml()['webhook']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) + } + + private ExternalSecretsOperator createExternalSecretsOperator() { + new ExternalSecretsOperator(config, + new FileSystemUtils() { + @Override + Path writeTempFile(Map mergeMap) { + def ret = super.writeTempFile(mergeMap) + temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) + // Path after template invocation + return ret + } + }, deployer, k8sClient, airGappedUtils, gitHandler) + } + + private Map parseActualYaml() { + def ys = new YamlSlurper() + return ys.parse(temporaryYamlFile) as Map + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/IngressTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/IngressTest.groovy index 1837c4003..d15bdfae5 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/IngressTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/IngressTest.groovy @@ -1,210 +1,200 @@ package com.cloudogu.gitops.features +import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType +import static org.assertj.core.api.Assertions.assertThat +import static org.mockito.ArgumentMatchers.any +import static org.mockito.Mockito.verify +import static org.mockito.Mockito.when + import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.utils.AirGappedUtils import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.K8sClientForTest + +import java.nio.file.Files +import java.nio.file.Path import groovy.yaml.YamlSlurper + import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.ArgumentCaptor import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import java.nio.file.Files -import java.nio.file.Path - -import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.* -import static org.assertj.core.api.Assertions.assertThat -import static org.mockito.ArgumentMatchers.any -import static org.mockito.Mockito.* - @ExtendWith(MockitoExtension.class) class IngressTest { - // setting default config values with ingress active - Config config = new Config( - application: new Config.ApplicationSchema( - namePrefix: 'foo-'), - features: new Config.FeaturesSchema( - ingress: new Config.IngressSchema( - active: true) - )) - Path temporaryYamlFile - FileSystemUtils fileSystemUtils = new FileSystemUtils() - - K8sClientForTest k8sClient = new K8sClientForTest(config) + // setting default config values with ingress active + Config config = new Config(application: new Config.ApplicationSchema(namePrefix: 'foo-'), + features: new Config.FeaturesSchema(ingress: new Config.IngressSchema(active: true))) + Path temporaryYamlFile + FileSystemUtils fileSystemUtils = new FileSystemUtils() + + K8sClientForTest k8sClient = new K8sClientForTest(config) + + @Mock + Deployer deployer + @Mock + AirGappedUtils airGappedUtils + @Mock + GitHandler gitHandler + @Mock + GitProvider gitProvider + + @Test + void 'Helm release is installed'() { + createIngress().install() + + /* Assert one default value */ + def actual = parseActualYaml() + assertThat(actual['deployment']['replicaCount']).isEqualTo(2) + + verify(deployer).deployFeature(config.features.ingress.helm.repoURL, 'traefik', + config.features.ingress.helm.chart, config.features.ingress.helm.version, 'foo-' + config.features.ingress.ingressNamespace, + 'traefik', temporaryYamlFile, RepoType.HELM) + assertThat(parseActualYaml()['deployment']['metrics']).isNull() + assertThat(parseActualYaml()['deployment']['networkPolicy']).isNull() + assertThat(parseActualYaml()).doesNotContainKey('imagePullSecrets') + + } + + @Test + void 'Sets pod resource limits and requests'() { + config.application.podResources = true + + createIngress().install() + + assertThat(parseActualYaml()['deployment']['resources'] as Map).containsKeys('limits', 'requests') + } + + @Test + void 'When Ingress is not enabled, ingress-helm-values yaml has no content'() { + config.features.ingress.active = false + + createIngress().install() + + assertThat(temporaryYamlFile).isNull() + } + + @Test + void 'additional helm values merged with default values'() { + config.features.ingress.helm.values = [controller: [replicaCount: 42, + span : '7,5',]] + + createIngress().install() + def actual = parseActualYaml() + + assertThat(actual['controller']['replicaCount']).isEqualTo(42) + assertThat(actual['controller']['span']).isEqualTo('7,5') + } + + @Test + void 'helm release is installed in air-gapped mode'() { + when(gitHandler.getResourcesScm()).thenReturn(gitProvider) + when(gitProvider.repoUrl(any())).thenReturn("http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b") + when(airGappedUtils.mirrorHelmRepoToGit(any(Config.HelmConfig))).thenReturn('a/b') - @Mock - DeploymentStrategy deploymentStrategy - @Mock - AirGappedUtils airGappedUtils - @Mock - GitHandler gitHandler - @Mock - GitProvider gitProvider + config.application.mirrorRepos = true - @Test - void 'Helm release is installed'() { - createIngress().install() + Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) + config.application.localHelmChartFolder = rootChartsFolder.toString() - /* Assert one default value */ - def actual = parseActualYaml() - assertThat(actual['deployment']['replicaCount']).isEqualTo(2) + Path SourceChart = rootChartsFolder.resolve('traefik') + Files.createDirectories(SourceChart) - verify(deploymentStrategy).deployFeature(config.features.ingress.helm.repoURL, 'traefik', - config.features.ingress.helm.chart, config.features.ingress.helm.version, 'foo-' + config.features.ingress.ingressNamespace, - 'traefik', temporaryYamlFile, RepoType.HELM) - assertThat(parseActualYaml()['deployment']['metrics']).isNull() - assertThat(parseActualYaml()['deployment']['networkPolicy']).isNull() - assertThat(parseActualYaml()).doesNotContainKey('imagePullSecrets') + Map ChartYaml = [version: '1.2.3'] + fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) - } + createIngress().install() - @Test - void 'Sets pod resource limits and requests'() { - config.application.podResources = true + def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) + verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) + assertThat(helmConfig.value.chart).isEqualTo('traefik') - createIngress().install() + assertThat(helmConfig.value.repoURL).isEqualTo('https://traefik.github.io/charts') + assertThat(helmConfig.value.version).isEqualTo('39.0.0') + verify(deployer).deployFeature('http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b', + 'traefik', '.', '1.2.3', 'foo-' + config.features.ingress.ingressNamespace, + 'traefik', temporaryYamlFile, RepoType.GIT) + } - assertThat(parseActualYaml()['deployment']['resources'] as Map).containsKeys('limits', 'requests') - } + @Test + void 'When Monitoring is enabled, metrics are enabled'() { + config.features.monitoring.active = true + config.application.namePrefix = "heliosphere" - @Test - void 'When Ingress is not enabled, ingress-helm-values yaml has no content'() { - config.features.ingress.active = false + createIngress().install() + + def actual = parseActualYaml() + + assertThat(actual['metrics']['enabled']).isEqualTo(true) + assertThat(actual['metrics']['prometheus']['serviceMonitor']['enabled']).isEqualTo(true) + assertThat(actual['metrics']['prometheus']['serviceMonitor']['namespace']).isEqualTo("heliospheremonitoring") + } - createIngress().install() + @Test + void 'Activates network policies'() { + config.application.netpols = true - assertThat(temporaryYamlFile).isNull() - } + createIngress().install() - @Test - void 'additional helm values merged with default values'() { - config.features.ingress.helm.values = [ - controller: [ - replicaCount: 42, - span : '7,5', - ] - ] + def actual = parseActualYaml() - createIngress().install() - def actual = parseActualYaml() + assertThat(actual['deployment']['networkPolicy']['enabled']).isEqualTo(true) + } - assertThat(actual['controller']['replicaCount']).isEqualTo(42) - assertThat(actual['controller']['span']).isEqualTo('7,5') - } - - - @Test - void 'helm release is installed in air-gapped mode'() { - when(gitHandler.getResourcesScm()).thenReturn(gitProvider) - when(gitProvider.repoUrl(any())).thenReturn("http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b") - when(airGappedUtils.mirrorHelmRepoToGit(any(Config.HelmConfig))).thenReturn('a/b') - - config.application.mirrorRepos = true - - Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) - config.application.localHelmChartFolder = rootChartsFolder.toString() - - Path SourceChart = rootChartsFolder.resolve('traefik') - Files.createDirectories(SourceChart) - - Map ChartYaml = [version: '1.2.3'] - fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) - - createIngress().install() - - def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) - verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) - assertThat(helmConfig.value.chart).isEqualTo('traefik') - - assertThat(helmConfig.value.repoURL).isEqualTo('https://traefik.github.io/charts') - assertThat(helmConfig.value.version).isEqualTo('39.0.0') - verify(deploymentStrategy).deployFeature( - 'http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b', - 'traefik', '.', '1.2.3', 'foo-' + config.features.ingress.ingressNamespace, - 'traefik', temporaryYamlFile, RepoType.GIT) - } - - @Test - void 'When Monitoring is enabled, metrics are enabled'() { - config.features.monitoring.active = true - config.application.namePrefix = "heliosphere" - - createIngress().install() - - def actual = parseActualYaml() - - assertThat(actual['metrics']['enabled']).isEqualTo(true) - assertThat(actual['metrics']['prometheus']['serviceMonitor']['enabled']).isEqualTo(true) - assertThat(actual['metrics']['prometheus']['serviceMonitor']['namespace']).isEqualTo("heliospheremonitoring") - } - - @Test - void 'Activates network policies'() { - config.application.netpols = true - - createIngress().install() + @Test + void 'deploys image pull secrets for proxy registry'() { + config.registry.createImagePullSecrets = true + config.registry.proxyUrl = 'proxy-url' + config.registry.proxyUsername = 'proxy-user' + config.registry.proxyPassword = 'proxy-pw' - def actual = parseActualYaml() - - assertThat(actual['deployment']['networkPolicy']['enabled']).isEqualTo(true) - } + createIngress().install() - @Test - void 'deploys image pull secrets for proxy registry'() { - config.registry.createImagePullSecrets = true - config.registry.proxyUrl = 'proxy-url' - config.registry.proxyUsername = 'proxy-user' - config.registry.proxyPassword = 'proxy-pw' + k8sClient.commandExecutorForTest.assertExecuted('kubectl create secret docker-registry proxy-registry -n foo-ingress' + + ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') - createIngress().install() + assertThat(parseActualYaml()['deployment']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) + } - k8sClient.commandExecutorForTest.assertExecuted( - 'kubectl create secret docker-registry proxy-registry -n foo-ingress' + - ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') + @Test + void 'Allows overriding the image'() { + config.features.ingress.helm.image = 'localhost/abc:v42' - assertThat(parseActualYaml()['deployment']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) - } - - @Test - void 'Allows overriding the image'() { - config.features.ingress.helm.image = 'localhost/abc:v42' - - createIngress().install() + createIngress().install() - def yaml = parseActualYaml() - assertThat(yaml['image']['repository']).isEqualTo('localhost/abc') - assertThat(yaml['image']['tag']).isEqualTo('v42') - assertThat(yaml['image']['digest']).isNull() - } - - @Test - void 'get namespace from feature'() { - assertThat(createIngress().getActiveNamespaceFromFeature()).isEqualTo('foo-' + config.features.ingress.ingressNamespace) - config.features.ingress.active = false - assertThat(createIngress().getActiveNamespaceFromFeature()).isEqualTo(null) - } + def yaml = parseActualYaml() + assertThat(yaml['image']['repository']).isEqualTo('localhost/abc') + assertThat(yaml['image']['tag']).isEqualTo('v42') + assertThat(yaml['image']['digest']).isNull() + } - private Ingress createIngress() { - // We use the real FileSystemUtils and not a mock to make sure file editing works as expected - new Ingress(config, new FileSystemUtils() { - @Override - Path writeTempFile(Map mergeMap) { - def ret = super.writeTempFile(mergeMap) - temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) - // Path after template invocation - return ret - } - }, deploymentStrategy, k8sClient, airGappedUtils, gitHandler) - } - - private Map parseActualYaml() { - def ys = new YamlSlurper() - return ys.parse(temporaryYamlFile) as Map - } + @Test + void 'get namespace from feature'() { + assertThat(createIngress().getActiveNamespaceFromFeature()).isEqualTo('foo-' + config.features.ingress.ingressNamespace) + config.features.ingress.active = false + assertThat(createIngress().getActiveNamespaceFromFeature()).isEqualTo(null) + } + + private Ingress createIngress() { + // We use the real FileSystemUtils and not a mock to make sure file editing works as expected + new Ingress(config, new FileSystemUtils() { + @Override + Path writeTempFile(Map mergeMap) { + def ret = super.writeTempFile(mergeMap) + temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) + // Path after template invocation + return ret + } + }, deployer, k8sClient, airGappedUtils, gitHandler) + } + + private Map parseActualYaml() { + def ys = new YamlSlurper() + return ys.parse(temporaryYamlFile) as Map + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/JenkinsTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/JenkinsTest.groovy index 3ac8573bb..b028a98ff 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/JenkinsTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/JenkinsTest.groovy @@ -1,372 +1,361 @@ package com.cloudogu.gitops.features +import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType +import static org.assertj.core.api.Assertions.assertThat +import static org.mockito.ArgumentMatchers.* +import static org.mockito.Mockito.* + import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy -import com.cloudogu.gitops.features.deployment.HelmStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.jenkins.GlobalPropertyManager import com.cloudogu.gitops.jenkins.JobManager import com.cloudogu.gitops.jenkins.PrometheusConfigurator import com.cloudogu.gitops.jenkins.UserManager +import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.CommandExecutorForTest import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.NetworkingUtils import com.cloudogu.gitops.utils.git.GitHandlerForTests import com.cloudogu.gitops.utils.git.ScmManagerMock + +import java.nio.file.Path import groovy.yaml.YamlSlurper + import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.ArgumentCaptor import org.mockito.Mock -import java.nio.file.Path - -import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.* -import static org.assertj.core.api.Assertions.assertThat -import static org.mockito.ArgumentMatchers.* -import static org.mockito.Mockito.* - class JenkinsTest { - Config config = new Config( - scm: [ - scmManager: [ - urlForJenkins: "testUrlJenkins" - ]], - jenkins: new Config.JenkinsSchema(active: true)) - - String expectedNodeName = 'something' - - CommandExecutorForTest commandExecutor = new CommandExecutorForTest() - GlobalPropertyManager globalPropertyManager = mock(GlobalPropertyManager) - JobManager jobManger = mock(JobManager) - UserManager userManager = mock(UserManager) - PrometheusConfigurator prometheusConfigurator = mock(PrometheusConfigurator) - HelmStrategy deploymentStrategy = mock(HelmStrategy) - Path temporaryYamlFile - NetworkingUtils networkingUtils = mock(NetworkingUtils.class) - K8sClient k8sClient = mock(K8sClient) - - @Mock - ScmManagerMock scmManagerMock = new ScmManagerMock() - GitHandler gitHandler = new GitHandlerForTests(config, scmManagerMock) - - @BeforeEach - void setup() { - // waitForInternalNodeIp -> waitForNode() - when(k8sClient.waitForNode()).thenReturn("node/${expectedNodeName}".toString()) - when(k8sClient.run(anyString(), anyString(), anyString(), anyMap(), any())).thenReturn('') - } - - @Test - void 'Installs Jenkins'() { - def jenkins = createJenkins() - - config.jenkins.url = 'http://jenkins' - config.jenkins.helm.chart = 'jen-chart' - config.jenkins.helm.repoURL = 'https://jen-repo' - config.jenkins.helm.version = '4.8.1' - config.jenkins.username = 'jenusr' - config.jenkins.password = 'jenpw' - config.jenkins.internalBashImage = 'bash:42' - config.jenkins.internalDockerClientVersion = '23' - - when(k8sClient.run(anyString(), anyString(), anyString(), anyMap(), any(String[].class))).thenReturn(''' + Config config = new Config(scm: [scmManager: [urlForJenkins: "testUrlJenkins"]], + jenkins: new Config.JenkinsSchema(active: true)) + + String expectedNodeName = 'something' + + CommandExecutorForTest commandExecutor = new CommandExecutorForTest() + GlobalPropertyManager globalPropertyManager = mock(GlobalPropertyManager) + JobManager jobManger = mock(JobManager) + UserManager userManager = mock(UserManager) + PrometheusConfigurator prometheusConfigurator = mock(PrometheusConfigurator) + Deployer deployer = mock(Deployer) + Path temporaryYamlFile + NetworkingUtils networkingUtils = mock(NetworkingUtils.class) + K8sClient k8sClient = mock(K8sClient) + + @Mock + ScmManagerMock scmManagerMock = new ScmManagerMock() + GitHandler gitHandler = new GitHandlerForTests(config, scmManagerMock) + + @BeforeEach + void setup() { + // waitForInternalNodeIp -> waitForNode() + when(k8sClient.waitForNode()).thenReturn("node/${expectedNodeName}".toString()) + when(k8sClient.run(anyString(), anyString(), anyString(), anyMap(), any())).thenReturn('') + } + + @Test + void 'Installs Jenkins'() { + def jenkins = createJenkins() + + config.jenkins.url = 'http://jenkins' + config.jenkins.helm.chart = 'jen-chart' + config.jenkins.helm.repoURL = 'https://jen-repo' + config.jenkins.helm.version = '4.8.1' + config.jenkins.username = 'jenusr' + config.jenkins.password = 'jenpw' + config.jenkins.internalBashImage = 'bash:42' + config.jenkins.internalDockerClientVersion = '23' + + when(k8sClient.run(anyString(), anyString(), anyString(), anyMap(), any(String[].class))).thenReturn(''' root:x:0: daemon:x:1: docker:x:42:me me:x:1000:''') - jenkins.install() + jenkins.install() - verify(deploymentStrategy).deployFeature('https://jen-repo', 'jenkins', - 'jen-chart', '4.8.1', 'jenkins', - 'jenkins', temporaryYamlFile, RepoType.HELM) - verify(k8sClient).label('node', expectedNodeName, new Tuple2('node', 'jenkins')) - verify(k8sClient).labelRemove('node', '--all', '', 'node') - verify(k8sClient).createSecret('generic', 'jenkins-credentials', 'jenkins', - new Tuple2('jenkins-admin-user', 'jenusr'), - new Tuple2('jenkins-admin-password', 'jenpw')) + verify(deployer).deployFeature('https://jen-repo', 'jenkins', + 'jen-chart', '4.8.1', 'jenkins', + 'jenkins', temporaryYamlFile, RepoType.HELM) + verify(k8sClient).label('node', expectedNodeName, new Tuple2('node', 'jenkins')) + verify(k8sClient).labelRemove('node', '--all', '', 'node') + verify(k8sClient).createSecret('generic', 'jenkins-credentials', 'jenkins', + new Tuple2('jenkins-admin-user', 'jenusr'), + new Tuple2('jenkins-admin-password', 'jenpw')) - assertThat(parseActualYaml()['dockerClientVersion'].toString()).isEqualTo('23') + assertThat(parseActualYaml()['dockerClientVersion'].toString()).isEqualTo('23') - assertThat(parseActualYaml()['controller']['image']['tag']).isEqualTo('4.8.1') + assertThat(parseActualYaml()['controller']['image']['tag']).isEqualTo('4.8.1') - assertThat(parseActualYaml()['controller']['jenkinsUrl']).isEqualTo('http://jenkins') - assertThat(parseActualYaml()['controller']['serviceType']).isEqualTo('NodePort') + assertThat(parseActualYaml()['controller']['jenkinsUrl']).isEqualTo('http://jenkins') + assertThat(parseActualYaml()['controller']['serviceType']).isEqualTo('NodePort') - assertThat(parseActualYaml()['controller']['ingress']).isNull() + assertThat(parseActualYaml()['controller']['ingress']).isNull() - List customInitContainers = parseActualYaml()['controller']['customInitContainers'] as List - assertThat(customInitContainers[0]['image']).isEqualTo('bash:42') + List customInitContainers = parseActualYaml()['controller']['customInitContainers'] as List + assertThat(customInitContainers[0]['image']).isEqualTo('bash:42') - assertThat(parseActualYaml()['agent']['runAsUser']).isEqualTo(1000) - assertThat(parseActualYaml()['agent']['runAsGroup']).isEqualTo(42) + assertThat(parseActualYaml()['agent']['runAsUser']).isEqualTo(1000) + assertThat(parseActualYaml()['agent']['runAsGroup']).isEqualTo(42) - ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor overridesCaptor = ArgumentCaptor.forClass(Map.class); - verify(k8sClient).run(nameCaptor.capture(), anyString(), eq(jenkins.namespace), overridesCaptor.capture(), any(String[].class)) - assertThat(nameCaptor.value).startsWith('tmp-docker-gid-grepper-') - List containers = overridesCaptor.value['spec']['containers'] as List - assertThat(containers[0]['image'].toString()).isEqualTo('bash:42') - } + ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor overridesCaptor = ArgumentCaptor.forClass(Map.class); + verify(k8sClient).run(nameCaptor.capture(), anyString(), eq(jenkins.namespace), overridesCaptor.capture(), any(String[].class)) + assertThat(nameCaptor.value).startsWith('tmp-docker-gid-grepper-') + List containers = overridesCaptor.value['spec']['containers'] as List + assertThat(containers[0]['image'].toString()).isEqualTo('bash:42') + } - @Test - void 'Installs Jenkins without dockerGid'() { - when(k8sClient.run(anyString(), anyString(), anyString(), anyMap(), any())).thenReturn(''' + @Test + void 'Installs Jenkins without dockerGid'() { + when(k8sClient.run(anyString(), anyString(), anyString(), anyMap(), any())).thenReturn(''' root:x:0: daemon:x:1: me:x:1000:''') - createJenkins().install() - - assertThat(parseActualYaml()['agent']['runAsUser']).isEqualTo('0') - assertThat(parseActualYaml()['agent']['runAsGroup']).isEqualTo('133') - } - - @Test - void 'Installs only if internal'() { - config.jenkins.internal = false - - createJenkins().install() - verify(deploymentStrategy, never()).deployFeature(anyString(), anyString(), anyString(), anyString(), - anyString(), anyString(), any(Path)) - - assertThat(temporaryYamlFile).isNull() - } - - @Test - void 'Additional helm values are merged with default values'() { - config.jenkins.helm.values = [ - controller: [ - nodePort: 42 - ] - ] - - createJenkins().install() - - assertThat(parseActualYaml()['controller']['nodePort']).isEqualTo(42) - } - - @Test - void 'Enables ingress when baseUrl is set'() { - config.jenkins.ingress = 'jenkins.localhost' - config.application.baseUrl = 'someBaseUrl' - - createJenkins().install() - - assertThat(parseActualYaml()['controller']['ingress']['enabled']).isEqualTo(true) - assertThat(parseActualYaml()['controller']['ingress']['hostName']).isEqualTo('jenkins.localhost') - } - - @Test - void 'Maps config properly'() { - config.application.trace = true - config.features.argocd.active = true - config.scm.scmManager.url = 'http://scmm.scm-manager.svc.cluster.local/scm' - config.scm.scmManager.username = 'scmm-usr' - config.scm.scmManager.password = 'scmm-pw' - config.application.namePrefix = 'my-prefix-' - config.application.namePrefixForEnvVars = 'MY_PREFIX_' - config.registry.url = 'reg-url' - config.registry.path = 'reg-path' - config.registry.username = 'reg-usr' - config.registry.password = 'reg-pw' - config.registry.proxyUrl = 'reg-proxy-url' - config.registry.proxyPath = 'reg-proxy-path' - config.registry.proxyUsername = 'reg-proxy-usr' - config.registry.proxyPassword = 'reg-proxy-pw' - config.jenkins.internal = false - config.jenkins.helm.version = '4.8.1' - config.jenkins.username = 'jenusr' - config.jenkins.password = 'jenpw' - config.jenkins.url = 'http://jenkins' - config.jenkins.metricsUsername = 'metrics-usr' - config.jenkins.metricsPassword = 'metrics-pw' - config.jenkins.skipPlugins = true - config.jenkins.skipRestart = true - - createJenkins().install() - - def env = getEnvAsMap() - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "${System.getProperty('user.dir')}/scripts/jenkins/init-jenkins.sh" as String) - - assertThat(env['TRACE']).isEqualTo('true') - assertThat(env['INTERNAL_JENKINS']).isEqualTo('false') - assertThat(env['JENKINS_HELM_CHART_VERSION']).isEqualTo('4.8.1') - assertThat(env['JENKINS_URL']).isEqualTo('http://jenkins') - assertThat(env['JENKINS_USERNAME']).isEqualTo('jenusr') - assertThat(env['JENKINS_PASSWORD']).isEqualTo('jenpw') - assertThat(env['JENKINS_USERNAME']).isEqualTo('jenusr') - assertThat(env['NAME_PREFIX']).isEqualTo('my-prefix-') - assertThat(env['INSECURE']).isEqualTo('false') - - assertThat(env['SCM_URL']).isEqualTo('http://scmm.scm-manager.svc.cluster.local/scm') - assertThat(env['SCM_PASSWORD']).isEqualTo(scmManagerMock.credentials.password) - assertThat(env['INSTALL_ARGOCD']).isEqualTo('true') - - assertThat(env['SKIP_PLUGINS']).isEqualTo('true') - assertThat(env['SKIP_RESTART']).isEqualTo('true') - - verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_SCM_URL', 'http://scmm.scm-manager.svc.cluster.local/scm') - verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_K8S_VERSION', Config.K8S_VERSION) - - verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_REGISTRY_URL', 'reg-url') - verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_REGISTRY_PATH', 'reg-path') - verify(globalPropertyManager, never()).setGlobalProperty(eq('MY_PREFIX_REGISTRY_PROXY_URL'), anyString()) - verify(globalPropertyManager, never()).setGlobalProperty(eq('MY_PREFIX_REGISTRY_PROXY_PATH'), anyString()) - verify(globalPropertyManager, never()).setGlobalProperty(eq('MAVEN_CENTRAL_MIRROR'), anyString()) - - verify(userManager).createUser('metrics-usr', 'metrics-pw') - verify(userManager).grantPermission('metrics-usr', UserManager.Permissions.METRICS_VIEW) - } - - @Test - void 'Does not configure prometheus when external Jenkins'() { - config.features.monitoring.active = true - config.jenkins.internal = false - - createJenkins().install() - - verify(prometheusConfigurator, never()).enableAuthentication() - } - - @Test - void 'Does not configure prometheus when monitoring off'() { - config.features.monitoring.active = false - config.jenkins.internal = true - - createJenkins().install() - - verify(prometheusConfigurator, never()).enableAuthentication() - } - - @Test - void 'Configures prometheus'() { - config.features.monitoring.active = true - config.jenkins.internal = true - - createJenkins().install() - - verify(prometheusConfigurator).enableAuthentication() - } - - @Test - void "URL: Use k8s service name if running as k8s pod"() { - config.jenkins.internal = true - config.application.runningInsideK8s = true - - createJenkins().install() - assertThat(config.jenkins.url).isEqualTo("http://jenkins.jenkins.svc.cluster.local:80") - } - - @Test - void "URL: Use local ip and nodePort when outside of k8s"() { - config.jenkins.internal = true - config.application.runningInsideK8s = false - - when(networkingUtils.findClusterBindAddress()).thenReturn('192.168.16.2') - when(k8sClient.waitForNodePort(anyString(), anyString())).thenReturn('42') - - createJenkins().install() - assertThat(config.jenkins.url).endsWith('192.168.16.2:42') - } - - @Test - void 'Handles two registries'() { - config.registry.twoRegistries = true - config.application.namePrefix = 'my-prefix-' - config.application.namePrefixForEnvVars = 'MY_PREFIX_' - - config.registry.url = 'reg-url' - config.registry.path = 'reg-path' - config.registry.username = 'reg-usr' - config.registry.password = 'reg-pw' - config.registry.proxyUrl = 'reg-proxy-url' - config.registry.proxyPath = 'reg-proxy-path' - config.registry.proxyUsername = 'reg-proxy-usr' - config.registry.proxyPassword = 'reg-proxy-pw' - - createJenkins().install() - - verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_REGISTRY_PROXY_URL', 'reg-proxy-url') - verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_REGISTRY_PROXY_PATH', 'reg-proxy-path') - - verify(globalPropertyManager).setGlobalProperty(eq('MY_PREFIX_REGISTRY_URL'), anyString()) - verify(globalPropertyManager).setGlobalProperty(eq('MY_PREFIX_REGISTRY_PATH'), anyString()) - - } - - @Test - void 'Does not create create job credentials when argo cd is deactivated'() { - config.application.namePrefixForEnvVars = 'MY_PREFIX_' - when(userManager.isUsingCasSecurityRealm()).thenReturn(true) - - createJenkins().install() - - verify(userManager, never()).createUser(anyString(), anyString()) - } - - @Test - void 'Global property is set for additional envs'() { - - config.jenkins.additionalEnvs = [ - ADDITIONAL_DOCKER_RUN_ARGS: '-u0:0' - ] - - createJenkins().install() - verify(globalPropertyManager).setGlobalProperty(eq('ADDITIONAL_DOCKER_RUN_ARGS'), eq('-u0:0')) - } - - @Test - void 'Does not create create user if CAS security realm is used'() { - config.features.argocd.active = false - - createJenkins().install() - verify(jobManger, never()).createCredential(anyString(), anyString(), anyString(), anyString(), anyString()) - verify(jobManger, never()).startJob(anyString()) - } - - @Test - void 'Properly handles null values'() { - config.application.baseUrl = null - createJenkins().install() - - def env = getEnvAsMap() - assertThat(env['BASE_URL']).isNotEqualTo('null') - } - - @Test - void 'Sets maven mirror '() { - config.registry.url = 'some value' - config.jenkins.mavenCentralMirror = 'http://test' - config.application.namePrefixForEnvVars = 'MY_PREFIX_' - - createJenkins().install() - - verify(globalPropertyManager).setGlobalProperty(eq('MY_PREFIX_MAVEN_CENTRAL_MIRROR'), eq("http://test")) - } - - protected Map getEnvAsMap() { - commandExecutor.environment.collectEntries { it.split('=') } - } - - private Jenkins createJenkins() { - when(networkingUtils.createUrl(anyString(), anyString(), anyString())).thenCallRealMethod() - when(networkingUtils.createUrl(anyString(), anyString())).thenCallRealMethod() - new Jenkins(config, commandExecutor, new FileSystemUtils() { - @Override - Path writeTempFile(Map mergeMap) { - def ret = super.writeTempFile(mergeMap) - temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) - // Path after template invocation - return ret - } - }, globalPropertyManager, jobManger, userManager, prometheusConfigurator, deploymentStrategy, k8sClient, networkingUtils, gitHandler) - } - - private Map parseActualYaml() { - def ys = new YamlSlurper() - return ys.parse(temporaryYamlFile) as Map - } -} + createJenkins().install() + + assertThat(parseActualYaml()['agent']['runAsUser']).isEqualTo('0') + assertThat(parseActualYaml()['agent']['runAsGroup']).isEqualTo('133') + } + + @Test + void 'Installs only if internal'() { + config.jenkins.internal = false + + createJenkins().install() + verify(deployer, never()).deployFeature(anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), any(Path), any(RepoType)) + + assertThat(temporaryYamlFile).isNull() + } + + @Test + void 'Additional helm values are merged with default values'() { + config.jenkins.helm.values = [controller: [nodePort: 42]] + + createJenkins().install() + + assertThat(parseActualYaml()['controller']['nodePort']).isEqualTo(42) + } + + @Test + void 'Enables ingress when baseUrl is set'() { + config.jenkins.ingress = 'jenkins.localhost' + config.application.baseUrl = 'someBaseUrl' + + createJenkins().install() + + assertThat(parseActualYaml()['controller']['ingress']['enabled']).isEqualTo(true) + assertThat(parseActualYaml()['controller']['ingress']['hostName']).isEqualTo('jenkins.localhost') + } + + @Test + void 'Maps config properly'() { + config.application.trace = true + config.features.argocd.active = true + config.scm.scmManager.url = 'http://scmm.scm-manager.svc.cluster.local/scm' + config.scm.scmManager.username = 'scmm-usr' + config.scm.scmManager.password = 'scmm-pw' + config.application.namePrefix = 'my-prefix-' + config.application.namePrefixForEnvVars = 'MY_PREFIX_' + config.registry.url = 'reg-url' + config.registry.path = 'reg-path' + config.registry.username = 'reg-usr' + config.registry.password = 'reg-pw' + config.registry.proxyUrl = 'reg-proxy-url' + config.registry.proxyPath = 'reg-proxy-path' + config.registry.proxyUsername = 'reg-proxy-usr' + config.registry.proxyPassword = 'reg-proxy-pw' + config.jenkins.internal = false + config.jenkins.helm.version = '4.8.1' + config.jenkins.username = 'jenusr' + config.jenkins.password = 'jenpw' + config.jenkins.url = 'http://jenkins' + config.jenkins.metricsUsername = 'metrics-usr' + config.jenkins.metricsPassword = 'metrics-pw' + config.jenkins.skipPlugins = true + config.jenkins.skipRestart = true + + createJenkins().install() + + def env = getEnvAsMap() + assertThat(commandExecutor.actualCommands[0]).isEqualTo("${System.getProperty('user.dir')}/scripts/jenkins/init-jenkins.sh" as String) + + assertThat(env['TRACE']).isEqualTo('true') + assertThat(env['INTERNAL_JENKINS']).isEqualTo('false') + assertThat(env['JENKINS_HELM_CHART_VERSION']).isEqualTo('4.8.1') + assertThat(env['JENKINS_URL']).isEqualTo('http://jenkins') + assertThat(env['JENKINS_USERNAME']).isEqualTo('jenusr') + assertThat(env['JENKINS_PASSWORD']).isEqualTo('jenpw') + assertThat(env['JENKINS_USERNAME']).isEqualTo('jenusr') + assertThat(env['NAME_PREFIX']).isEqualTo('my-prefix-') + assertThat(env['INSECURE']).isEqualTo('false') + + assertThat(env['SCM_URL']).isEqualTo('http://scmm.scm-manager.svc.cluster.local/scm') + assertThat(env['SCM_PASSWORD']).isEqualTo(scmManagerMock.credentials.password) + assertThat(env['INSTALL_ARGOCD']).isEqualTo('true') + + assertThat(env['SKIP_PLUGINS']).isEqualTo('true') + assertThat(env['SKIP_RESTART']).isEqualTo('true') + + verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_SCM_URL', 'http://scmm.scm-manager.svc.cluster.local/scm') + verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_K8S_VERSION', Config.K8S_VERSION) + + verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_REGISTRY_URL', 'reg-url') + verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_REGISTRY_PATH', 'reg-path') + verify(globalPropertyManager, never()).setGlobalProperty(eq('MY_PREFIX_REGISTRY_PROXY_URL'), anyString()) + verify(globalPropertyManager, never()).setGlobalProperty(eq('MY_PREFIX_REGISTRY_PROXY_PATH'), anyString()) + verify(globalPropertyManager, never()).setGlobalProperty(eq('MAVEN_CENTRAL_MIRROR'), anyString()) + + verify(userManager).createUser('metrics-usr', 'metrics-pw') + verify(userManager).grantPermission('metrics-usr', UserManager.Permissions.METRICS_VIEW) + } + + @Test + void 'Does not configure prometheus when external Jenkins'() { + config.features.monitoring.active = true + config.jenkins.internal = false + + createJenkins().install() + + verify(prometheusConfigurator, never()).enableAuthentication() + } + + @Test + void 'Does not configure prometheus when monitoring off'() { + config.features.monitoring.active = false + config.jenkins.internal = true + + createJenkins().install() + + verify(prometheusConfigurator, never()).enableAuthentication() + } + + @Test + void 'Configures prometheus'() { + config.features.monitoring.active = true + config.jenkins.internal = true + + createJenkins().install() + + verify(prometheusConfigurator).enableAuthentication() + } + + @Test + void "URL: Use k8s service name if running as k8s pod"() { + config.jenkins.internal = true + config.application.runningInsideK8s = true + + createJenkins().install() + assertThat(config.jenkins.url).isEqualTo("http://jenkins.jenkins.svc.cluster.local:80") + } + + @Test + void "URL: Use local ip and nodePort when outside of k8s"() { + config.jenkins.internal = true + config.application.runningInsideK8s = false + + when(networkingUtils.findClusterBindAddress()).thenReturn('192.168.16.2') + when(k8sClient.waitForNodePort(anyString(), anyString())).thenReturn('42') + + createJenkins().install() + assertThat(config.jenkins.url).endsWith('192.168.16.2:42') + } + + @Test + void 'Handles two registries'() { + config.registry.twoRegistries = true + config.application.namePrefix = 'my-prefix-' + config.application.namePrefixForEnvVars = 'MY_PREFIX_' + + config.registry.url = 'reg-url' + config.registry.path = 'reg-path' + config.registry.username = 'reg-usr' + config.registry.password = 'reg-pw' + config.registry.proxyUrl = 'reg-proxy-url' + config.registry.proxyPath = 'reg-proxy-path' + config.registry.proxyUsername = 'reg-proxy-usr' + config.registry.proxyPassword = 'reg-proxy-pw' + + createJenkins().install() + + verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_REGISTRY_PROXY_URL', 'reg-proxy-url') + verify(globalPropertyManager).setGlobalProperty('MY_PREFIX_REGISTRY_PROXY_PATH', 'reg-proxy-path') + + verify(globalPropertyManager).setGlobalProperty(eq('MY_PREFIX_REGISTRY_URL'), anyString()) + verify(globalPropertyManager).setGlobalProperty(eq('MY_PREFIX_REGISTRY_PATH'), anyString()) + + } + + @Test + void 'Does not create create job credentials when argo cd is deactivated'() { + config.application.namePrefixForEnvVars = 'MY_PREFIX_' + when(userManager.isUsingCasSecurityRealm()).thenReturn(true) + + createJenkins().install() + + verify(userManager, never()).createUser(anyString(), anyString()) + } + + @Test + void 'Global property is set for additional envs'() { + + config.jenkins.additionalEnvs = [ADDITIONAL_DOCKER_RUN_ARGS: '-u0:0'] + + createJenkins().install() + verify(globalPropertyManager).setGlobalProperty(eq('ADDITIONAL_DOCKER_RUN_ARGS'), eq('-u0:0')) + } + + @Test + void 'Does not create create user if CAS security realm is used'() { + config.features.argocd.active = false + + createJenkins().install() + verify(jobManger, never()).createCredential(anyString(), anyString(), anyString(), anyString(), anyString()) + verify(jobManger, never()).startJob(anyString()) + } + + @Test + void 'Properly handles null values'() { + config.application.baseUrl = null + createJenkins().install() + + def env = getEnvAsMap() + assertThat(env['BASE_URL']).isNotEqualTo('null') + } + + @Test + void 'Sets maven mirror '() { + config.registry.url = 'some value' + config.jenkins.mavenCentralMirror = 'http://test' + config.application.namePrefixForEnvVars = 'MY_PREFIX_' + + createJenkins().install() + + verify(globalPropertyManager).setGlobalProperty(eq('MY_PREFIX_MAVEN_CENTRAL_MIRROR'), eq("http://test")) + } + + protected Map getEnvAsMap() { + commandExecutor.environment.collectEntries { it.split('=') } + } + + private Jenkins createJenkins() { + when(networkingUtils.createUrl(anyString(), anyString(), anyString())).thenCallRealMethod() + when(networkingUtils.createUrl(anyString(), anyString())).thenCallRealMethod() + new Jenkins(config, commandExecutor, new FileSystemUtils() { + @Override + Path writeTempFile(Map mergeMap) { + def ret = super.writeTempFile(mergeMap) + temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) + // Path after template invocation + return ret + } + }, globalPropertyManager, jobManger, userManager, prometheusConfigurator, deployer, k8sClient, networkingUtils, gitHandler) + } + + private Map parseActualYaml() { + def ys = new YamlSlurper() + return ys.parse(temporaryYamlFile) as Map + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/MonitoringTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/MonitoringTest.groovy index a24ddfc0e..9af7a4474 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/MonitoringTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/MonitoringTest.groovy @@ -1,168 +1,138 @@ package com.cloudogu.gitops.features +import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType +import static org.assertj.core.api.Assertions.assertThat +import static org.mockito.ArgumentMatchers.any +import static org.mockito.Mockito.* + import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepo -import com.cloudogu.gitops.utils.git.TestGitRepoFactory import com.cloudogu.gitops.git.providers.GitProvider -import com.cloudogu.gitops.utils.git.ScmManagerMock import com.cloudogu.gitops.utils.* -import groovy.yaml.YamlSlurper -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.mockito.ArgumentCaptor +import com.cloudogu.gitops.utils.git.ScmManagerMock +import com.cloudogu.gitops.utils.git.TestGitRepoFactory import java.nio.file.Files import java.nio.file.Path +import groovy.yaml.YamlSlurper -import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType -import static org.assertj.core.api.Assertions.assertThat -import static org.mockito.ArgumentMatchers.any -import static org.mockito.Mockito.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor class MonitoringTest { - Config config = Config.fromMap( - registry: [ - internal : true, - createImagePullSecrets: false - ], - scm: [ - scmManager: [ - internal: true - ] - ], - jenkins: [ - internal : true, - active: true, - metricsUsername: 'metrics', - metricsPassword: 'metrics', - ], - application: [ - username : 'abc', - password : '123', - openshift : false, - namePrefix : 'foo-', - mirrorRepos : false, - podResources : false, - skipCrds : false, - namespaceIsolation: false, - gitName : 'Cloudogu', - gitEmail : 'hello@cloudogu.com', - netpols : false, - namespaces : [ - dedicatedNamespaces: [ - "test1-default", - "test1-argocd", - "test1-monitoring", - "test1-secrets" - ] as LinkedHashSet, - tenantNamespaces : [ - "test1-example-apps-staging", - "test1-example-apps-production" - ] as LinkedHashSet - ] - ], - features: [ - argocd : [ - active: true - ], - monitoring : [ - active : true, - grafanaUrl : '', - grafanaEmailFrom: 'grafana@example.org', - grafanaEmailTo : 'infra@example.org', - helm : [ - chart : 'kube-prometheus-stack', - repoURL: 'https://prom', - version: '19.2.2' - ] - ], - secrets : [ - active: true - ], - ingress: [ - active: true - ] - ]) - - K8sClientForTest k8sClient = new K8sClientForTest(config) - CommandExecutorForTest k8sCommandExecutor = k8sClient.commandExecutorForTest - DeploymentStrategy deploymentStrategy = mock(DeploymentStrategy) - AirGappedUtils airGappedUtils = mock(AirGappedUtils) - Path temporaryYamlFilePrometheus = null - FileSystemUtils fileSystemUtils = new FileSystemUtils() - File clusterResourcesRepoDir - - GitHandler gitHandler = mock(GitHandler.class) - ScmManagerMock scmManagerMock - - @BeforeEach - void setup() { - scmManagerMock = new ScmManagerMock() - } - - - @Test - void "is disabled via active flag"() { - config.features.monitoring.active = false - createStack(scmManagerMock).install() - assertThat(temporaryYamlFilePrometheus).isNull() - assertThat(k8sCommandExecutor.actualCommands).isEmpty() - verifyNoMoreInteractions(deploymentStrategy) - } - - @Test - void 'When mailServer disabled: Does not include mail configurations into cluster resources'() { - config.features.mail.active = null // user should not do this in real. - createStack(scmManagerMock).install() - - def yaml = parseActualYaml() - assertThat(yaml['grafana']['notifiers']).isNull() - } - - @Test - void 'When mailServer enabled: Includes mail configurations into cluster resources'() { - config.features.mail.active = true - createStack(scmManagerMock).install() - assertThat(parseActualYaml()['grafana']['notifiers']).isNotNull() - } - - @Test - void "When Email Addresses is set"() { - config.features.mail.active = true - config.features.monitoring.grafanaEmailFrom = 'grafana@example.com' - config.features.monitoring.grafanaEmailTo = 'infra@example.com' - createStack(scmManagerMock).install() - - def notifiersYaml = parseActualYaml()['grafana']['notifiers']['notifiers.yaml']['notifiers']['settings'] as List - assertThat(notifiersYaml[0]['addresses']).isEqualTo('infra@example.com') - assertThat(parseActualYaml()['grafana']['env']['GF_SMTP_FROM_ADDRESS']).isEqualTo('grafana@example.com') - } - - @Test - void "When Email Addresses is NOT set"() { - config.features.mail.active = true - createStack(scmManagerMock).install() - - def notifiersYaml = parseActualYaml()['grafana']['notifiers']['notifiers.yaml']['notifiers']['settings'] as List - assertThat(notifiersYaml[0]['addresses']).isEqualTo('infra@example.org') - assertThat(parseActualYaml()['grafana']['env']['GF_SMTP_FROM_ADDRESS']).isEqualTo('grafana@example.org') - } - - @Test - void 'When external Mailserver is set'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - config.features.mail.smtpPort = 1010110 - config.features.monitoring.grafanaEmailTo = 'grafana@example.com' - // needed to check that yaml is inserted correctly - - createStack(scmManagerMock).install() - def contactPointsYaml = parseActualYaml() - - assertThat(contactPointsYaml['grafana']['alerting']['contactpoints.yaml']).isEqualTo(new YamlSlurper().parseText( - """ + Config config = Config.fromMap(registry: [internal : true, + createImagePullSecrets: false], + scm: [scmManager: [internal: true]], + jenkins: [internal : true, + active : true, + metricsUsername: 'metrics', + metricsPassword: 'metrics',], + application: [username : 'abc', + password : '123', + openshift : false, + namePrefix : 'foo-', + mirrorRepos : false, + podResources : false, + skipCrds : false, + namespaceIsolation: false, + gitName : 'Cloudogu', + gitEmail : 'hello@cloudogu.com', + netpols : false, + namespaces : [dedicatedNamespaces: ["test1-default", + "test1-argocd", + "test1-monitoring", + "test1-secrets"] as LinkedHashSet, + tenantNamespaces : ["test1-example-apps-staging", + "test1-example-apps-production"] as LinkedHashSet]], + features: [argocd : [active: true], + monitoring: [active : true, + grafanaUrl : '', + grafanaEmailFrom: 'grafana@example.org', + grafanaEmailTo : 'infra@example.org', + helm : [chart : 'kube-prometheus-stack', + repoURL: 'https://prom', + version: '19.2.2']], + secrets : [active: true], + ingress : [active: true]]) + + K8sClientForTest k8sClient = new K8sClientForTest(config) + CommandExecutorForTest k8sCommandExecutor = k8sClient.commandExecutorForTest + Deployer deployer = mock(deployer) + AirGappedUtils airGappedUtils = mock(AirGappedUtils) + Path temporaryYamlFilePrometheus = null + FileSystemUtils fileSystemUtils = new FileSystemUtils() + File clusterResourcesRepoDir + + GitHandler gitHandler = mock(GitHandler.class) + ScmManagerMock scmManagerMock + + @BeforeEach + void setup() { + scmManagerMock = new ScmManagerMock() + } + + @Test + void "is disabled via active flag"() { + config.features.monitoring.active = false + createStack(scmManagerMock).install() + assertThat(temporaryYamlFilePrometheus).isNull() + assertThat(k8sCommandExecutor.actualCommands).isEmpty() + verifyNoMoreInteractions(deployer) + } + + @Test + void 'When mailServer disabled: Does not include mail configurations into cluster resources'() { + config.features.mail.active = null // user should not do this in real. + createStack(scmManagerMock).install() + + def yaml = parseActualYaml() + assertThat(yaml['grafana']['notifiers']).isNull() + } + + @Test + void 'When mailServer enabled: Includes mail configurations into cluster resources'() { + config.features.mail.active = true + createStack(scmManagerMock).install() + assertThat(parseActualYaml()['grafana']['notifiers']).isNotNull() + } + + @Test + void "When Email Addresses is set"() { + config.features.mail.active = true + config.features.monitoring.grafanaEmailFrom = 'grafana@example.com' + config.features.monitoring.grafanaEmailTo = 'infra@example.com' + createStack(scmManagerMock).install() + + def notifiersYaml = parseActualYaml()['grafana']['notifiers']['notifiers.yaml']['notifiers']['settings'] as List + assertThat(notifiersYaml[0]['addresses']).isEqualTo('infra@example.com') + assertThat(parseActualYaml()['grafana']['env']['GF_SMTP_FROM_ADDRESS']).isEqualTo('grafana@example.com') + } + + @Test + void "When Email Addresses is NOT set"() { + config.features.mail.active = true + createStack(scmManagerMock).install() + + def notifiersYaml = parseActualYaml()['grafana']['notifiers']['notifiers.yaml']['notifiers']['settings'] as List + assertThat(notifiersYaml[0]['addresses']).isEqualTo('infra@example.org') + assertThat(parseActualYaml()['grafana']['env']['GF_SMTP_FROM_ADDRESS']).isEqualTo('grafana@example.org') + } + + @Test + void 'When external Mailserver is set'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + config.features.mail.smtpPort = 1010110 + config.features.monitoring.grafanaEmailTo = 'grafana@example.com' + // needed to check that yaml is inserted correctly + + createStack(scmManagerMock).install() + def contactPointsYaml = parseActualYaml() + + assertThat(contactPointsYaml['grafana']['alerting']['contactpoints.yaml']).isEqualTo(new YamlSlurper().parseText(""" apiVersion: 1 contactPoints: - orgId: 1 @@ -173,11 +143,8 @@ contactPoints: type: email settings: addresses: ${config.features.monitoring.grafanaEmailTo} -""" - ) - ) - assertThat(contactPointsYaml['grafana']['alerting']['notification-policies.yaml']).isEqualTo(new YamlSlurper().parseText( - ''' +""")) + assertThat(contactPointsYaml['grafana']['alerting']['notification-policies.yaml']).isEqualTo(new YamlSlurper().parseText(''' apiVersion: 1 policies: - orgId: 1 @@ -186,474 +153,454 @@ policies: routes: - receiver: email group_by: ["grafana_folder", "alertname"] -''' - )) +''')) + + assertThat(contactPointsYaml['grafana']['env']['GF_SMTP_HOST']).isEqualTo('smtp.example.com:1010110') + } + + @Test + void 'When external Mailserver is set with user'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + config.features.mail.smtpUser = 'mailserver@example.com' + + createStack(scmManagerMock).install() + + assertThat(parseActualYaml()['grafana']['smtp']['existingSecret']).isEqualTo('grafana-email-secret') + k8sCommandExecutor.assertExecuted('kubectl create secret generic grafana-email-secret -n foo-monitoring --from-literal user=mailserver@example.com --from-literal password=') + } + + @Test + void 'When external Mailserver is set with password'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + config.features.mail.smtpPassword = '1101ABCabc&/+*~' + + createStack(scmManagerMock).install() + assertThat(parseActualYaml()['grafana']['smtp']['existingSecret']).isEqualTo('grafana-email-secret') + k8sCommandExecutor.assertExecuted('kubectl create secret generic grafana-email-secret -n foo-monitoring --from-literal user= --from-literal password=1101ABCabc&/+*~') + } + + @Test + void 'When external Mailserver is set without user and password'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + + createStack(scmManagerMock).install() + + assertThat(parseActualYaml()['grafana']['valuesFrom']).isNull() + assertThat(parseActualYaml()['grafana']['smtp']).isNull() + k8sCommandExecutor.assertNotExecuted('kubectl create secret generic grafana-email-secret') + } + + @Test + void 'Check if kubernetes secret will be created when external emailservers credential is set'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + config.features.mail.smtpUser = 'grafana@example.com' + config.features.mail.smtpPassword = '1101ABCabc&/+*~' + + createStack(scmManagerMock).install() + + k8sCommandExecutor.assertExecuted('kubectl create secret generic grafana-email-secret -n foo-monitoring --from-literal user=grafana@example.com --from-literal password=1101ABCabc&/+*~') + } - assertThat(contactPointsYaml['grafana']['env']['GF_SMTP_HOST']).isEqualTo('smtp.example.com:1010110') - } - - @Test - void 'When external Mailserver is set with user'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - config.features.mail.smtpUser = 'mailserver@example.com' + @Test + void 'When external Mailserver is set without port'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + + createStack(scmManagerMock).install() + def contactPointsYaml = parseActualYaml() - createStack(scmManagerMock).install() + assertThat(contactPointsYaml['grafana']['env']['GF_SMTP_HOST']).isEqualTo('smtp.example.com') + } - assertThat(parseActualYaml()['grafana']['smtp']['existingSecret']).isEqualTo('grafana-email-secret') - k8sCommandExecutor.assertExecuted('kubectl create secret generic grafana-email-secret -n foo-monitoring --from-literal user=mailserver@example.com --from-literal password=') - } + @Test + void 'When external Mailserver is NOT set'() { + config.features.mail.active = null // user should not do this in real. + createStack(scmManagerMock).install() + def contactPointsYaml = parseActualYaml() - @Test - void 'When external Mailserver is set with password'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - config.features.mail.smtpPassword = '1101ABCabc&/+*~' - - createStack(scmManagerMock).install() - assertThat(parseActualYaml()['grafana']['smtp']['existingSecret']).isEqualTo('grafana-email-secret') - k8sCommandExecutor.assertExecuted('kubectl create secret generic grafana-email-secret -n foo-monitoring --from-literal user= --from-literal password=1101ABCabc&/+*~') - } - - @Test - void 'When external Mailserver is set without user and password'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - - createStack(scmManagerMock).install() - - assertThat(parseActualYaml()['grafana']['valuesFrom']).isNull() - assertThat(parseActualYaml()['grafana']['smtp']).isNull() - k8sCommandExecutor.assertNotExecuted('kubectl create secret generic grafana-email-secret') - } - - @Test - void 'Check if kubernetes secret will be created when external emailservers credential is set'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - config.features.mail.smtpUser = 'grafana@example.com' - config.features.mail.smtpPassword = '1101ABCabc&/+*~' - - createStack(scmManagerMock).install() - - k8sCommandExecutor.assertExecuted('kubectl create secret generic grafana-email-secret -n foo-monitoring --from-literal user=grafana@example.com --from-literal password=1101ABCabc&/+*~') - } - - @Test - void 'When external Mailserver is set without port'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - - createStack(scmManagerMock).install() - def contactPointsYaml = parseActualYaml() - - assertThat(contactPointsYaml['grafana']['env']['GF_SMTP_HOST']).isEqualTo('smtp.example.com') - } - - @Test - void 'When external Mailserver is NOT set'() { - config.features.mail.active = null // user should not do this in real. - createStack(scmManagerMock).install() - def contactPointsYaml = parseActualYaml() - - assertThat(contactPointsYaml['grafana']['alerting']).isNull() - } - - @Test - void "configures admin user if requested"() { - config.application.username = "my-user" - config.application.password = "hunter2" - createStack(scmManagerMock).install() - - assertThat(parseActualYaml()['grafana']['adminUser']).isEqualTo('my-user') - assertThat(parseActualYaml()['grafana']['adminPassword']).isEqualTo('hunter2') - } - - @Test - void 'uses ingress if enabled'() { - config.features.monitoring.grafanaUrl = 'http://grafana.local' - - createStack(scmManagerMock).install() - - def serviceYaml = parseActualYaml()['grafana']['ingress'] - assertThat(serviceYaml['enabled']).isEqualTo(true) - assertThat((serviceYaml['hosts'] as List)[0]).isEqualTo('grafana.local') - } - - @Test - void 'does not use ingress by default'() { - createStack(scmManagerMock).install() - - assertThat(parseActualYaml()['grafana'] as Map).doesNotContainKey('ingress') - } - - @Test - void 'cleanupUnusedDashboards removes all dashboards for disabled features'() { - config.features.monitoring.active = true - config.features.ingress.active = false - config.jenkins.active = false - config.scm.scmManager.url = null // triggers scmm dashboard cleanup - - createStack(scmManagerMock).install() - - File dashboardDir = new File(clusterResourcesRepoDir, "apps/prometheusstack/misc/dashboard") - - assertThat(new File(dashboardDir, "traefik-dashboard.yaml")).doesNotExist() - assertThat(new File(dashboardDir, "traefik-dashboard-requests-handling.yaml")).doesNotExist() - assertThat(new File(dashboardDir, "jenkins-dashboard.yaml")).doesNotExist() - assertThat(new File(dashboardDir, "scmm-dashboard.yaml")).doesNotExist() - } - - @Test - void 'Applies Prometheus ServiceMonitor CRD from file before installing (air-gapped mode)'() { - // Arrange - config.features.monitoring.active = true - config.application.mirrorRepos = true - config.application.skipCrds = false - - Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) - config.application.localHelmChartFolder = rootChartsFolder.toString() - - Path crdFile = rootChartsFolder.resolve( - "${config.features.monitoring.helm.chart}/charts/crds/crds/crd-servicemonitors.yaml" - ) - Files.createDirectories(crdFile.parent) - Files.writeString(crdFile, "dummy") // content can be anything for this test - - Path chartYaml = rootChartsFolder.resolve("${config.features.monitoring.helm.chart}/Chart.yaml") - Files.createDirectories(chartYaml.parent) - Files.writeString(chartYaml, "apiVersion: v2\nname: kube-prometheus-stack\nversion: 42.0.3\n") - - createStack(scmManagerMock).install() - k8sCommandExecutor.assertExecuted("kubectl apply -f ${crdFile}") - - } - - @Test - void 'Applies Prometheus ServiceMonitor CRD from GitHub before installing'() { - config.features.monitoring.active = true - config.application.mirrorRepos = false // optional, but makes intent explicit - config.application.skipCrds = false // optional, but makes intent explicit - - createStack(scmManagerMock).install() - - k8sCommandExecutor.assertExecuted( - "kubectl apply -f https://raw.githubusercontent.com/prometheus-community/helm-charts/" + - "kube-prometheus-stack-${config.features.monitoring.helm.version}/" + - "charts/kube-prometheus-stack/charts/crds/crds/crd-servicemonitors.yaml" - ) - } - - @Test - void 'does not apply ServiceMonitor CRD when monitoring is disabled'() { - config.features.monitoring.active = false // important - config.application.skipCrds = false // so it would apply if enabled - config.application.mirrorRepos = false // avoid local chart access - - createStack(scmManagerMock).install() - - // no CRD apply should happen at all - k8sCommandExecutor.assertNotExecuted('kubectl apply -f https://raw.githubusercontent.com/prometheus-community/helm-charts/') - } - - @Test - void 'uses remote scmm url if requested'() { - createStack(scmManagerMock).install() - - def additionalScrapeConfigs = parseActualYaml()['prometheus']['prometheusSpec']['additionalScrapeConfigs'] as List - assertThat(((additionalScrapeConfigs[0]['static_configs'] as List)[0]['targets'] as List)[0]).isEqualTo('localhost:8080') - assertThat(additionalScrapeConfigs[0]['metrics_path']).isEqualTo('/scm/api/v2/metrics/prometheus') - assertThat(additionalScrapeConfigs[0]['scheme']).isEqualTo('http') - - // scrape config for jenkins is unchanged - assertThat(((additionalScrapeConfigs[1]['static_configs'] as List)[0]['targets'] as List)[0]).isEqualTo('jenkins.foo-jenkins.svc.cluster.local') - assertThat(additionalScrapeConfigs[1]['scheme']).isEqualTo('http') - assertThat(additionalScrapeConfigs[1]['metrics_path']).isEqualTo('/prometheus') - } - - @Test - void 'uses remote jenkins url if requested'() { - config.jenkins["internal"] = false - config.jenkins["url"] = 'https://localhost:9090/jenkins' - createStack(scmManagerMock).install() - def additionalScrapeConfigs = parseActualYaml()['prometheus']['prometheusSpec']['additionalScrapeConfigs'] as List - - // scrape config for scmm is unchanged - assertThat(((additionalScrapeConfigs[0]['static_configs'] as List)[0]['targets'] as List)[0]).isEqualTo('localhost:8080') - assertThat(additionalScrapeConfigs[0]['scheme']).isEqualTo('http') - assertThat(additionalScrapeConfigs[0]['metrics_path']).isEqualTo('/scm/api/v2/metrics/prometheus') - - - assertThat(((additionalScrapeConfigs[1]['static_configs'] as List)[0]['targets'] as List)[0]).isEqualTo('localhost:9090') - assertThat(additionalScrapeConfigs[1]['metrics_path']).isEqualTo('/jenkins/prometheus') - assertThat(additionalScrapeConfigs[1]['scheme']).isEqualTo('https') - } - - @Test - void 'configures custom metrics user for jenkins'() { - config.jenkins["metricsUsername"] = 'external-metrics-username' - config.jenkins["metricsPassword"] = 'hunter2' - createStack(scmManagerMock).install() - - assertThat(k8sCommandExecutor.actualCommands[1]).isEqualTo("kubectl create secret generic prometheus-metrics-creds-jenkins -n foo-monitoring --from-literal password=hunter2 --dry-run=client -oyaml | kubectl apply -f-") - def additionalScrapeConfigs = parseActualYaml()['prometheus']['prometheusSpec']['additionalScrapeConfigs'] as List - assertThat(additionalScrapeConfigs[1]['basic_auth']['username']).isEqualTo('external-metrics-username') - } - - @Test - void "configures custom image for grafana"() { - config.features.monitoring.helm.grafanaImage = "localhost:5000/grafana/grafana:the-tag" - createStack(scmManagerMock).install() - - assertThat(parseActualYaml()['grafana']['image']['registry']).isEqualTo('localhost:5000') - assertThat(parseActualYaml()['grafana']['image']['repository']).isEqualTo('grafana/grafana') - assertThat(parseActualYaml()['grafana']['image']['tag']).isEqualTo('the-tag') - } - - @Test - void "configures custom image for grafana-sidecar"() { - config.features.monitoring.helm.grafanaSidecarImage = "localhost:5000/grafana/sidecar:the-tag" - createStack(scmManagerMock).install() - - assertThat(parseActualYaml()['grafana']['sidecar']['image']['registry']).isEqualTo('localhost:5000') - assertThat(parseActualYaml()['grafana']['sidecar']['image']['repository']).isEqualTo('grafana/sidecar') - assertThat(parseActualYaml()['grafana']['sidecar']['image']['tag']).isEqualTo('the-tag') - } - - @Test - void "configures custom image for prometheus and operator"() { - config.features.monitoring.helm.prometheusImage = "localhost:5000/prometheus/prometheus:v1" - config.features.monitoring.helm.prometheusOperatorImage = "localhost:5000/prometheus-operator/prometheus-operator:v2" - config.features.monitoring.helm.prometheusConfigReloaderImage = "localhost:5000/prometheus-operator/prometheus-config-reloader:v3" - - createStack(scmManagerMock).install() - - def actualYaml = parseActualYaml() - assertThat(actualYaml['prometheus']['prometheusSpec']['image']['registry']).isEqualTo('localhost:5000') - assertThat(actualYaml['prometheus']['prometheusSpec']['image']['repository']).isEqualTo('prometheus/prometheus') - assertThat(actualYaml['prometheus']['prometheusSpec']['image']['tag']).isEqualTo('v1') - assertThat(actualYaml['prometheusOperator']['image']['registry']).isEqualTo('localhost:5000') - assertThat(actualYaml['prometheusOperator']['image']['repository']).isEqualTo('prometheus-operator/prometheus-operator') - assertThat(actualYaml['prometheusOperator']['image']['tag']).isEqualTo('v2') - assertThat(actualYaml['prometheusOperator']['prometheusConfigReloader']['image']['registry']).isEqualTo('localhost:5000') - assertThat(actualYaml['prometheusOperator']['prometheusConfigReloader']['image']['repository']).isEqualTo('prometheus-operator/prometheus-config-reloader') - assertThat(actualYaml['prometheusOperator']['prometheusConfigReloader']['image']['tag']).isEqualTo('v3') - } - - @Test - void 'deploys image pull secrets for proxy registry'() { - config.registry.createImagePullSecrets = true - config.registry.proxyUrl = 'proxy-url' - config.registry.proxyUsername = 'proxy-user' - config.registry.proxyPassword = 'proxy-pw' - - createStack(scmManagerMock).install() - - k8sClient.commandExecutorForTest.assertExecuted( - 'kubectl create secret docker-registry proxy-registry -n foo-monitoring' + - ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') - assertThat(parseActualYaml()['global']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) - } - - @Test - void 'helm release is installed'() { - createStack(scmManagerMock).install() - - assertThat(k8sCommandExecutor.actualCommands[0].trim()).isEqualTo( - 'kubectl create secret generic prometheus-metrics-creds-scmm -n foo-monitoring --from-literal password=123 --dry-run=client -oyaml | kubectl apply -f-') - - verify(deploymentStrategy).deployFeature('https://prom', 'monitoring', - 'kube-prometheus-stack', '19.2.2', 'foo-monitoring', - 'kube-prometheus-stack', temporaryYamlFilePrometheus, RepoType.HELM) - /* This corresponds to - 'helm repo add prometheusstack https://prom' - 'helm upgrade -i kube-prometheus-stack prometheusstack/kube-prometheus-stack --version 19.2.2' + - " --values ${temporaryYamlFile} --namespace foo-monitoring --create-namespace") */ - - def yaml = parseActualYaml() - assertThat(yaml['grafana']['adminUser']).isEqualTo('abc') - assertThat(yaml['grafana']['adminPassword']).isEqualTo(123) - - assertThat(yaml['prometheusOperator'] as Map).doesNotContainKey('resources') - assertThat(yaml['grafana'] as Map).doesNotContainKey('resources') - assertThat(yaml['grafana']['sidecar'] as Map).doesNotContainKey('resources') - assertThat(yaml['prometheus']['prometheusSpec'] as Map).doesNotContainKey('resources') - - assertThat(yaml['prometheusOperator']['securityContext']).isNull() - assertThat(yaml['grafana']['securityContext']).isNull() - assertThat(yaml['prometheus']['prometheusSpec']['securityContext']).isNull() - - assertThat(yaml['kubeApiServer']).isNull() - - assertThat(yaml['prometheusOperator']['admissionWebhooks']['enabled']).isEqualTo(false) - assertThat(yaml['prometheusOperator']['tls']['enabled']).isEqualTo(false) - assertThat(yaml['prometheusOperator']['kubeletService']).isNull() - assertThat(yaml['prometheusOperator']['namespaces']).isNull() - assertThat(yaml).doesNotContainKey('global') - - assertThat(yaml['grafana']['rbac']).isNull() - assertThat(yaml['grafana']['sidecar']['dashboards']['searchNamespace']).isEqualTo('ALL') - - assertThat(yaml['crds']).isNull() - assertThat(new File("$clusterResourcesRepoDir/misc/monitoring/rbac")).doesNotExist() - } - - @Test - void 'Skips CRDs'() { - config.application.skipCrds = true - - createStack(scmManagerMock).install() - - assertThat(parseActualYaml()['crds']['enabled']).isEqualTo(false) - } - - @Test - void 'Sets pod resource limits and requests'() { - config.application.podResources = true - - createStack(scmManagerMock).install() - - def yaml = parseActualYaml() - assertThat(yaml['prometheusOperator']['resources'] as Map).containsKeys('limits', 'requests') - assertThat(yaml['prometheusOperator']['prometheusConfigReloader']['resources'] as Map).containsKeys('limits', 'requests') - assertThat(yaml['grafana']['resources'] as Map) containsKeys('limits', 'requests') - assertThat(yaml['grafana']['sidecar']['resources'] as Map) containsKeys('limits', 'requests') - assertThat(yaml['prometheus']['prometheusSpec']['resources'] as Map) containsKeys('limits', 'requests') - } - - @Test - void 'works with openshift'() { - config.application.openshift = true - // Prepare UID - String realoutput = '{"app.kubernetes.io/created-by":"Internal OpenShift","openshift.io/description":"","openshift.io/display-name":"","openshift.io/requester":"myUser@mydomain.de","openshift.io/sa.scc.mcs":"s0:c30,c25","openshift.io/sa.scc.supplemental-groups":"1000920000/10000","openshift.io/sa.scc.uid-range":"1000920000/10000","project-type":"customer"}' - k8sCommandExecutor.enqueueOutput(new CommandExecutor.Output('', realoutput, 0)) - - createStack(scmManagerMock).install() - - def yaml = parseActualYaml() - assertThat(yaml['prometheusOperator']['securityContext']).isNotNull() - assertThat(yaml['prometheusOperator']['securityContext']['fsGroup']).isNull() - assertThat(yaml['prometheusOperator']['securityContext']['runAsGroup']).isNull() - assertThat(yaml['prometheusOperator']['securityContext']['runAsUser']).isNull() - - assertThat(yaml['grafana']['securityContext']).isNotNull() - assertThat(yaml['grafana']['securityContext']['fsGroup']).isEqualTo(1000920000) - assertThat(yaml['grafana']['securityContext']['runAsGroup']).isEqualTo(1000920000) - assertThat(yaml['grafana']['securityContext']['runAsUser']).isEqualTo(1000920000) - - assertThat(yaml['prometheus']['prometheusSpec']['securityContext']).isNotNull() - assertThat(yaml['prometheus']['prometheusSpec']['securityContext']['fsGroup']).isNull() - assertThat(yaml['prometheus']['prometheusSpec']['securityContext']['runAsGroup']).isNull() - assertThat(yaml['prometheus']['prometheusSpec']['securityContext']['runAsUser']).isNull() - } - - @Test - void 'works with namespaceIsolation'() { - config.application.namespaceIsolation = true - - def prometheusStack = createStack(scmManagerMock) - prometheusStack.install() - - def yaml = parseActualYaml() - assertThat(yaml['global']['rbac']['create']).isEqualTo(false) - - for (String namespace : config.application.namespaces.getActiveNamespaces()) { - def rbacYaml = new File("$clusterResourcesRepoDir/apps/monitoring/misc/rbac/${namespace}.yaml") - assertThat(rbacYaml.text).contains("namespace: ${namespace}") - assertThat(rbacYaml.text).contains(" namespace: foo-monitoring") - } - - assertThat(yaml['kubeApiServer']['enabled']).isEqualTo(false) - - assertThat(yaml['prometheusOperator']['kubeletService']['enabled']).isEqualTo(false) - assertThat(yaml['prometheusOperator']['namespaces']['releaseNamespace']).isEqualTo(false) - assertThat(yaml['prometheusOperator']['namespaces']['additional'] as List).hasSameElementsAs(config.application.namespaces.getActiveNamespaces()) - - assertThat(yaml['grafana']['rbac']['create']).isEqualTo(false) - assertThat(yaml['grafana']['sidecar']['dashboards']['searchNamespace']).isEqualTo(config.application.namespaces.getActiveNamespaces().join(',')) - } - - @Test - void 'network policies are created for prometheus'() { - config.application.netpols = true - //config.application.namespaces.dedicatedNamespaces = ["testnamespace1", "testnamespace2"] - def prometheusStack = createStack(scmManagerMock) - prometheusStack.install() - - for (String namespace : config.application.namespaces.getActiveNamespaces()) { - def netPolsYaml = new File("$clusterResourcesRepoDir/apps/monitoring/misc/netpols/${namespace}.yaml") - assertThat(netPolsYaml.text).contains("namespace: ${namespace}") - } - } - - @Test - void 'helm releases are installed in air-gapped mode'() { - config.application.mirrorRepos = true - when(airGappedUtils.mirrorHelmRepoToGit(any(Config.HelmConfig))).thenReturn('a/b') - - Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) - config.application.localHelmChartFolder = rootChartsFolder.toString() - - Path prometheusSourceChart = rootChartsFolder.resolve('kube-prometheus-stack') - Files.createDirectories(prometheusSourceChart) - - Map prometheusChartYaml = [version: '1.2.3'] - fileSystemUtils.writeYaml(prometheusChartYaml, prometheusSourceChart.resolve('Chart.yaml').toFile()) - - scmManagerMock.inClusterBase = new URI("http://scmm.foo-scm-manager.svc.cluster.local/scm") - createStack(scmManagerMock).install() - - def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) - verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) - assertThat(helmConfig.value.chart).isEqualTo('kube-prometheus-stack') - assertThat(helmConfig.value.repoURL).isEqualTo('https://prom') - assertThat(helmConfig.value.version).isEqualTo('19.2.2') - verify(deploymentStrategy).deployFeature( - 'http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b', - 'monitoring', '.', '1.2.3', 'foo-monitoring', - 'kube-prometheus-stack', temporaryYamlFilePrometheus, RepoType.GIT) - } - - @Test - void 'Merges additional helm values merged with default values'() { - config.features.monitoring.helm.values = [ - key : [ - some: 'thing', - one : 1 - ], - prometheus: [ - prometheusSpec: [ - scrapeConfigSelectorNilUsesHelmValues: null - ] - ] - ] - - createStack(scmManagerMock).install() - def actual = parseActualYaml() - - assertThat(actual['key']['some']).isEqualTo('thing') - assertThat(actual['key']['one']).isEqualTo(1) - assertThat(actual['prometheus']['prometheusSpec']['scrapeConfigSelectorNilUsesHelmValues']).isEqualTo(null) - } - - @Test - void 'ServiceMonitor selectors'() { - config.application.namePrefix = "test1-" - config.features.argocd.active = true - config.features.secrets.active = true - config.features.ingress.active = false - LinkedHashSet namespaceList = [ - "test1-argocd", - "test1-monitoring", - "test1-example-apps-staging", - "test1-example-apps-production", - "test1-secrets" - ] - config.application.namespaces.dedicatedNamespaces = namespaceList - createStack(scmManagerMock).install() - def actual = parseActualYaml() - - assertThat(actual['prometheus']['prometheusSpec']['serviceMonitorNamespaceSelector']).isEqualTo(new YamlSlurper().parseText(''' + assertThat(contactPointsYaml['grafana']['alerting']).isNull() + } + + @Test + void "configures admin user if requested"() { + config.application.username = "my-user" + config.application.password = "hunter2" + createStack(scmManagerMock).install() + + assertThat(parseActualYaml()['grafana']['adminUser']).isEqualTo('my-user') + assertThat(parseActualYaml()['grafana']['adminPassword']).isEqualTo('hunter2') + } + + @Test + void 'uses ingress if enabled'() { + config.features.monitoring.grafanaUrl = 'http://grafana.local' + + createStack(scmManagerMock).install() + + def serviceYaml = parseActualYaml()['grafana']['ingress'] + assertThat(serviceYaml['enabled']).isEqualTo(true) + assertThat((serviceYaml['hosts'] as List)[0]).isEqualTo('grafana.local') + } + + @Test + void 'does not use ingress by default'() { + createStack(scmManagerMock).install() + + assertThat(parseActualYaml()['grafana'] as Map).doesNotContainKey('ingress') + } + + @Test + void 'cleanupUnusedDashboards removes all dashboards for disabled features'() { + config.features.monitoring.active = true + config.features.ingress.active = false + config.jenkins.active = false + config.scm.scmManager.url = null // triggers scmm dashboard cleanup + + createStack(scmManagerMock).install() + + File dashboardDir = new File(clusterResourcesRepoDir, "apps/prometheusstack/misc/dashboard") + + assertThat(new File(dashboardDir, "traefik-dashboard.yaml")).doesNotExist() + assertThat(new File(dashboardDir, "traefik-dashboard-requests-handling.yaml")).doesNotExist() + assertThat(new File(dashboardDir, "jenkins-dashboard.yaml")).doesNotExist() + assertThat(new File(dashboardDir, "scmm-dashboard.yaml")).doesNotExist() + } + + @Test + void 'Applies Prometheus ServiceMonitor CRD from file before installing (air-gapped mode)'() { + // Arrange + config.features.monitoring.active = true + config.application.mirrorRepos = true + config.application.skipCrds = false + + Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) + config.application.localHelmChartFolder = rootChartsFolder.toString() + + Path crdFile = rootChartsFolder.resolve("${config.features.monitoring.helm.chart}/charts/crds/crds/crd-servicemonitors.yaml") + Files.createDirectories(crdFile.parent) + Files.writeString(crdFile, "dummy") // content can be anything for this test + + Path chartYaml = rootChartsFolder.resolve("${config.features.monitoring.helm.chart}/Chart.yaml") + Files.createDirectories(chartYaml.parent) + Files.writeString(chartYaml, "apiVersion: v2\nname: kube-prometheus-stack\nversion: 42.0.3\n") + + createStack(scmManagerMock).install() + k8sCommandExecutor.assertExecuted("kubectl apply -f ${crdFile}") + + } + + @Test + void 'Applies Prometheus ServiceMonitor CRD from GitHub before installing'() { + config.features.monitoring.active = true + config.application.mirrorRepos = false // optional, but makes intent explicit + config.application.skipCrds = false // optional, but makes intent explicit + + createStack(scmManagerMock).install() + + k8sCommandExecutor.assertExecuted("kubectl apply -f https://raw.githubusercontent.com/prometheus-community/helm-charts/" + "kube-prometheus-stack-${config.features.monitoring.helm.version}/" + + "charts/kube-prometheus-stack/charts/crds/crds/crd-servicemonitors.yaml") + } + + @Test + void 'does not apply ServiceMonitor CRD when monitoring is disabled'() { + config.features.monitoring.active = false // important + config.application.skipCrds = false // so it would apply if enabled + config.application.mirrorRepos = false // avoid local chart access + + createStack(scmManagerMock).install() + + // no CRD apply should happen at all + k8sCommandExecutor.assertNotExecuted('kubectl apply -f https://raw.githubusercontent.com/prometheus-community/helm-charts/') + } + + @Test + void 'uses remote scmm url if requested'() { + createStack(scmManagerMock).install() + + def additionalScrapeConfigs = parseActualYaml()['prometheus']['prometheusSpec']['additionalScrapeConfigs'] as List + assertThat(((additionalScrapeConfigs[0]['static_configs'] as List)[0]['targets'] as List)[0]).isEqualTo('localhost:8080') + assertThat(additionalScrapeConfigs[0]['metrics_path']).isEqualTo('/scm/api/v2/metrics/prometheus') + assertThat(additionalScrapeConfigs[0]['scheme']).isEqualTo('http') + + // scrape config for jenkins is unchanged + assertThat(((additionalScrapeConfigs[1]['static_configs'] as List)[0]['targets'] as List)[0]).isEqualTo('jenkins.foo-jenkins.svc.cluster.local') + assertThat(additionalScrapeConfigs[1]['scheme']).isEqualTo('http') + assertThat(additionalScrapeConfigs[1]['metrics_path']).isEqualTo('/prometheus') + } + + @Test + void 'uses remote jenkins url if requested'() { + config.jenkins["internal"] = false + config.jenkins["url"] = 'https://localhost:9090/jenkins' + createStack(scmManagerMock).install() + def additionalScrapeConfigs = parseActualYaml()['prometheus']['prometheusSpec']['additionalScrapeConfigs'] as List + + // scrape config for scmm is unchanged + assertThat(((additionalScrapeConfigs[0]['static_configs'] as List)[0]['targets'] as List)[0]).isEqualTo('localhost:8080') + assertThat(additionalScrapeConfigs[0]['scheme']).isEqualTo('http') + assertThat(additionalScrapeConfigs[0]['metrics_path']).isEqualTo('/scm/api/v2/metrics/prometheus') + + assertThat(((additionalScrapeConfigs[1]['static_configs'] as List)[0]['targets'] as List)[0]).isEqualTo('localhost:9090') + assertThat(additionalScrapeConfigs[1]['metrics_path']).isEqualTo('/jenkins/prometheus') + assertThat(additionalScrapeConfigs[1]['scheme']).isEqualTo('https') + } + + @Test + void 'configures custom metrics user for jenkins'() { + config.jenkins["metricsUsername"] = 'external-metrics-username' + config.jenkins["metricsPassword"] = 'hunter2' + createStack(scmManagerMock).install() + + assertThat(k8sCommandExecutor.actualCommands[1]).isEqualTo("kubectl create secret generic prometheus-metrics-creds-jenkins -n foo-monitoring --from-literal password=hunter2 --dry-run=client -oyaml | kubectl apply -f-") + def additionalScrapeConfigs = parseActualYaml()['prometheus']['prometheusSpec']['additionalScrapeConfigs'] as List + assertThat(additionalScrapeConfigs[1]['basic_auth']['username']).isEqualTo('external-metrics-username') + } + + @Test + void "configures custom image for grafana"() { + config.features.monitoring.helm.grafanaImage = "localhost:5000/grafana/grafana:the-tag" + createStack(scmManagerMock).install() + + assertThat(parseActualYaml()['grafana']['image']['registry']).isEqualTo('localhost:5000') + assertThat(parseActualYaml()['grafana']['image']['repository']).isEqualTo('grafana/grafana') + assertThat(parseActualYaml()['grafana']['image']['tag']).isEqualTo('the-tag') + } + + @Test + void "configures custom image for grafana-sidecar"() { + config.features.monitoring.helm.grafanaSidecarImage = "localhost:5000/grafana/sidecar:the-tag" + createStack(scmManagerMock).install() + + assertThat(parseActualYaml()['grafana']['sidecar']['image']['registry']).isEqualTo('localhost:5000') + assertThat(parseActualYaml()['grafana']['sidecar']['image']['repository']).isEqualTo('grafana/sidecar') + assertThat(parseActualYaml()['grafana']['sidecar']['image']['tag']).isEqualTo('the-tag') + } + + @Test + void "configures custom image for prometheus and operator"() { + config.features.monitoring.helm.prometheusImage = "localhost:5000/prometheus/prometheus:v1" + config.features.monitoring.helm.prometheusOperatorImage = "localhost:5000/prometheus-operator/prometheus-operator:v2" + config.features.monitoring.helm.prometheusConfigReloaderImage = "localhost:5000/prometheus-operator/prometheus-config-reloader:v3" + + createStack(scmManagerMock).install() + + def actualYaml = parseActualYaml() + assertThat(actualYaml['prometheus']['prometheusSpec']['image']['registry']).isEqualTo('localhost:5000') + assertThat(actualYaml['prometheus']['prometheusSpec']['image']['repository']).isEqualTo('prometheus/prometheus') + assertThat(actualYaml['prometheus']['prometheusSpec']['image']['tag']).isEqualTo('v1') + assertThat(actualYaml['prometheusOperator']['image']['registry']).isEqualTo('localhost:5000') + assertThat(actualYaml['prometheusOperator']['image']['repository']).isEqualTo('prometheus-operator/prometheus-operator') + assertThat(actualYaml['prometheusOperator']['image']['tag']).isEqualTo('v2') + assertThat(actualYaml['prometheusOperator']['prometheusConfigReloader']['image']['registry']).isEqualTo('localhost:5000') + assertThat(actualYaml['prometheusOperator']['prometheusConfigReloader']['image']['repository']).isEqualTo('prometheus-operator/prometheus-config-reloader') + assertThat(actualYaml['prometheusOperator']['prometheusConfigReloader']['image']['tag']).isEqualTo('v3') + } + + @Test + void 'deploys image pull secrets for proxy registry'() { + config.registry.createImagePullSecrets = true + config.registry.proxyUrl = 'proxy-url' + config.registry.proxyUsername = 'proxy-user' + config.registry.proxyPassword = 'proxy-pw' + + createStack(scmManagerMock).install() + + k8sClient.commandExecutorForTest.assertExecuted('kubectl create secret docker-registry proxy-registry -n foo-monitoring' + + ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') + assertThat(parseActualYaml()['global']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) + } + + @Test + void 'helm release is installed'() { + createStack(scmManagerMock).install() + + assertThat(k8sCommandExecutor.actualCommands[0].trim()).isEqualTo('kubectl create secret generic prometheus-metrics-creds-scmm -n foo-monitoring --from-literal password=123 --dry-run=client -oyaml | kubectl apply -f-') + + verify(deployer).deployFeature('https://prom', 'monitoring', + 'kube-prometheus-stack', '19.2.2', 'foo-monitoring', + 'kube-prometheus-stack', temporaryYamlFilePrometheus, RepoType.HELM) + /* This corresponds to + 'helm repo add prometheusstack https://prom' + 'helm upgrade -i kube-prometheus-stack prometheusstack/kube-prometheus-stack --version 19.2.2' + + " --values ${temporaryYamlFile} --namespace foo-monitoring --create-namespace") */ + + def yaml = parseActualYaml() + assertThat(yaml['grafana']['adminUser']).isEqualTo('abc') + assertThat(yaml['grafana']['adminPassword']).isEqualTo(123) + + assertThat(yaml['prometheusOperator'] as Map).doesNotContainKey('resources') + assertThat(yaml['grafana'] as Map).doesNotContainKey('resources') + assertThat(yaml['grafana']['sidecar'] as Map).doesNotContainKey('resources') + assertThat(yaml['prometheus']['prometheusSpec'] as Map).doesNotContainKey('resources') + + assertThat(yaml['prometheusOperator']['securityContext']).isNull() + assertThat(yaml['grafana']['securityContext']).isNull() + assertThat(yaml['prometheus']['prometheusSpec']['securityContext']).isNull() + + assertThat(yaml['kubeApiServer']).isNull() + + assertThat(yaml['prometheusOperator']['admissionWebhooks']['enabled']).isEqualTo(false) + assertThat(yaml['prometheusOperator']['tls']['enabled']).isEqualTo(false) + assertThat(yaml['prometheusOperator']['kubeletService']).isNull() + assertThat(yaml['prometheusOperator']['namespaces']).isNull() + assertThat(yaml).doesNotContainKey('global') + + assertThat(yaml['grafana']['rbac']).isNull() + assertThat(yaml['grafana']['sidecar']['dashboards']['searchNamespace']).isEqualTo('ALL') + + assertThat(yaml['crds']).isNull() + assertThat(new File("$clusterResourcesRepoDir/misc/monitoring/rbac")).doesNotExist() + } + + @Test + void 'Skips CRDs'() { + config.application.skipCrds = true + + createStack(scmManagerMock).install() + + assertThat(parseActualYaml()['crds']['enabled']).isEqualTo(false) + } + + @Test + void 'Sets pod resource limits and requests'() { + config.application.podResources = true + + createStack(scmManagerMock).install() + + def yaml = parseActualYaml() + assertThat(yaml['prometheusOperator']['resources'] as Map).containsKeys('limits', 'requests') + assertThat(yaml['prometheusOperator']['prometheusConfigReloader']['resources'] as Map).containsKeys('limits', 'requests') + assertThat(yaml['grafana']['resources'] as Map) containsKeys('limits', 'requests') + assertThat(yaml['grafana']['sidecar']['resources'] as Map) containsKeys('limits', 'requests') + assertThat(yaml['prometheus']['prometheusSpec']['resources'] as Map) containsKeys('limits', 'requests') + } + + @Test + void 'works with openshift'() { + config.application.openshift = true + // Prepare UID + String realoutput = '{"app.kubernetes.io/created-by":"Internal OpenShift","openshift.io/description":"","openshift.io/display-name":"","openshift.io/requester":"myUser@mydomain.de","openshift.io/sa.scc.mcs":"s0:c30,c25","openshift.io/sa.scc.supplemental-groups":"1000920000/10000","openshift.io/sa.scc.uid-range":"1000920000/10000","project-type":"customer"}' + k8sCommandExecutor.enqueueOutput(new CommandExecutor.Output('', realoutput, 0)) + + createStack(scmManagerMock).install() + + def yaml = parseActualYaml() + assertThat(yaml['prometheusOperator']['securityContext']).isNotNull() + assertThat(yaml['prometheusOperator']['securityContext']['fsGroup']).isNull() + assertThat(yaml['prometheusOperator']['securityContext']['runAsGroup']).isNull() + assertThat(yaml['prometheusOperator']['securityContext']['runAsUser']).isNull() + + assertThat(yaml['grafana']['securityContext']).isNotNull() + assertThat(yaml['grafana']['securityContext']['fsGroup']).isEqualTo(1000920000) + assertThat(yaml['grafana']['securityContext']['runAsGroup']).isEqualTo(1000920000) + assertThat(yaml['grafana']['securityContext']['runAsUser']).isEqualTo(1000920000) + + assertThat(yaml['prometheus']['prometheusSpec']['securityContext']).isNotNull() + assertThat(yaml['prometheus']['prometheusSpec']['securityContext']['fsGroup']).isNull() + assertThat(yaml['prometheus']['prometheusSpec']['securityContext']['runAsGroup']).isNull() + assertThat(yaml['prometheus']['prometheusSpec']['securityContext']['runAsUser']).isNull() + } + + @Test + void 'works with namespaceIsolation'() { + config.application.namespaceIsolation = true + + def prometheusStack = createStack(scmManagerMock) + prometheusStack.install() + + def yaml = parseActualYaml() + assertThat(yaml['global']['rbac']['create']).isEqualTo(false) + + for (String namespace : config.application.namespaces.getActiveNamespaces()) { + def rbacYaml = new File("$clusterResourcesRepoDir/apps/monitoring/misc/rbac/${namespace}.yaml") + assertThat(rbacYaml.text).contains("namespace: ${namespace}") + assertThat(rbacYaml.text).contains(" namespace: foo-monitoring") + } + + assertThat(yaml['kubeApiServer']['enabled']).isEqualTo(false) + + assertThat(yaml['prometheusOperator']['kubeletService']['enabled']).isEqualTo(false) + assertThat(yaml['prometheusOperator']['namespaces']['releaseNamespace']).isEqualTo(false) + assertThat(yaml['prometheusOperator']['namespaces']['additional'] as List).hasSameElementsAs(config.application.namespaces.getActiveNamespaces()) + + assertThat(yaml['grafana']['rbac']['create']).isEqualTo(false) + assertThat(yaml['grafana']['sidecar']['dashboards']['searchNamespace']).isEqualTo(config.application.namespaces.getActiveNamespaces().join(',')) + } + + @Test + void 'network policies are created for prometheus'() { + config.application.netpols = true + //config.application.namespaces.dedicatedNamespaces = ["testnamespace1", "testnamespace2"] + def prometheusStack = createStack(scmManagerMock) + prometheusStack.install() + + for (String namespace : config.application.namespaces.getActiveNamespaces()) { + def netPolsYaml = new File("$clusterResourcesRepoDir/apps/monitoring/misc/netpols/${namespace}.yaml") + assertThat(netPolsYaml.text).contains("namespace: ${namespace}") + } + } + + @Test + void 'helm releases are installed in air-gapped mode'() { + config.application.mirrorRepos = true + when(airGappedUtils.mirrorHelmRepoToGit(any(Config.HelmConfig))).thenReturn('a/b') + + Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) + config.application.localHelmChartFolder = rootChartsFolder.toString() + + Path prometheusSourceChart = rootChartsFolder.resolve('kube-prometheus-stack') + Files.createDirectories(prometheusSourceChart) + + Map prometheusChartYaml = [version: '1.2.3'] + fileSystemUtils.writeYaml(prometheusChartYaml, prometheusSourceChart.resolve('Chart.yaml').toFile()) + + scmManagerMock.inClusterBase = new URI("http://scmm.foo-scm-manager.svc.cluster.local/scm") + createStack(scmManagerMock).install() + + def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) + verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) + assertThat(helmConfig.value.chart).isEqualTo('kube-prometheus-stack') + assertThat(helmConfig.value.repoURL).isEqualTo('https://prom') + assertThat(helmConfig.value.version).isEqualTo('19.2.2') + verify(deployer).deployFeature('http://scmm.foo-scm-manager.svc.cluster.local/scm/repo/a/b', + 'monitoring', '.', '1.2.3', 'foo-monitoring', + 'kube-prometheus-stack', temporaryYamlFilePrometheus, RepoType.GIT) + } + + @Test + void 'Merges additional helm values merged with default values'() { + config.features.monitoring.helm.values = [key : [some: 'thing', + one : 1], + prometheus: [prometheusSpec: [scrapeConfigSelectorNilUsesHelmValues: null]]] + + createStack(scmManagerMock).install() + def actual = parseActualYaml() + + assertThat(actual['key']['some']).isEqualTo('thing') + assertThat(actual['key']['one']).isEqualTo(1) + assertThat(actual['prometheus']['prometheusSpec']['scrapeConfigSelectorNilUsesHelmValues']).isEqualTo(null) + } + + @Test + void 'ServiceMonitor selectors'() { + config.application.namePrefix = "test1-" + config.features.argocd.active = true + config.features.secrets.active = true + config.features.ingress.active = false + LinkedHashSet namespaceList = ["test1-argocd", + "test1-monitoring", + "test1-example-apps-staging", + "test1-example-apps-production", + "test1-secrets"] + config.application.namespaces.dedicatedNamespaces = namespaceList + createStack(scmManagerMock).install() + def actual = parseActualYaml() + + assertThat(actual['prometheus']['prometheusSpec']['serviceMonitorNamespaceSelector']).isEqualTo(new YamlSlurper().parseText(''' matchExpressions: - key: kubernetes.io/metadata.name operator: In @@ -663,46 +610,45 @@ matchExpressions: - test1-example-apps-staging - test1-example-apps-production - test1-secrets -''' - )) - } - - private Monitoring createStack(ScmManagerMock scmManagerMock) { - // We use the real FileSystemUtils and not a mock to make sure file editing works as expected - when(gitHandler.getResourcesScm()).thenReturn(scmManagerMock) - def configuration = config - TestGitRepoFactory repoProvider = new TestGitRepoFactory(config, new FileSystemUtils()) { - @Override - GitRepo getRepo(String repoTarget,GitProvider scm) { - def repo = super.getRepo(repoTarget, scmManagerMock) - clusterResourcesRepoDir = new File(repo.getAbsoluteLocalRepoTmpDir()) - - // Create dummy dashboards so cleanupUnusedDashboards can delete them - def dashboardDir = new File(clusterResourcesRepoDir, "apps/monitoring/misc/dashboard") - dashboardDir.mkdirs() - - new File(dashboardDir, "traefik-dashboard.yaml").text = "dummy" - new File(dashboardDir, "traefik-dashboard-requests-handling.yaml").text = "dummy" - new File(dashboardDir, "jenkins-dashboard.yaml").text = "dummy" - new File(dashboardDir, "scmm-dashboard.yaml").text = "dummy" - - return repo - } - - } - - new Monitoring(configuration, new FileSystemUtils() { - @Override - Path writeTempFile(Map mapValues) { - def ret = super.writeTempFile(mapValues) - temporaryYamlFilePrometheus = Path.of(ret.toString().replace(".ftl", "")) - return ret - } - }, deploymentStrategy, k8sClient, airGappedUtils, repoProvider, gitHandler) - } - - private Map parseActualYaml() { - def ys = new YamlSlurper() - return ys.parse(temporaryYamlFilePrometheus) as Map - } -} +''')) + } + + private Monitoring createStack(ScmManagerMock scmManagerMock) { + // We use the real FileSystemUtils and not a mock to make sure file editing works as expected + when(gitHandler.getResourcesScm()).thenReturn(scmManagerMock) + def configuration = config + TestGitRepoFactory repoProvider = new TestGitRepoFactory(config, new FileSystemUtils()) { + @Override + GitRepo getRepo(String repoTarget, GitProvider scm) { + def repo = super.getRepo(repoTarget, scmManagerMock) + clusterResourcesRepoDir = new File(repo.getAbsoluteLocalRepoTmpDir()) + + // Create dummy dashboards so cleanupUnusedDashboards can delete them + def dashboardDir = new File(clusterResourcesRepoDir, "apps/monitoring/misc/dashboard") + dashboardDir.mkdirs() + + new File(dashboardDir, "traefik-dashboard.yaml").text = "dummy" + new File(dashboardDir, "traefik-dashboard-requests-handling.yaml").text = "dummy" + new File(dashboardDir, "jenkins-dashboard.yaml").text = "dummy" + new File(dashboardDir, "scmm-dashboard.yaml").text = "dummy" + + return repo + } + + } + + new Monitoring(configuration, new FileSystemUtils() { + @Override + Path writeTempFile(Map mapValues) { + def ret = super.writeTempFile(mapValues) + temporaryYamlFilePrometheus = Path.of(ret.toString().replace(".ftl", "")) + return ret + } + }, deployer, k8sClient, airGappedUtils, repoProvider, gitHandler) + } + + private Map parseActualYaml() { + def ys = new YamlSlurper() + return ys.parse(temporaryYamlFilePrometheus) as Map + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/RegistryTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/RegistryTest.groovy index 64be7f72c..47cf3a1a5 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/RegistryTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/RegistryTest.groovy @@ -1,92 +1,84 @@ package com.cloudogu.gitops.features +import static com.cloudogu.gitops.config.Config.* +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.Config +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.deployment.HelmStrategy +import com.cloudogu.gitops.kubernetes.api.HelmClient import com.cloudogu.gitops.utils.CommandExecutorForTest import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.HelmClient import com.cloudogu.gitops.utils.K8sClientForTest -import groovy.yaml.YamlSlurper -import org.junit.jupiter.api.Test + import java.nio.file.Path +import groovy.yaml.YamlSlurper -import static org.assertj.core.api.Assertions.assertThat -import static com.cloudogu.gitops.config.Config.* +import org.junit.jupiter.api.Test class RegistryTest { - K8sClientForTest k8sClient - CommandExecutorForTest helmCommands - HelmClient helmClient - Path temporaryYamlFile - - @Test - void 'is disabled when external registry is configured'() { - createRegistry().install() - - assertThat(helmCommands.actualCommands).isEmpty() - assertThat(k8sClient.commandExecutorForTest.actualCommands).isEmpty() - } - - @Test - void 'is installed'() { - createRegistry(new RegistrySchema(active: true)).install() - - assertThat(parseActualYaml()['service']['nodePort']).isEqualTo(DEFAULT_REGISTRY_PORT) - assertThat(parseActualYaml()['service']['type']).isEqualTo('NodePort') - assertThat(helmCommands.actualCommands[0].trim()).startsWith( - 'helm repo add registry') - assertThat(helmCommands.actualCommands[1].trim()).startsWith( - 'helm upgrade -i docker-registry registry/docker-registry --create-namespace') - assertThat(helmCommands.actualCommands[1].trim()).contains('--version') - assertThat(helmCommands.actualCommands[1].trim()).contains("--values ${temporaryYamlFile}") - assertThat(helmCommands.actualCommands[1].trim()).contains('--namespace foo-registry') - assertThat(k8sClient.commandExecutorForTest.actualCommands).isEmpty() - } - - @Test - void 'inject custom value into chart'() { - def registryConfig = new RegistrySchema(active: true, - helm: new HelmConfigWithValues( - chart: 'test', - values: [ - service: [ - type: 'NodePortTest' - ], - customValue: 'testinjectionValue' - ] - ) - ) - - createRegistry(registryConfig).install() - assertThat(parseActualYaml()['service'] as String).contains('NodePortTest') - assertThat(parseActualYaml()['customValue'] as String).contains('testinjectionValue') - } - - private Registry createRegistry(RegistrySchema registryConfig = new RegistrySchema()) { - def config = new Config( - application: new ApplicationSchema(namePrefix: 'foo-'), - registry: registryConfig - ) - k8sClient = new K8sClientForTest(config) - helmCommands = new CommandExecutorForTest() - helmClient = new HelmClient(helmCommands) - - // We use the real FileSystemUtils and not a mock to make sure file editing works as expected - new Registry(config, new FileSystemUtils() { - @Override - Path writeTempFile(Map mergeMap) { - def ret = super.writeTempFile(mergeMap) - temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) - // Path after template invocation - return ret - } - }, k8sClient, new HelmStrategy(config, helmClient)) - } - - private Map parseActualYaml() { - def ys = new YamlSlurper() - return ys.parse(temporaryYamlFile) as Map - } + K8sClientForTest k8sClient + CommandExecutorForTest helmCommands + HelmClient helmClient + Path temporaryYamlFile + + @Test + void 'is disabled when external registry is configured'() { + createRegistry().install() + + assertThat(helmCommands.actualCommands).isEmpty() + assertThat(k8sClient.commandExecutorForTest.actualCommands).isEmpty() + } + + @Test + void 'is installed'() { + createRegistry(new RegistrySchema(active: true)).install() + + assertThat(parseActualYaml()['service']['nodePort']).isEqualTo(DEFAULT_REGISTRY_PORT) + assertThat(parseActualYaml()['service']['type']).isEqualTo('NodePort') + assertThat(helmCommands.actualCommands[0].trim()).startsWith('helm repo add registry') + assertThat(helmCommands.actualCommands[1].trim()).startsWith('helm upgrade -i docker-registry registry/docker-registry --create-namespace') + assertThat(helmCommands.actualCommands[1].trim()).contains('--version') + assertThat(helmCommands.actualCommands[1].trim()).contains("--values ${temporaryYamlFile}") + assertThat(helmCommands.actualCommands[1].trim()).contains('--namespace foo-registry') + assertThat(k8sClient.commandExecutorForTest.actualCommands).isEmpty() + } + + @Test + void 'inject custom value into chart'() { + def registryConfig = new RegistrySchema(active: true, + helm: new HelmConfigWithValues(chart: 'test', + values: [service : [type: 'NodePortTest'], + customValue: 'testinjectionValue'])) + + createRegistry(registryConfig).install() + assertThat(parseActualYaml()['service'] as String).contains('NodePortTest') + assertThat(parseActualYaml()['customValue'] as String).contains('testinjectionValue') + } + + private Registry createRegistry(RegistrySchema registryConfig = new RegistrySchema()) { + def config = new Config(application: new ApplicationSchema(namePrefix: 'foo-'), + registry: registryConfig) + k8sClient = new K8sClientForTest(config) + helmCommands = new CommandExecutorForTest() + helmClient = new HelmClient(helmCommands) + + // We use the real FileSystemUtils and not a mock to make sure file editing works as expected + new Registry(config, new FileSystemUtils() { + @Override + Path writeTempFile(Map mergeMap) { + def ret = super.writeTempFile(mergeMap) + temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) + // Path after template invocation + return ret + } + }, k8sClient, new Deployer(new HelmStrategy(config, helmClient))) + } + + private Map parseActualYaml() { + def ys = new YamlSlurper() + return ys.parse(temporaryYamlFile) as Map + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/VaultTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/VaultTest.groovy index 9e6c9b695..ad7bcd0a2 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/VaultTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/VaultTest.groovy @@ -1,256 +1,239 @@ package com.cloudogu.gitops.features +import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.RepoType +import static org.assertj.core.api.Assertions.assertThat +import static org.mockito.ArgumentMatchers.any +import static org.mockito.Mockito.* + import com.cloudogu.gitops.config.Config -import com.cloudogu.gitops.features.deployment.DeploymentStrategy +import com.cloudogu.gitops.features.deployment.Deployer import com.cloudogu.gitops.features.git.GitHandler +import com.cloudogu.gitops.utils.* import com.cloudogu.gitops.utils.git.GitHandlerForTests import com.cloudogu.gitops.utils.git.ScmManagerMock -import com.cloudogu.gitops.utils.* -import groovy.yaml.YamlSlurper -import org.junit.jupiter.api.Test -import org.mockito.ArgumentCaptor import java.nio.file.Files import java.nio.file.Path +import groovy.yaml.YamlSlurper -import static com.cloudogu.gitops.features.deployment.DeploymentStrategy.* -import static org.assertj.core.api.Assertions.assertThat -import static org.mockito.ArgumentMatchers.any -import static org.mockito.Mockito.* +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor class VaultTest { - Config config = new Config( - application: new Config.ApplicationSchema( - namePrefix: 'foo-', - ), - features: new Config.FeaturesSchema( - secrets: new Config.SecretsSchema( - active: true, - ) - ) - ) - - CommandExecutorForTest helmCommands = new CommandExecutorForTest() - FileSystemUtils fileSystemUtils = new FileSystemUtils() - DeploymentStrategy deploymentStrategy = mock(DeploymentStrategy) - AirGappedUtils airGappedUtils = mock(AirGappedUtils) - K8sClientForTest k8sClient = new K8sClientForTest(config) - GitHandler gitHandler = new GitHandlerForTests(config, new ScmManagerMock()) - Path temporaryYamlFile - - @Test - void 'is disabled via active flag'() { - config.features.secrets.active = false - createVault().install() - assertThat(helmCommands.actualCommands).isEmpty() - assertThat(k8sClient.commandExecutorForTest.actualCommands).isEmpty() - } - - @Test - void 'uses ingress if enabled'() { - config.features.secrets.vault.url = 'http://vault.local' - createVault().install() - - def ingressYaml = parseActualYaml()['server']['ingress'] - assertThat(ingressYaml['enabled']).isEqualTo(true) - assertThat((ingressYaml['hosts'] as List)[0]['host']).isEqualTo('vault.local') - } - - @Test - void 'uses ingress if enabled and image set'() { - config.features.secrets.vault.url = 'http://vault.local' - // Also set image to make sure ingress and image work at the same time under the server block - //config.features.secrets.vault.helm.image = 'localhost:5000/hashicorp/vault:1.12.0' - createVault().install() - - def ingressYaml = parseActualYaml()['server']['ingress'] - assertThat(ingressYaml['enabled']).isEqualTo(true) - } - - @Test - void 'does not use ingress by default'() { - createVault().install() - - assertThat(parseActualYaml()).doesNotContainKey('server') - } - - @Test - void 'Dev mode can be enabled via config'() { - config.features.secrets.vault.mode = 'dev' - config.application.username = 'abc' - config.application.password = '123' - config.features.argocd.active = true - - def vault = createVault() - - // Simulate that the namespace does not exist (kubectl get returns a non-zero exit code) - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('Error from server (NotFound): namespaces "foo-secrets" not found', '', 1)) - - vault.install() - - def actualYaml = parseActualYaml() - assertThat(actualYaml['server']['dev']['enabled']).isEqualTo(true) - - assertThat(actualYaml['server']['dev']['devRootToken']).isNotEqualTo('root') - assertThat(actualYaml['server']['dev']['devRootToken']).isNotEqualTo(config.application.password) - - List actualPostStart = (List) actualYaml['server']['postStart'] - assertThat(actualPostStart[0]).isEqualTo('/bin/sh') - assertThat(actualPostStart[1]).isEqualTo('-c') - - assertThat(actualPostStart[2]).isEqualTo( - 'USERNAME=abc PASSWORD=123 ARGOCD=true /var/opt/scripts/dev-post-start.sh 2>&1 | tee /tmp/dev-post-start.log') - - List actualVolumes = actualYaml['server']['volumes'] as List - List actualVolumeMounts = actualYaml['server']['volumeMounts'] as List - assertThat(actualVolumes[0]['name']).isEqualTo(actualVolumeMounts[0]['name']) - assertThat(actualVolumes[0]['configMap']['defaultMode']).isEqualTo(Integer.valueOf(0774)) - - assertThat(actualVolumeMounts[0]['readOnly']).is(true) - assertThat(actualPostStart[2] as String).contains(actualVolumeMounts[0]['mountPath'] as String + "/dev-post-start.sh") - - assertThat(k8sClient.commandExecutorForTest.actualCommands).hasSize(3) - - assertThat(k8sClient.commandExecutorForTest.actualCommands[0]).contains('kubectl get namespace foo-secrets') - assertThat(k8sClient.commandExecutorForTest.actualCommands[1]).contains('kubectl create namespace foo-secrets') - - def createdConfigMapName = ((k8sClient.commandExecutorForTest.actualCommands[2] =~ /kubectl create configmap (\S*) .*/)[0] as List)[1] - assertThat(actualVolumes[0]['configMap']['name']).isEqualTo(createdConfigMapName) - - assertThat(k8sClient.commandExecutorForTest.actualCommands[2]).contains('-n foo-secrets') - assertThat(actualYaml['server'] as Map).doesNotContainKey('resources') - } - - @Test - void 'Dev mode can be enabled via config with argoCD disabled'() { - config.features.secrets.vault.mode = 'dev' - config.application.username = 'abc' - config.application.password = '123' - createVault().install() - - def actualYaml = parseActualYaml() - List actualPostStart = (List) actualYaml['server']['postStart'] - assertThat(actualPostStart[2]).isEqualTo( - 'USERNAME=abc PASSWORD=123 ARGOCD=false /var/opt/scripts/dev-post-start.sh 2>&1 | tee /tmp/dev-post-start.log') - } - - @Test - void 'Prod mode can be enabled'() { - config.features.secrets.vault.mode = 'prod' - createVault().install() - - assertThat(parseActualYaml()).doesNotContainKey('server') - - assertThat(k8sClient.commandExecutorForTest.actualCommands).isEmpty() - } - - @Test - void 'custom image is used'() { - config.features.secrets.vault.helm.image = 'localhost:5000/hashicorp/vault:1.12.0' - createVault().install() - - def actualYaml = parseActualYaml() - assertThat(actualYaml['server']['image']['repository']).isEqualTo('localhost:5000/hashicorp/vault') - assertThat(actualYaml['server']['image']['tag']).isEqualTo('1.12.0') - } - - @Test - void 'helm release is installed'() { - config.features.secrets.vault.helm = new Config.SecretsSchema.VaultSchema.VaultHelmSchema( - chart: 'vault', - repoURL: 'https://vault-reg', - version: '42.23.0' - ) - createVault().install() - - verify(deploymentStrategy).deployFeature( - 'https://vault-reg', - 'vault', - 'vault', - '42.23.0', - 'foo-secrets', - 'vault', - temporaryYamlFile, - RepoType.HELM - ) - - assertThat(parseActualYaml()).doesNotContainKey('global') - } - - @Test - void 'helm release is installed in air-gapped mode'() { - config.application.mirrorRepos = true - config.features.secrets.vault.helm = new Config.SecretsSchema.VaultSchema.VaultHelmSchema( - chart: 'vault', - repoURL: 'https://vault-reg', - version: '42.23.0' - ) - - when(airGappedUtils.mirrorHelmRepoToGit(any(Config.HelmConfig))).thenReturn('a/b') - - Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) - config.application.localHelmChartFolder = rootChartsFolder.toString() - - Path SourceChart = rootChartsFolder.resolve('vault') - Files.createDirectories(SourceChart) - - Map ChartYaml = [version: '1.2.3'] - fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) - - createVault().install() - - def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) - verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) - assertThat(helmConfig.value.chart).isEqualTo('vault') - assertThat(helmConfig.value.repoURL).isEqualTo('https://vault-reg') - assertThat(helmConfig.value.version).isEqualTo('42.23.0') - verify(deploymentStrategy).deployFeature( - 'http://scmm.scm-manager.svc.cluster.local/scm/repo/a/b', - 'vault', '.', '1.2.3', 'foo-secrets', - 'vault', temporaryYamlFile, RepoType.GIT) - } - - @Test - void 'Sets pod resource limits and requests'() { - config.application.podResources = true - - createVault().install() - - def actualYaml = parseActualYaml() - assertThat(actualYaml['server']['resources'] as Map).containsKeys('limits', 'requests') - } - - @Test - void 'deploys image pull secrets for proxy registry'() { - config.registry.createImagePullSecrets = true - config.registry.proxyUrl = 'proxy-url' - config.registry.proxyUsername = 'proxy-user' - config.registry.proxyPassword = 'proxy-pw' - - createVault().install() - - k8sClient.commandExecutorForTest.assertExecuted( - 'kubectl create secret docker-registry proxy-registry -n foo-secrets' + - ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') - assertThat(parseActualYaml()['global']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) - } - - private Vault createVault() { - // We use the real FileSystemUtils and not a mock to make sure file editing works as expected - - new Vault(config, new FileSystemUtils() { - @Override - Path writeTempFile(Map mapValues) { - def ret = super.writeTempFile(mapValues) - temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) - return ret - } - }, k8sClient, deploymentStrategy, airGappedUtils, gitHandler) - } - - private Map parseActualYaml() { - def ys = new YamlSlurper() - return ys.parse(temporaryYamlFile) as Map - } + Config config = new Config(application: new Config.ApplicationSchema(namePrefix: 'foo-',), + features: new Config.FeaturesSchema(secrets: new Config.SecretsSchema(active: true,))) + + CommandExecutorForTest helmCommands = new CommandExecutorForTest() + FileSystemUtils fileSystemUtils = new FileSystemUtils() + Deployer deployer = mock(Deployer) + AirGappedUtils airGappedUtils = mock(AirGappedUtils) + K8sClientForTest k8sClient = new K8sClientForTest(config) + GitHandler gitHandler = new GitHandlerForTests(config, new ScmManagerMock()) + Path temporaryYamlFile + + @Test + void 'is disabled via active flag'() { + config.features.secrets.active = false + createVault().install() + assertThat(helmCommands.actualCommands).isEmpty() + assertThat(k8sClient.commandExecutorForTest.actualCommands).isEmpty() + } + + @Test + void 'uses ingress if enabled'() { + config.features.secrets.vault.url = 'http://vault.local' + createVault().install() + + def ingressYaml = parseActualYaml()['server']['ingress'] + assertThat(ingressYaml['enabled']).isEqualTo(true) + assertThat((ingressYaml['hosts'] as List)[0]['host']).isEqualTo('vault.local') + } + + @Test + void 'uses ingress if enabled and image set'() { + config.features.secrets.vault.url = 'http://vault.local' + // Also set image to make sure ingress and image work at the same time under the server block + //config.features.secrets.vault.helm.image = 'localhost:5000/hashicorp/vault:1.12.0' + createVault().install() + + def ingressYaml = parseActualYaml()['server']['ingress'] + assertThat(ingressYaml['enabled']).isEqualTo(true) + } + + @Test + void 'does not use ingress by default'() { + createVault().install() + + assertThat(parseActualYaml()).doesNotContainKey('server') + } + + @Test + void 'Dev mode can be enabled via config'() { + config.features.secrets.vault.mode = 'dev' + config.application.username = 'abc' + config.application.password = '123' + config.features.argocd.active = true + + def vault = createVault() + + // Simulate that the namespace does not exist (kubectl get returns a non-zero exit code) + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('Error from server (NotFound): namespaces "foo-secrets" not found', '', 1)) + + vault.install() + + def actualYaml = parseActualYaml() + assertThat(actualYaml['server']['dev']['enabled']).isEqualTo(true) + + assertThat(actualYaml['server']['dev']['devRootToken']).isNotEqualTo('root') + assertThat(actualYaml['server']['dev']['devRootToken']).isNotEqualTo(config.application.password) + + List actualPostStart = (List) actualYaml['server']['postStart'] + assertThat(actualPostStart[0]).isEqualTo('/bin/sh') + assertThat(actualPostStart[1]).isEqualTo('-c') + + assertThat(actualPostStart[2]).isEqualTo('USERNAME=abc PASSWORD=123 ARGOCD=true /var/opt/scripts/dev-post-start.sh 2>&1 | tee /tmp/dev-post-start.log') + + List actualVolumes = actualYaml['server']['volumes'] as List + List actualVolumeMounts = actualYaml['server']['volumeMounts'] as List + assertThat(actualVolumes[0]['name']).isEqualTo(actualVolumeMounts[0]['name']) + assertThat(actualVolumes[0]['configMap']['defaultMode']).isEqualTo(Integer.valueOf(0774)) + + assertThat(actualVolumeMounts[0]['readOnly']).is(true) + assertThat(actualPostStart[2] as String).contains(actualVolumeMounts[0]['mountPath'] as String + "/dev-post-start.sh") + + assertThat(k8sClient.commandExecutorForTest.actualCommands).hasSize(3) + + assertThat(k8sClient.commandExecutorForTest.actualCommands[0]).contains('kubectl get namespace foo-secrets') + assertThat(k8sClient.commandExecutorForTest.actualCommands[1]).contains('kubectl create namespace foo-secrets') + + def createdConfigMapName = ((k8sClient.commandExecutorForTest.actualCommands[2] =~ /kubectl create configmap (\S*) .*/)[0] as List)[1] + assertThat(actualVolumes[0]['configMap']['name']).isEqualTo(createdConfigMapName) + + assertThat(k8sClient.commandExecutorForTest.actualCommands[2]).contains('-n foo-secrets') + assertThat(actualYaml['server'] as Map).doesNotContainKey('resources') + } + + @Test + void 'Dev mode can be enabled via config with argoCD disabled'() { + config.features.secrets.vault.mode = 'dev' + config.application.username = 'abc' + config.application.password = '123' + createVault().install() + + def actualYaml = parseActualYaml() + List actualPostStart = (List) actualYaml['server']['postStart'] + assertThat(actualPostStart[2]).isEqualTo('USERNAME=abc PASSWORD=123 ARGOCD=false /var/opt/scripts/dev-post-start.sh 2>&1 | tee /tmp/dev-post-start.log') + } + + @Test + void 'Prod mode can be enabled'() { + config.features.secrets.vault.mode = 'prod' + createVault().install() + + assertThat(parseActualYaml()).doesNotContainKey('server') + + assertThat(k8sClient.commandExecutorForTest.actualCommands).isEmpty() + } + + @Test + void 'custom image is used'() { + config.features.secrets.vault.helm.image = 'localhost:5000/hashicorp/vault:1.12.0' + createVault().install() + + def actualYaml = parseActualYaml() + assertThat(actualYaml['server']['image']['repository']).isEqualTo('localhost:5000/hashicorp/vault') + assertThat(actualYaml['server']['image']['tag']).isEqualTo('1.12.0') + } + + @Test + void 'helm release is installed'() { + config.features.secrets.vault.helm = new Config.SecretsSchema.VaultSchema.VaultHelmSchema(chart: 'vault', + repoURL: 'https://vault-reg', + version: '42.23.0') + createVault().install() + + verify(deployer).deployFeature('https://vault-reg', + 'vault', + 'vault', + '42.23.0', + 'foo-secrets', + 'vault', + temporaryYamlFile, + RepoType.HELM) + + assertThat(parseActualYaml()).doesNotContainKey('global') + } + + @Test + void 'helm release is installed in air-gapped mode'() { + config.application.mirrorRepos = true + config.features.secrets.vault.helm = new Config.SecretsSchema.VaultSchema.VaultHelmSchema(chart: 'vault', + repoURL: 'https://vault-reg', + version: '42.23.0') + + when(airGappedUtils.mirrorHelmRepoToGit(any(Config.HelmConfig))).thenReturn('a/b') + + Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) + config.application.localHelmChartFolder = rootChartsFolder.toString() + + Path SourceChart = rootChartsFolder.resolve('vault') + Files.createDirectories(SourceChart) + + Map ChartYaml = [version: '1.2.3'] + fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) + + createVault().install() + + def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) + verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) + assertThat(helmConfig.value.chart).isEqualTo('vault') + assertThat(helmConfig.value.repoURL).isEqualTo('https://vault-reg') + assertThat(helmConfig.value.version).isEqualTo('42.23.0') + verify(deployer).deployFeature('http://scmm.scm-manager.svc.cluster.local/scm/repo/a/b', + 'vault', '.', '1.2.3', 'foo-secrets', + 'vault', temporaryYamlFile, RepoType.GIT) + } + + @Test + void 'Sets pod resource limits and requests'() { + config.application.podResources = true + + createVault().install() + + def actualYaml = parseActualYaml() + assertThat(actualYaml['server']['resources'] as Map).containsKeys('limits', 'requests') + } + + @Test + void 'deploys image pull secrets for proxy registry'() { + config.registry.createImagePullSecrets = true + config.registry.proxyUrl = 'proxy-url' + config.registry.proxyUsername = 'proxy-user' + config.registry.proxyPassword = 'proxy-pw' + + createVault().install() + + k8sClient.commandExecutorForTest.assertExecuted('kubectl create secret docker-registry proxy-registry -n foo-secrets' + + ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') + assertThat(parseActualYaml()['global']['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) + } + + private Vault createVault() { + // We use the real FileSystemUtils and not a mock to make sure file editing works as expected + + new Vault(config, new FileSystemUtils() { + @Override + Path writeTempFile(Map mapValues) { + def ret = super.writeTempFile(mapValues) + temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) + return ret + } + }, k8sClient, deployer, airGappedUtils, gitHandler) + } + + private Map parseActualYaml() { + def ys = new YamlSlurper() + return ys.parse(temporaryYamlFile) as Map + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDTest.groovy index 1b4ec6dcf..64ed3f25e 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDTest.groovy @@ -1,1604 +1,1471 @@ package com.cloudogu.gitops.features.argocd +import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable +import static org.assertj.core.api.Assertions.assertThat +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.git.providers.GitProvider -import com.cloudogu.gitops.kubernetes.api.HelmClient -import com.cloudogu.gitops.utils.* +import com.cloudogu.gitops.utils.CommandExecutor +import com.cloudogu.gitops.utils.CommandExecutorForTest +import com.cloudogu.gitops.utils.FileSystemUtils +import com.cloudogu.gitops.utils.K8sClientForTest import com.cloudogu.gitops.utils.git.GitHandlerForTests import com.cloudogu.gitops.utils.git.TestGitProvider import com.cloudogu.gitops.utils.git.TestGitRepoFactory + +import java.nio.file.Files +import java.nio.file.Path +import java.util.stream.Collectors import groovy.io.FileType import groovy.json.JsonSlurper import groovy.yaml.YamlSlurper + import org.junit.jupiter.api.Test import org.mockito.Spy import org.springframework.security.crypto.bcrypt.BCrypt -import java.nio.file.Files -import java.nio.file.Path -import java.util.stream.Collectors +class ArgoCDTest { + Map buildImages = [kubectl : 'kubectl-value', + helm : 'helm-value', + kubeval : 'kubeval-value', + helmKubeval: 'helmKubeval-value', + yamllint : 'yamllint-value'] + + Config config = Config.fromMap(application: [openshift : false, + insecure : false, + password : '123', + username : 'something', + namePrefix : '', + namePrefixForEnvVars: '', + gitName : 'Cloudogu', + gitEmail : 'hello@cloudogu.com', + namespaces : [dedicatedNamespaces: ["argocd", "monitoring", "traefik", "secrets"], + tenantNamespaces : ["example-apps-staging", "example-apps-production"]]], + scm: [scmManager: [internal: true], + gitlab : [url: '']], + multiTenant: [scmManager : [url: ''], + gitlab : [url: ''], + useDedicatedInstance: false], + content: [repos : [[url : 'https://github.com/cloudogu/gitops-build-lib', + target : '3rd-party-dependencies/gitops-build-lib', + overwriteMode: 'RESET'], + [url : 'https://github.com/cloudogu/ces-build-lib', + target : '3rd-party-dependencies/ces-build-lib', + overwriteMode: 'RESET'], + [url : 'https://github.com/cloudogu/spring-boot-helm-chart', + target : '3rd-party-dependencies/spring-boot-helm-chart', + overwriteMode: 'RESET'], + [url : 'https://github.com/cloudogu/spring-petclinic', + target : 'argocd/petclinic-plain', + ref : 'feature/gitops_ready', + targetRef : 'main', + overwriteMode : 'UPGRADE', + createJenkinsJob: true], + [url : 'https://github.com/cloudogu/spring-petclinic', + target : 'argocd/petclinic-helm', + ref : 'feature/gitops_ready', + targetRef : 'main', + overwriteMode : 'UPGRADE', + createJenkinsJob: true], + [url : 'https://github.com/cloudogu/gitops-playground', + path : 'example-apps-via-content-loader/', + ref : 'main', + templating : true, + type : 'FOLDER_BASED', + overwriteMode: 'UPGRADE']], + namespaces: ["example-apps-production", + "example-apps-staging"], + variables : [petclinic: [baseDomain: 'petclinic.localhost'], + nginx : [baseDomain: 'nginx.localhost'], + images : [kubectl : 'bitnamilegacy/kubectl:1.29', + helm : 'ghcr.io/cloudogu/helm:3.16.4-1', + kubeval : 'ghcr.io/cloudogu/helm:3.16.4-1', + helmKubeval: 'ghcr.io/cloudogu/helm:3.16.4-1', + yamllint : 'cytopia/yamllint:1.25-0.7', + nginx : '', + petclinic : 'eclipse-temurin:17-jre-alpine', + maven : '']]], + features: [argocd : [operator : false, + active : true, + configOnly : true, + emailFrom : 'argocd@example.org', + emailToUser : 'app-team@example.org', + emailToAdmin : 'infra@example.org', + resourceInclusionsCluster: ''], + monitoring: [active: true, + helm : [chart : 'kube-prometheus-stack', + version: '42.0.3']], + ingress : [active: true], + secrets : [active: true, + + ]]) + + @Spy + CommandExecutor test = new CommandExecutor() + + CommandExecutorForTest k8sCommands = new CommandExecutorForTest() + CommandExecutorForTest helmCommands = new CommandExecutorForTest() + // GitRepo argocdRepo + String actualHelmValuesFile + GitRepo clusterResourcesRepo + List petClinicRepos = [] + ArgoCD argocd + RepoLayout clusterResourcesRepoLayout + + @Test + void 'Installs argoCD'() { + // Simulate argocd Namespace does not exist + k8sCommands.enqueueOutput(new CommandExecutor.Output('namespace not found', '', 1)) + + def argocd = createArgoCD() + argocd.install() + this.clusterResourcesRepo = (argocd as ArgoCDForTest).clusterResourcesRepo + + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + + k8sCommands.assertExecuted('kubectl create namespace argocd') + + // check values.yaml + List filesWithInternalSCMM = findFilesContaining(new File(clusterResourcesRepoLayout.rootDir()), + clusterResourcesRepo.gitProvider.url) + assertThat(filesWithInternalSCMM).isNotEmpty() + assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['server']['service']['type']) + .isEqualTo('ClusterIP') + assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['notifications']['argocdUrl']).isNull() + + assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['crds']).isNull() + assertThat(parseActualYaml(actualHelmValuesFile)['global']).isNull() + + // check repoTemplateSecretName + k8sCommands.assertExecuted('kubectl create secret generic argocd-repo-creds-scm -n argocd') + k8sCommands.assertExecuted('kubectl label secret argocd-repo-creds-scm -n argocd') + + // Check dependency build and helm install (Chart liegt jetzt unter apps/argocd/argocd) + assertThat(helmCommands.actualCommands[0].trim()) + .isEqualTo('helm repo add argo https://argoproj.github.io/argo-helm') + assertThat(helmCommands.actualCommands[1].trim()) + .isEqualTo("helm dependency build ${clusterResourcesRepoLayout.helmDir()}".toString()) + assertThat(helmCommands.actualCommands[2].trim()) + .isEqualTo("helm upgrade -i argocd ${clusterResourcesRepoLayout.helmDir()} --create-namespace --namespace argocd".toString()) + + // Check patched PW + def patchCommand = k8sCommands.assertExecuted('kubectl patch secret argocd-secret -n argocd') + String patchFile = (patchCommand =~ /--patch-file=([\S]+)/)?.findResult { (it as List)[1] } + assertThat(BCrypt.checkpw(config.application.password as String, + parseActualYaml(patchFile)['stringData']['admin.password'] as String)) + .as("Password hash missmatch").isTrue() + + // Check bootstrapping (liegt jetzt unter argocd/projects und argocd/applications) + k8sCommands.assertExecuted("kubectl apply -f ${Path.of(clusterResourcesRepoLayout.projectsDir(), 'argocd.yaml')}") + k8sCommands.assertExecuted("kubectl apply -f ${Path.of(clusterResourcesRepoLayout.applicationsDir(), 'bootstrap.yaml')}") + + def deleteCommand = k8sCommands.assertExecuted('kubectl delete secret -n argocd') + assertThat(deleteCommand).contains('owner=helm', 'name=argocd') + + // Operator disabled -> operator Ordner sollte fehlen + assertThat(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toFile()).doesNotExist() + assertThat(Path.of(clusterResourcesRepoLayout.operatorRbacDir()).toFile()).doesNotExist() + + // Projects (jetzt unter argocd/projects) + def clusterRessourcesYaml = new YamlSlurper().parse(Path.of(clusterResourcesRepoLayout.projectsDir(), 'cluster-resources.yaml')) + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains('https://prometheus-community.github.io/helm-charts') + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain('http://scmm-scm-manager.default.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack') + + // Applications (jetzt unter argocd/applications) + def argocdYaml = new YamlSlurper().parse(Path.of(clusterResourcesRepoLayout.applicationsDir(), 'argocd.yaml')) + assertThat(argocdYaml['spec']['source']['directory']).isNull() + + // Neuer Pfad: Chart liegt unter argocd/argocd (nicht mehr nur argocd/) + assertThat(argocdYaml['spec']['source']['path'] as String) + .isIn('apps/argocd/argocd', 'apps/argocd/argocd/') + } + + @Test + void 'Installs Argo CD with custom values'() { + config.features.argocd.values = ['argo-cd': [key: 'value']] + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + def valuesYaml = parseActualYaml(actualHelmValuesFile) + assertThat(valuesYaml['argo-cd']['key']).isEqualTo('value') + } + + @Test + void 'When monitoring disabled: Does not push path monitoring to cluster resources'() { + config.features.monitoring.active = false + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + assertThat(new File(clusterResourcesRepoLayout.monitoringDir())).doesNotExist() + } + + @Test + void 'When monitoring enabled: Does push path monitoring to cluster resources'() { + config.features.monitoring.active = true + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + assertThat(new File(clusterResourcesRepoLayout.monitoringDir())).exists() + assertValidDashboards(clusterResourcesRepoLayout.monitoringDir()) + } + + void assertValidDashboards(String monitoringPath) { + Files.walk(Path.of(monitoringPath)) + .filter { it.toString() ==~ /.*-dashboard\.yaml/ }.each { Path path -> + def dashboardConfigMap = null + + assertThatCode { + dashboardConfigMap = parseActualYaml(path.toString()) + }.as("Invalid YAML in ${path.fileName}").doesNotThrowAnyException() + + assertThat(dashboardConfigMap.data as Map).hasSize(1) + .as('Expected only on dashboard json within map') + assertThatCode { + def dashboardJsonString = (dashboardConfigMap.data as Map).entrySet().first().value as String + new JsonSlurper().parseText(dashboardJsonString) + }.as("Invalid JSON in ${path.fileName}").doesNotThrowAnyException() + } + } + + @Test + void 'When mailServer disabled: Does not include mail configurations into cluster resources'() { + config.features.mail.active = false + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + + def valuesYaml = parseActualYaml(actualHelmValuesFile) + assertThat(valuesYaml['argo-cd']['notifications']['enabled']).isEqualTo(false) + assertThat(valuesYaml['argo-cd']['notifications']['notifiers']).isNull() + } + + @Test + void 'When mailServer enabled: Includes mail configurations into cluster resources'() { + config.features.mail.active = true + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + def valuesYaml = parseActualYaml(actualHelmValuesFile) + + assertThat(valuesYaml['argo-cd']['notifications']['enabled']).isEqualTo(true) + assertThat(valuesYaml['argo-cd']['notifications']['notifiers']).isNotNull() + } + + @Test + void 'When emailaddress is set: Include given email addresses into configurations'() { + config.features.mail.active = true + config.features.argocd.emailFrom = 'argocd@example.com' + config.features.argocd.emailToUser = 'app-team@example.com' + config.features.argocd.emailToAdmin = 'argocd@example.com' + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + def valuesYaml = parseActualYaml(actualHelmValuesFile) + + def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), "cluster-resources.yaml") + def argocdYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.applicationsDir(), 'argocd.yaml') + def defaultYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), 'default.yaml') + + assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)['from']).isEqualTo("argocd@example.com") + assertThat(clusterRessourcesYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.email']).isEqualTo('argocd@example.com') + assertThat(argocdYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.on-sync-status-unknown.email']).isEqualTo('argocd@example.com') + assertThat(defaultYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.email']).isEqualTo('argocd@example.com') + } + + @Test + void 'When emailaddress is NOT set: Use default email addresses in configurations'() { + config.features.mail.active = true + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + def valuesYaml = parseActualYaml(actualHelmValuesFile) + + def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), "cluster-resources.yaml") + def argocdYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.applicationsDir(), 'argocd.yaml') + def defaultYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), 'default.yaml') + + assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)['from']).isEqualTo("argocd@example.org") + assertThat(clusterRessourcesYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.email']).isEqualTo('infra@example.org') + assertThat(argocdYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.on-sync-status-unknown.email']).isEqualTo('infra@example.org') + assertThat(defaultYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.email']).isEqualTo('infra@example.org') + } + + @Test + void 'When external Mailserver is set'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + config.features.mail.smtpPort = 1010110 + config.features.mail.smtpUser = 'argo@example.com' + config.features.mail.smtpPassword = '1101:ABCabc&/+*~' + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + + def serviceEmail = new YamlSlurper().parseText(parseActualYaml(actualHelmValuesFile)['argo-cd']['notifications']['notifiers']['service.email'] as String) + + assertThat(serviceEmail['host']).isEqualTo(config.features.mail.smtpAddress) + assertThat(serviceEmail['port']).isEqualTo(config.features.mail.smtpPort) + // username and password are both linked to the k8s secret. Secrets will be created at runtime, in this test + assertThat(serviceEmail['username']).isEqualTo('$email-username') + assertThat(serviceEmail['password']).isEqualTo('$email-password') + + def createMailSecretCommand = k8sCommands.assertExecuted('kubectl create secret generic argocd-notifications-secret -n argocd') + assertThat(createMailSecretCommand).contains('email-username', config.features.mail.smtpUser as CharSequence) + assertThat(createMailSecretCommand).contains('email-password', config.features.mail.smtpPassword as CharSequence) + } + + @Test + void 'When external emailservers username is set, check if kubernetes secret will be created'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + config.features.mail.smtpUser = 'argo@example.com' + + createArgoCD().install() + + def createMailSecretCommand = k8sCommands.assertExecuted('kubectl create secret generic argocd-notifications-secret -n argocd') + assertThat(createMailSecretCommand).contains('email-username', config.features.mail.smtpUser as CharSequence) + } + + @Test + void 'When external emailservers password is set, check if kubernetes secret will be created'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + config.features.mail.smtpPassword = '1101:ABCabc&/+*~' + + createArgoCD().install() + + def createMailSecretCommand = k8sCommands.assertExecuted('kubectl create secret generic argocd-notifications-secret -n argocd') + assertThat(createMailSecretCommand).contains('email-password', config.features.mail.smtpPassword as CharSequence) + } + + @Test + void 'When external Mailserver is set without port, user, password'() { + config.features.mail.active = true + config.features.mail.smtpAddress = 'smtp.example.com' + + def argocd = createArgoCD() + argocd.install() + + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + + def serviceEmail = new YamlSlurper().parseText(parseActualYaml(actualHelmValuesFile)['argo-cd']['notifications']['notifiers']['service.email'] as String) + + k8sCommands.assertNotExecuted('kubectl create secret generic argocd-notifications-secret') + + assertThat(serviceEmail['host']).isEqualTo("smtp.example.com") + assertThat(serviceEmail as Map).doesNotContainKey('port') + assertThat(serviceEmail as Map).doesNotContainKey('username') + assertThat(serviceEmail as Map).doesNotContainKey('password') + } + + @Test + void 'When external Mailserver is NOT set'() { + config.features.mail.active = true + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + def valuesYaml = parseActualYaml(actualHelmValuesFile) + + assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)['port']).isEqualTo(1025) + assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)) doesNotHaveToString('username') + assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)) doesNotHaveToString('password') + } + + @Test + void 'When vault disabled: Does not push path "secrets" to cluster resources'() { + config.features.secrets.active = false + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + assertThat(new File(clusterResourcesRepoLayout.vaultDir())).doesNotExist() + } + + @Test + void 'Prepares repos for air-gapped mode'() { + config.features.monitoring.active = false + config.application.mirrorRepos = true + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + + def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), "cluster-resources.yaml") + + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains('http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack') + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain('https://prometheus-community.github.io/helm-charts') + } + + @Test + void 'Pushes repos with empty name-prefix'() { + def argocd = createArgoCD() + argocd.install() + this.clusterResourcesRepo = (argocd as ArgoCDForTest).clusterResourcesRepo + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + assertArgoCdYamlPrefixes(clusterResourcesRepo.gitProvider.url, '', clusterResourcesRepoLayout) + } + + @Test + void 'Creates Jenkinsfiles for two registries'() { + config.registry.twoRegistries = true + createArgoCD().install() + + assertJenkinsfileRegistryCredentials() + } + + @Test + void 'Pushes repos with name-prefix'() { + config.application.namePrefix = 'abc-' + + def argocd = createArgoCD() + argocd.install() + this.clusterResourcesRepo = (argocd as ArgoCDForTest).clusterResourcesRepo + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + assertArgoCdYamlPrefixes(clusterResourcesRepo.gitProvider.url, config.application.namePrefix, clusterResourcesRepoLayout) + } + + @Test + void 'SecurityContext null in Openshift'() { + config.application.openshift = true + createArgoCD().install() + + for (def petclinicRepo : petClinicRepos) { + if (petclinicRepo.repoTarget.contains('argocd/petclinic-plain')) { + assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, '/k8s/staging/deployment.yaml').text).contains('runAsUser: null') + assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, '/k8s/staging/deployment.yaml').text).contains('runAsGroup: null') + } + if (petclinicRepo.repoTarget.contains('argocd/petclinic-helm')) { + assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, '/k8s/values-shared.yaml').text).contains('runAsUser: null') + assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, '/k8s/values-shared.yaml').text).contains('runAsGroup: null') + } + } + } + + @Test + void 'Skips CRDs for argo cd'() { + config.application.skipCrds = true + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + + assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['crds']['install']).isEqualTo(false) + } + + @Test + void 'Write maven mirror into jenkinsfiles'() { + config.jenkins.mavenCentralMirror = 'http://test' + createArgoCD().install() + + for (def petclinicRepo : petClinicRepos) { + assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, 'Jenkinsfile').text).contains('mvn.useMirrors([name: \'maven-central-mirror\', mirrorOf: \'central\', url: env.MAVEN_CENTRAL_MIRROR])') + } + } + + @Test + void 'ArgoCD with active network policies'() { + config.application.netpols = true + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" + + assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['global']['networkPolicy']['create']).isEqualTo(true) + assertThat(new File(clusterResourcesRepoLayout.argocdRoot(), '/argocd/values.yaml').text.contains("namespace: monitoring")) + assertThat(new File(clusterResourcesRepoLayout.argocdRoot(), '/argocd/templates/allow-namespaces.yaml').text.contains("namespace: monitoring")) + assertThat(new File(clusterResourcesRepoLayout.argocdRoot(), '/argocd/templates/allow-namespaces.yaml').text.contains("namespace: default")) + } + + private void assertArgoCdYamlPrefixes(String scmmUrl, String expectedPrefix, RepoLayout repoLayout) { + + assertAllYamlFiles(new File(repoLayout.argocdRoot()), 'projects', 3) { Path file -> + def yaml = parseActualYaml(file.toString()) + List sourceRepos = yaml['spec']['sourceRepos'] as List + // Some projects might not have sourceRepos + if (sourceRepos) { + sourceRepos.each { + if (it.startsWith(scmmUrl)) { + assertThat(it) + .as("$file sourceRepos have name prefix") + .startsWith("${scmmUrl}/repo/${expectedPrefix}argocd") + } + } + } + + String metadataNamespace = yaml['metadata']['namespace'] as String + if (metadataNamespace) { + assertThat(metadataNamespace) + .as("$file metadata.namespace has name prefix") + .isEqualTo("${expectedPrefix}argocd".toString()) + } + + List sourceNamespaces = yaml['spec']['sourceNamespaces'] as List + if (sourceNamespaces) { + sourceNamespaces.each { + if (it != '*') { + assertThat(it) + .as("$file spec.sourceNamespace has name prefix") + .startsWith("${expectedPrefix}") + } + } + } + } + + assertAllYamlFiles(new File(repoLayout.argocdRoot()), 'applications', 3) { Path file -> + def yaml = parseActualYaml(file.toString()) + assertThat(yaml['spec']['source']['repoURL'] as String) + .as("$file repoURL have name prefix") + .startsWith("${scmmUrl}/repo/${expectedPrefix}argocd") + + assertThat(yaml['metadata']['namespace']) + .as("$file metadata.namspace has name prefix") + .isEqualTo("${expectedPrefix}argocd".toString()) + + assertThat(yaml['spec']['destination']['namespace']) + .as("$file spec.destination.namspace has name prefix") + .isEqualTo("${expectedPrefix}argocd".toString()) + } + + //checks all other folder for prefixed yaml files except "apps/argocd" + assertAllYamlFiles(new File(repoLayout.rootDir()), 'apps', 9, + ['/apps/argocd/']) { Path it -> + + def yaml = parseActualYaml(it.toString()) + List yamlDocuments = yaml instanceof List ? yaml : [yaml] + for (def document in yamlDocuments) { + if (document && document['kind'] != 'Namespace') { + def metadataNamespace = document['metadata']['namespace'] as String + assertThat(metadataNamespace) + .as("$it metadata.namespace has name prefix") + .startsWith("${expectedPrefix}") + } + } + } + } + + private static void assertAllYamlFiles( + File rootDir, + String childDir, + Integer numberOfFiles, + List excludeContains = [], + Closure cl) { + def rootPath = Path.of(rootDir.absolutePath, childDir) + + def yamlFiles = Files.walk(rootPath) + .filter { Files.isRegularFile(it) } + .filter { Path p -> + def s = p.toString().replace('\\', '/') + (s.endsWith('.yaml') || s.endsWith('.yml')) && !excludeContains.any { ex -> s.contains(ex) } + } + .collect(Collectors.toList()) + + yamlFiles.each(cl) + + assertThat(yamlFiles.size()).isEqualTo(numberOfFiles) + } + + private static List findFilesContaining(File folder, String stringToSearch) { + List result = [] + folder.eachFileRecurse(FileType.FILES) { + if (it.text.contains(stringToSearch)) { + result += it + } + } + return result + } + + ArgoCD createArgoCD() { + def argoCD = ArgoCDForTest.newWithAutoProviders(config, k8sCommands, helmCommands) + return argoCD + } + + void assertJenkinsfileRegistryCredentials() { + List defaultRegistryExpectedLines = ['String pathPrefix = !dockerRegistryPath?.trim() ? "" : "${dockerRegistryPath}/"', + 'imageName = "${dockerRegistryBaseUrl}/${pathPrefix}${application}:${imageTag}"'] + List twoRegistriesExpectedLines = ['String proxyPathPrefix = !dockerRegistryProxyPath?.trim() ? "" : "${dockerRegistryProxyPath}/"', + 'docker.withRegistry("https://${dockerRegistryProxyBaseUrl}/${proxyPathPrefix}", dockerRegistryProxyCredentials) {',] + + for (def petclinicRepo : petClinicRepos) { + String jenkinsfile = new File(petclinicRepo.absoluteLocalRepoTmpDir, 'Jenkinsfile').text + + defaultRegistryExpectedLines.each { expectedEnvVar -> assertThat(jenkinsfile).contains(expectedEnvVar) + } + + if (config.registry['twoRegistries']) { + twoRegistriesExpectedLines.each { expectedEnvVar -> assertThat(jenkinsfile).contains(expectedEnvVar) + } + } else { + twoRegistriesExpectedLines.each { expectedEnvVar -> assertThat(jenkinsfile).doesNotContain(expectedEnvVar) + } + } + } + } + + @Test + void 'Prepares ArgoCD repo with Operator configuration file'() { + def argocd = setupOperatorTest() + + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) + def rbacConfigPath = Path.of(clusterResourcesRepoLayout.operatorRbacDir()) + + assertThat(argocdConfigPath.toFile()).exists() + assertThat(rbacConfigPath.toFile()).exists() + + def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) + assertThat(yaml['apiVersion']).isEqualTo('argoproj.io/v1beta1') + assertThat(yaml['kind']).isEqualTo('ArgoCD') + } + + @Test + void 'No files for operator when operator is false'() { + def argocd = createArgoCD() + + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) + def rbacConfigPath = Path.of(clusterResourcesRepoLayout.operatorRbacDir()) + + assertThat(argocdConfigPath.toFile()).doesNotExist() + assertThat(rbacConfigPath.toFile()).doesNotExist() + } + + @Test + void 'Deploys with operator without OpenShift configuration'() { + def argocd = setupOperatorTest(openshift: false) + + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) + + k8sCommands.assertExecuted("kubectl apply -f ${argocdConfigPath}") + + def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) + assertThat(yaml['spec']['rbac']).isNull() + assertThat(yaml['spec']['sso']).isNull() + + def argocdYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.applicationsDir(), 'argocd.yaml') + assertThat(argocdYaml['spec']['source']['directory']['recurse'] as Boolean).isTrue() + assertThat(argocdYaml['spec']['source']['path']).isEqualTo('apps/argocd/operator/') + // Here we should assert all <#if argocd.isOperator> in YAML 😐️ + } + + @Test + void 'RBACs with operator using RbacDefinition outputs'() { + config.application.namePrefix = "testPrefix-" + + LinkedHashSet expectedNamespaces = ["testPrefix-monitoring", + "testPrefix-secrets", + "testPrefix-traefik", + "testPrefix-example-apps-staging", + "testPrefix-example-apps-production"] + // have to prepare activeNamespaces for unit-test, Application.groovy is setting this in integration way + config.application.namespaces.dedicatedNamespaces = new LinkedHashSet(["monitoring", + "secrets", + "traefik", + "example-apps-staging", + "example-apps-production"]) + + def argocd = setupOperatorTest(openshift: false) + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() -import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable -import static org.assertj.core.api.Assertions.assertThat -import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode + File rbacPath = Path.of(clusterResourcesRepoLayout.operatorRbacDir()).toFile() -class ArgoCDTest { - Map buildImages = [ - kubectl : 'kubectl-value', - helm : 'helm-value', - kubeval : 'kubeval-value', - helmKubeval: 'helmKubeval-value', - yamllint : 'yamllint-value' - ] - - Config config = Config.fromMap( - application: [ - openshift : false, - insecure : false, - password : '123', - username : 'something', - namePrefix : '', - namePrefixForEnvVars: '', - gitName : 'Cloudogu', - gitEmail : 'hello@cloudogu.com', - namespaces : [ - dedicatedNamespaces: ["argocd", "monitoring", "traefik", "secrets"], - tenantNamespaces : ["example-apps-staging", "example-apps-production"] - ] - ], - scm: [ - scmManager: [ - internal: true], - gitlab : [ - url: '' - ] - ], - multiTenant: [ - scmManager : [ - url: '' - ], - gitlab : [ - url: '' - ], - useDedicatedInstance: false - ], - content: [ - repos: [ - [ - url: 'https://github.com/cloudogu/gitops-build-lib', - target: '3rd-party-dependencies/gitops-build-lib', - overwriteMode: 'RESET' - ], - [ - url: 'https://github.com/cloudogu/ces-build-lib', - target: '3rd-party-dependencies/ces-build-lib', - overwriteMode: 'RESET' - ], - [ - url: 'https://github.com/cloudogu/spring-boot-helm-chart', - target: '3rd-party-dependencies/spring-boot-helm-chart', - overwriteMode: 'RESET' - ], - [ - url: 'https://github.com/cloudogu/spring-petclinic', - target: 'argocd/petclinic-plain', - ref: 'feature/gitops_ready', - targetRef: 'main', - overwriteMode: 'UPGRADE', - createJenkinsJob: true - ], - [ - url: 'https://github.com/cloudogu/spring-petclinic', - target: 'argocd/petclinic-helm', - ref: 'feature/gitops_ready', - targetRef: 'main', - overwriteMode: 'UPGRADE', - createJenkinsJob: true - ], - [ - url: 'https://github.com/cloudogu/gitops-playground', - path: 'example-apps-via-content-loader/', - ref: 'main', - templating: true, - type: 'FOLDER_BASED', - overwriteMode: 'UPGRADE' - ] - ], - namespaces: [ - "example-apps-production", - "example-apps-staging" - ], - variables: [ - petclinic: [ - baseDomain: 'petclinic.localhost' - ], - nginx: [ - baseDomain: 'nginx.localhost' - ], - images: [ - kubectl: 'bitnamilegacy/kubectl:1.29', - helm: 'ghcr.io/cloudogu/helm:3.16.4-1', - kubeval: 'ghcr.io/cloudogu/helm:3.16.4-1', - helmKubeval: 'ghcr.io/cloudogu/helm:3.16.4-1', - yamllint: 'cytopia/yamllint:1.25-0.7', - nginx: '', - petclinic: 'eclipse-temurin:17-jre-alpine', - maven: '' - ] - ] - ], - features: [ - argocd : [ - operator : false, - active : true, - configOnly : true, - emailFrom : 'argocd@example.org', - emailToUser : 'app-team@example.org', - emailToAdmin : 'infra@example.org', - resourceInclusionsCluster: '' - ], - monitoring : [ - active: true, - helm : [ - chart : 'kube-prometheus-stack', - version: '42.0.3' - ] - ], - ingress: [ - active: true - ], - secrets : [ - active: true, - - ] - ] - ) - - @Spy - CommandExecutor test = new CommandExecutor() - - CommandExecutorForTest k8sCommands = new CommandExecutorForTest() - CommandExecutorForTest helmCommands = new CommandExecutorForTest() -// GitRepo argocdRepo - String actualHelmValuesFile - GitRepo clusterResourcesRepo - List petClinicRepos = [] - ArgoCD argocd - RepoLayout clusterResourcesRepoLayout - - @Test - void 'Installs argoCD'() { - // Simulate argocd Namespace does not exist - k8sCommands.enqueueOutput(new CommandExecutor.Output('namespace not found', '', 1)) - - def argocd = createArgoCD() - argocd.install() - this.clusterResourcesRepo = (argocd as ArgoCDForTest).clusterResourcesRepo - - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - - - k8sCommands.assertExecuted('kubectl create namespace argocd') - - // check values.yaml - List filesWithInternalSCMM = findFilesContaining( - new File(clusterResourcesRepoLayout.rootDir()), - clusterResourcesRepo.gitProvider.url - ) - assertThat(filesWithInternalSCMM).isNotEmpty() - assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['server']['service']['type']) - .isEqualTo('ClusterIP') - assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['notifications']['argocdUrl']).isNull() - - assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['crds']).isNull() - assertThat(parseActualYaml(actualHelmValuesFile)['global']).isNull() - - // check repoTemplateSecretName - k8sCommands.assertExecuted('kubectl create secret generic argocd-repo-creds-scm -n argocd') - k8sCommands.assertExecuted('kubectl label secret argocd-repo-creds-scm -n argocd') - - // Check dependency build and helm install (Chart liegt jetzt unter apps/argocd/argocd) - assertThat(helmCommands.actualCommands[0].trim()) - .isEqualTo('helm repo add argo https://argoproj.github.io/argo-helm') - assertThat(helmCommands.actualCommands[1].trim()) - .isEqualTo("helm dependency build ${clusterResourcesRepoLayout.helmDir()}".toString()) - assertThat(helmCommands.actualCommands[2].trim()) - .isEqualTo("helm upgrade -i argocd ${clusterResourcesRepoLayout.helmDir()} --create-namespace --namespace argocd".toString()) - - // Check patched PW - def patchCommand = k8sCommands.assertExecuted('kubectl patch secret argocd-secret -n argocd') - String patchFile = (patchCommand =~ /--patch-file=([\S]+)/)?.findResult { (it as List)[1] } - assertThat(BCrypt.checkpw(config.application.password as String, - parseActualYaml(patchFile)['stringData']['admin.password'] as String)) - .as("Password hash missmatch").isTrue() - - // Check bootstrapping (liegt jetzt unter argocd/projects und argocd/applications) - k8sCommands.assertExecuted("kubectl apply -f ${Path.of(clusterResourcesRepoLayout.projectsDir(), 'argocd.yaml')}") - k8sCommands.assertExecuted("kubectl apply -f ${Path.of(clusterResourcesRepoLayout.applicationsDir(), 'bootstrap.yaml')}") - - def deleteCommand = k8sCommands.assertExecuted('kubectl delete secret -n argocd') - assertThat(deleteCommand).contains('owner=helm', 'name=argocd') - - // Operator disabled -> operator Ordner sollte fehlen - assertThat(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toFile()).doesNotExist() - assertThat(Path.of(clusterResourcesRepoLayout.operatorRbacDir()).toFile()).doesNotExist() - - // Projects (jetzt unter argocd/projects) - def clusterRessourcesYaml = new YamlSlurper().parse( - Path.of(clusterResourcesRepoLayout.projectsDir(), 'cluster-resources.yaml') - ) - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains( - 'https://prometheus-community.github.io/helm-charts' - ) - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain( - 'http://scmm-scm-manager.default.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack' - ) - - // Applications (jetzt unter argocd/applications) - def argocdYaml = new YamlSlurper().parse( - Path.of(clusterResourcesRepoLayout.applicationsDir(), 'argocd.yaml') - ) - assertThat(argocdYaml['spec']['source']['directory']).isNull() - - // Neuer Pfad: Chart liegt unter argocd/argocd (nicht mehr nur argocd/) - assertThat(argocdYaml['spec']['source']['path'] as String) - .isIn('apps/argocd/argocd', 'apps/argocd/argocd/') - } - - @Test - void 'Installs Argo CD with custom values'() { - config.features.argocd.values = ['argo-cd': [key: 'value']] - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - def valuesYaml = parseActualYaml(actualHelmValuesFile) - assertThat(valuesYaml['argo-cd']['key']).isEqualTo('value') - } - - @Test - void 'When monitoring disabled: Does not push path monitoring to cluster resources'() { - config.features.monitoring.active = false - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - assertThat(new File(clusterResourcesRepoLayout.monitoringDir())).doesNotExist() - } - - @Test - void 'When monitoring enabled: Does push path monitoring to cluster resources'() { - config.features.monitoring.active = true - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - assertThat(new File(clusterResourcesRepoLayout.monitoringDir())).exists() - assertValidDashboards(clusterResourcesRepoLayout.monitoringDir()) - } - - void assertValidDashboards(String monitoringPath) { - Files.walk(Path.of(monitoringPath)) - .filter { it.toString() ==~ /.*-dashboard\.yaml/ }.each { Path path -> - def dashboardConfigMap = null - - assertThatCode { - dashboardConfigMap = parseActualYaml(path.toString()) - }.as("Invalid YAML in ${path.fileName}").doesNotThrowAnyException() - - assertThat(dashboardConfigMap.data as Map).hasSize(1) - .as('Expected only on dashboard json within map') - assertThatCode { - def dashboardJsonString = (dashboardConfigMap.data as Map).entrySet().first().value as String - new JsonSlurper().parseText(dashboardJsonString) - }.as("Invalid JSON in ${path.fileName}").doesNotThrowAnyException() - } - } - - - - @Test - void 'When mailServer disabled: Does not include mail configurations into cluster resources'() { - config.features.mail.active = false - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - - def valuesYaml = parseActualYaml(actualHelmValuesFile) - assertThat(valuesYaml['argo-cd']['notifications']['enabled']).isEqualTo(false) - assertThat(valuesYaml['argo-cd']['notifications']['notifiers']).isNull() - } - - @Test - void 'When mailServer enabled: Includes mail configurations into cluster resources'() { - config.features.mail.active = true - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - def valuesYaml = parseActualYaml(actualHelmValuesFile) - - assertThat(valuesYaml['argo-cd']['notifications']['enabled']).isEqualTo(true) - assertThat(valuesYaml['argo-cd']['notifications']['notifiers']).isNotNull() - } - - @Test - void 'When emailaddress is set: Include given email addresses into configurations'() { - config.features.mail.active = true - config.features.argocd.emailFrom = 'argocd@example.com' - config.features.argocd.emailToUser = 'app-team@example.com' - config.features.argocd.emailToAdmin = 'argocd@example.com' - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - def valuesYaml = parseActualYaml(actualHelmValuesFile) - - def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), "cluster-resources.yaml") - def argocdYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.applicationsDir(), 'argocd.yaml') - def defaultYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), 'default.yaml') - - assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)['from']).isEqualTo("argocd@example.com") - assertThat(clusterRessourcesYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.email']).isEqualTo('argocd@example.com') - assertThat(argocdYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.on-sync-status-unknown.email']).isEqualTo('argocd@example.com') - assertThat(defaultYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.email']).isEqualTo('argocd@example.com') - } - - @Test - void 'When emailaddress is NOT set: Use default email addresses in configurations'() { - config.features.mail.active = true - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - def valuesYaml = parseActualYaml(actualHelmValuesFile) - - def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), "cluster-resources.yaml") - def argocdYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.applicationsDir(), 'argocd.yaml') - def defaultYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), 'default.yaml') - - assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)['from']).isEqualTo("argocd@example.org") - assertThat(clusterRessourcesYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.email']).isEqualTo('infra@example.org') - assertThat(argocdYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.on-sync-status-unknown.email']).isEqualTo('infra@example.org') - assertThat(defaultYaml['metadata']['annotations']['notifications.argoproj.io/subscribe.email']).isEqualTo('infra@example.org') - } - - @Test - void 'When external Mailserver is set'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - config.features.mail.smtpPort = 1010110 - config.features.mail.smtpUser = 'argo@example.com' - config.features.mail.smtpPassword = '1101:ABCabc&/+*~' - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - - def serviceEmail = new YamlSlurper().parseText( - parseActualYaml(actualHelmValuesFile)['argo-cd']['notifications']['notifiers']['service.email'] as String) - - assertThat(serviceEmail['host']).isEqualTo(config.features.mail.smtpAddress) - assertThat(serviceEmail['port']).isEqualTo(config.features.mail.smtpPort) - // username and password are both linked to the k8s secret. Secrets will be created at runtime, in this test - assertThat(serviceEmail['username']).isEqualTo('$email-username') - assertThat(serviceEmail['password']).isEqualTo('$email-password') - - def createMailSecretCommand = k8sCommands.assertExecuted('kubectl create secret generic argocd-notifications-secret -n argocd') - assertThat(createMailSecretCommand).contains('email-username', config.features.mail.smtpUser as CharSequence) - assertThat(createMailSecretCommand).contains('email-password', config.features.mail.smtpPassword as CharSequence) - } - - @Test - void 'When external emailservers username is set, check if kubernetes secret will be created'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - config.features.mail.smtpUser = 'argo@example.com' - - createArgoCD().install() - - def createMailSecretCommand = k8sCommands.assertExecuted('kubectl create secret generic argocd-notifications-secret -n argocd') - assertThat(createMailSecretCommand).contains('email-username', config.features.mail.smtpUser as CharSequence) - } - - @Test - void 'When external emailservers password is set, check if kubernetes secret will be created'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - config.features.mail.smtpPassword = '1101:ABCabc&/+*~' - - createArgoCD().install() - - def createMailSecretCommand = k8sCommands.assertExecuted('kubectl create secret generic argocd-notifications-secret -n argocd') - assertThat(createMailSecretCommand).contains('email-password', config.features.mail.smtpPassword as CharSequence) - } - - - @Test - void 'When external Mailserver is set without port, user, password'() { - config.features.mail.active = true - config.features.mail.smtpAddress = 'smtp.example.com' - - def argocd = createArgoCD() - argocd.install() - - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - - def serviceEmail = new YamlSlurper().parseText( - parseActualYaml(actualHelmValuesFile)['argo-cd']['notifications']['notifiers']['service.email'] as String) - - k8sCommands.assertNotExecuted('kubectl create secret generic argocd-notifications-secret') - - assertThat(serviceEmail['host']).isEqualTo("smtp.example.com") - assertThat(serviceEmail as Map).doesNotContainKey('port') - assertThat(serviceEmail as Map).doesNotContainKey('username') - assertThat(serviceEmail as Map).doesNotContainKey('password') - } - - @Test - void 'When external Mailserver is NOT set'() { - config.features.mail.active = true - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - def valuesYaml = parseActualYaml(actualHelmValuesFile) - - assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)['port']).isEqualTo(1025) - assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)) doesNotHaveToString('username') - assertThat(new YamlSlurper().parseText(valuesYaml['argo-cd']['notifications']['notifiers']['service.email'] as String)) doesNotHaveToString('password') - } - - @Test - void 'When vault disabled: Does not push path "secrets" to cluster resources'() { - config.features.secrets.active = false - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - assertThat(new File(clusterResourcesRepoLayout.vaultDir())).doesNotExist() - } - - @Test - void 'Prepares repos for air-gapped mode'() { - config.features.monitoring.active = false - config.application.mirrorRepos = true - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - - def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), "cluster-resources.yaml") - - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains( - 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack') - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain( - 'https://prometheus-community.github.io/helm-charts') - } - - - @Test - void 'Pushes repos with empty name-prefix'() { - def argocd = createArgoCD() - argocd.install() - this.clusterResourcesRepo = (argocd as ArgoCDForTest).clusterResourcesRepo - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - - assertArgoCdYamlPrefixes(clusterResourcesRepo.gitProvider.url, '', clusterResourcesRepoLayout) - } - - @Test - void 'Creates Jenkinsfiles for two registries'() { - config.registry.twoRegistries = true - createArgoCD().install() - - assertJenkinsfileRegistryCredentials() - } - - @Test - void 'Pushes repos with name-prefix'() { - config.application.namePrefix = 'abc-' - - def argocd = createArgoCD() - argocd.install() - this.clusterResourcesRepo = (argocd as ArgoCDForTest).clusterResourcesRepo - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - assertArgoCdYamlPrefixes(clusterResourcesRepo.gitProvider.url, config.application.namePrefix, clusterResourcesRepoLayout) - } - - @Test - void 'SecurityContext null in Openshift'() { - config.application.openshift = true - createArgoCD().install() - - for (def petclinicRepo : petClinicRepos) { - if (petclinicRepo.repoTarget.contains('argocd/petclinic-plain')) { - assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, '/k8s/staging/deployment.yaml').text).contains('runAsUser: null') - assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, '/k8s/staging/deployment.yaml').text).contains('runAsGroup: null') - } - if (petclinicRepo.repoTarget.contains('argocd/petclinic-helm')) { - assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, '/k8s/values-shared.yaml').text).contains('runAsUser: null') - assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, '/k8s/values-shared.yaml').text).contains('runAsGroup: null') - } - } - } - - @Test - void 'Skips CRDs for argo cd'() { - config.application.skipCrds = true - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - - assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['crds']['install']).isEqualTo(false) - } - - @Test - void 'Write maven mirror into jenkinsfiles'() { - config.jenkins.mavenCentralMirror = 'http://test' - createArgoCD().install() - - for (def petclinicRepo : petClinicRepos) { - assertThat(new File(petclinicRepo.absoluteLocalRepoTmpDir, 'Jenkinsfile').text).contains( - 'mvn.useMirrors([name: \'maven-central-mirror\', mirrorOf: \'central\', url: env.MAVEN_CENTRAL_MIRROR])' - ) - } - } - - @Test - void 'ArgoCD with active network policies'() { - config.application.netpols = true - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - this.actualHelmValuesFile = "${clusterResourcesRepoLayout.helmDir()}/values.yaml" - - assertThat(parseActualYaml(actualHelmValuesFile)['argo-cd']['global']['networkPolicy']['create']).isEqualTo(true) - assertThat(new File(clusterResourcesRepoLayout.argocdRoot(), '/argocd/values.yaml').text.contains("namespace: monitoring")) - assertThat(new File(clusterResourcesRepoLayout.argocdRoot(), '/argocd/templates/allow-namespaces.yaml').text.contains("namespace: monitoring")) - assertThat(new File(clusterResourcesRepoLayout.argocdRoot(), '/argocd/templates/allow-namespaces.yaml').text.contains("namespace: default")) - } - - private void assertArgoCdYamlPrefixes(String scmmUrl, String expectedPrefix, RepoLayout repoLayout) { - - assertAllYamlFiles(new File(repoLayout.argocdRoot()), 'projects', 3) { Path file -> - def yaml = parseActualYaml(file.toString()) - List sourceRepos = yaml['spec']['sourceRepos'] as List - // Some projects might not have sourceRepos - if (sourceRepos) { - sourceRepos.each { - if (it.startsWith(scmmUrl)) { - assertThat(it) - .as("$file sourceRepos have name prefix") - .startsWith("${scmmUrl}/repo/${expectedPrefix}argocd") - } - } - } - - String metadataNamespace = yaml['metadata']['namespace'] as String - if (metadataNamespace) { - assertThat(metadataNamespace) - .as("$file metadata.namespace has name prefix") - .isEqualTo("${expectedPrefix}argocd".toString()) - } - - List sourceNamespaces = yaml['spec']['sourceNamespaces'] as List - if (sourceNamespaces) { - sourceNamespaces.each { - if (it != '*') { - assertThat(it) - .as("$file spec.sourceNamespace has name prefix") - .startsWith("${expectedPrefix}") - } - } - } - } - - assertAllYamlFiles(new File(repoLayout.argocdRoot()), 'applications', 3) { Path file -> - def yaml = parseActualYaml(file.toString()) - assertThat(yaml['spec']['source']['repoURL'] as String) - .as("$file repoURL have name prefix") - .startsWith("${scmmUrl}/repo/${expectedPrefix}argocd") - - assertThat(yaml['metadata']['namespace']) - .as("$file metadata.namspace has name prefix") - .isEqualTo("${expectedPrefix}argocd".toString()) - - assertThat(yaml['spec']['destination']['namespace']) - .as("$file spec.destination.namspace has name prefix") - .isEqualTo("${expectedPrefix}argocd".toString()) - } - - //checks all other folder for prefixed yaml files except "apps/argocd" - assertAllYamlFiles(new File(repoLayout.rootDir()), 'apps', 9, - [ '/apps/argocd/' ]) { Path it -> - - def yaml = parseActualYaml(it.toString()) - List yamlDocuments = yaml instanceof List ? yaml : [yaml] - for (def document in yamlDocuments) { - if (document && document['kind'] != 'Namespace') { - def metadataNamespace = document['metadata']['namespace'] as String - assertThat(metadataNamespace) - .as("$it metadata.namespace has name prefix") - .startsWith("${expectedPrefix}") - } - } - } - } - - private static void assertAllYamlFiles( - File rootDir, - String childDir, - Integer numberOfFiles, - List excludeContains = [], - Closure cl - ) { - def rootPath = Path.of(rootDir.absolutePath, childDir) - - def yamlFiles = Files.walk(rootPath) - .filter { Files.isRegularFile(it) } - .filter { Path p -> - def s = p.toString().replace('\\', '/') - (s.endsWith('.yaml') || s.endsWith('.yml')) && - !excludeContains.any { ex -> s.contains(ex) } - } - .collect(Collectors.toList()) - - yamlFiles.each(cl) - - assertThat(yamlFiles.size()).isEqualTo(numberOfFiles) - } - - - private static List findFilesContaining(File folder, String stringToSearch) { - List result = [] - folder.eachFileRecurse(FileType.FILES) { - if (it.text.contains(stringToSearch)) { - result += it - } - } - return result - } - - ArgoCD createArgoCD() { - def argoCD = ArgoCDForTest.newWithAutoProviders(config, k8sCommands, helmCommands) - return argoCD - } - - void assertJenkinsfileRegistryCredentials() { - List defaultRegistryExpectedLines = [ - 'String pathPrefix = !dockerRegistryPath?.trim() ? "" : "${dockerRegistryPath}/"', - 'imageName = "${dockerRegistryBaseUrl}/${pathPrefix}${application}:${imageTag}"' - ] - List twoRegistriesExpectedLines = [ - 'String proxyPathPrefix = !dockerRegistryProxyPath?.trim() ? "" : "${dockerRegistryProxyPath}/"', - 'docker.withRegistry("https://${dockerRegistryProxyBaseUrl}/${proxyPathPrefix}", dockerRegistryProxyCredentials) {', - ] - - for (def petclinicRepo : petClinicRepos) { - String jenkinsfile = new File(petclinicRepo.absoluteLocalRepoTmpDir, 'Jenkinsfile').text - - defaultRegistryExpectedLines.each { expectedEnvVar -> - assertThat(jenkinsfile).contains(expectedEnvVar) - } - - if (config.registry['twoRegistries']) { - twoRegistriesExpectedLines.each { expectedEnvVar -> - assertThat(jenkinsfile).contains(expectedEnvVar) - } - } else { - twoRegistriesExpectedLines.each { expectedEnvVar -> - assertThat(jenkinsfile).doesNotContain(expectedEnvVar) - } - } - } - } - - @Test - void 'Prepares ArgoCD repo with Operator configuration file'() { - def argocd = setupOperatorTest() - - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) - def rbacConfigPath = Path.of(clusterResourcesRepoLayout.operatorRbacDir()) - - assertThat(argocdConfigPath.toFile()).exists() - assertThat(rbacConfigPath.toFile()).exists() - - def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) - assertThat(yaml['apiVersion']).isEqualTo('argoproj.io/v1beta1') - assertThat(yaml['kind']).isEqualTo('ArgoCD') - } - - @Test - void 'No files for operator when operator is false'() { - def argocd = createArgoCD() - - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) - def rbacConfigPath = Path.of(clusterResourcesRepoLayout.operatorRbacDir()) - - assertThat(argocdConfigPath.toFile()).doesNotExist() - assertThat(rbacConfigPath.toFile()).doesNotExist() - } - - @Test - void 'Deploys with operator without OpenShift configuration'() { - def argocd = setupOperatorTest(openshift: false) - - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) - - k8sCommands.assertExecuted("kubectl apply -f ${argocdConfigPath}") - - def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) - assertThat(yaml['spec']['rbac']).isNull() - assertThat(yaml['spec']['sso']).isNull() - - def argocdYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.applicationsDir(), 'argocd.yaml') - assertThat(argocdYaml['spec']['source']['directory']['recurse'] as Boolean).isTrue() - assertThat(argocdYaml['spec']['source']['path']).isEqualTo('apps/argocd/operator/') - // Here we should assert all <#if argocd.isOperator> in YAML 😐️ - } - - @Test - void 'RBACs with operator using RbacDefinition outputs'() { - config.application.namePrefix = "testPrefix-" - - LinkedHashSet expectedNamespaces = [ - "testPrefix-monitoring", - "testPrefix-secrets", - "testPrefix-traefik", - "testPrefix-example-apps-staging", - "testPrefix-example-apps-production" - ] - // have to prepare activeNamespaces for unit-test, Application.groovy is setting this in integration way - config.application.namespaces.dedicatedNamespaces = new LinkedHashSet([ - "monitoring", - "secrets", - "traefik", - "example-apps-staging", - "example-apps-production" - ]) - - def argocd = setupOperatorTest(openshift: false) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - File rbacPath = Path.of(clusterResourcesRepoLayout.operatorRbacDir()).toFile() + expectedNamespaces.each { String ns -> + File roleFile = new File(rbacPath, "role-argocd-${ns}.yaml") + File bindingFile = new File(rbacPath, "rolebinding-argocd-${ns}.yaml") + + assertThat(roleFile).exists() + assertThat(bindingFile).exists() - expectedNamespaces.each { String ns -> - File roleFile = new File(rbacPath, "role-argocd-${ns}.yaml") - File bindingFile = new File(rbacPath, "rolebinding-argocd-${ns}.yaml") + Map roleYaml = new YamlSlurper().parse(roleFile) as Map + Map bindingYaml = new YamlSlurper().parse(bindingFile) as Map - assertThat(roleFile).exists() - assertThat(bindingFile).exists() + assertThat(roleYaml["kind"]).isEqualTo("Role") + assertThat(roleYaml["metadata"]["name"]).isEqualTo("argocd") + assertThat(roleYaml["metadata"]["namespace"]).isEqualTo(ns) - Map roleYaml = new YamlSlurper().parse(roleFile) as Map - Map bindingYaml = new YamlSlurper().parse(bindingFile) as Map + assertThat(bindingYaml["kind"]).isEqualTo("RoleBinding") + assertThat(bindingYaml["metadata"]["name"]).isEqualTo("argocd") + assertThat(bindingYaml["metadata"]["namespace"]).isEqualTo(ns) - assertThat(roleYaml["kind"]).isEqualTo("Role") - assertThat(roleYaml["metadata"]["name"]).isEqualTo("argocd") - assertThat(roleYaml["metadata"]["namespace"]).isEqualTo(ns) + List> subjects = bindingYaml["subjects"] as List> + assertThat(subjects).isNotEmpty() + assertThat(subjects*.kind).containsOnly("ServiceAccount") + assertThat(subjects*.namespace).containsOnly("testPrefix-argocd") + assertThat(subjects*.name).containsExactlyInAnyOrder("argocd-argocd-server", + "argocd-argocd-application-controller", + "argocd-applicationset-controller") - assertThat(bindingYaml["kind"]).isEqualTo("RoleBinding") - assertThat(bindingYaml["metadata"]["name"]).isEqualTo("argocd") - assertThat(bindingYaml["metadata"]["namespace"]).isEqualTo(ns) + Map roleRef = bindingYaml["roleRef"] as Map + assertThat(roleRef).isNotNull() + assertThat(roleRef["name"]).isEqualTo("argocd") + assertThat(roleRef["kind"]).isEqualTo("Role") + } + } - List> subjects = bindingYaml["subjects"] as List> - assertThat(subjects).isNotEmpty() - assertThat(subjects*.kind).containsOnly("ServiceAccount") - assertThat(subjects*.namespace).containsOnly("testPrefix-argocd") - assertThat(subjects*.name).containsExactlyInAnyOrder( - "argocd-argocd-server", - "argocd-argocd-application-controller", - "argocd-applicationset-controller" - ) + @Test + void 'Deploys with operator with OpenShift configuration'() { + def argocd = setupOperatorTest(openshift: true) - Map roleRef = bindingYaml["roleRef"] as Map - assertThat(roleRef).isNotNull() - assertThat(roleRef["name"]).isEqualTo("argocd") - assertThat(roleRef["kind"]).isEqualTo("Role") - } - } + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) + k8sCommands.assertExecuted("kubectl apply -f ${argocdConfigPath}") - @Test - void 'Deploys with operator with OpenShift configuration'() { - def argocd = setupOperatorTest(openshift: true) + def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) + assertThat(yaml['spec']['sso']).isNotNull() + assertThat(yaml['spec']['sso']['dex']['openShiftOAuth']).isEqualTo(true) + assertThat(yaml['spec']['sso']['provider']).isEqualTo('dex') + assertThat(yaml['spec']['rbac']).isNotNull() + assertThat(yaml['spec']['server']['route']['enabled']).isEqualTo(true) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + k8sCommands.assertNotExecuted("kubectl patch service argocd-server -n argocd") + } - def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) - k8sCommands.assertExecuted("kubectl apply -f ${argocdConfigPath}") + @Test + void 'check if external_secrets_io and monitoring_coreos_com is set'() { - def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) - assertThat(yaml['spec']['sso']).isNotNull() - assertThat(yaml['spec']['sso']['dex']['openShiftOAuth']).isEqualTo(true) - assertThat(yaml['spec']['sso']['provider']).isEqualTo('dex') - assertThat(yaml['spec']['rbac']).isNotNull() - assertThat(yaml['spec']['server']['route']['enabled']).isEqualTo(true) + config.features.monitoring.active = true + config.features.secrets.active = true - k8sCommands.assertNotExecuted("kubectl patch service argocd-server -n argocd") - } + String expectedMonitoring = 'monitoring.coreos.com' + String expectedExternalSecret = 'external-secrets.io' - @Test - void 'check if external_secrets_io and monitoring_coreos_com is set'() { + def argocd = setupOperatorTest(openshift: true) + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - config.features.monitoring.active = true - config.features.secrets.active = true + def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) - String expectedMonitoring = 'monitoring.coreos.com' - String expectedExternalSecret = 'external-secrets.io' - - def argocd = setupOperatorTest(openshift: true) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) + def resourceInclusionsString = yaml['spec']['resourceInclusions'] as String - def resourceInclusionsString = yaml['spec']['resourceInclusions'] as String + assertThat(resourceInclusionsString.contains(expectedMonitoring)).isTrue() + assertThat(resourceInclusionsString.contains(expectedExternalSecret)).isTrue() + } - assertThat(resourceInclusionsString.contains(expectedMonitoring)).isTrue() - assertThat(resourceInclusionsString.contains(expectedExternalSecret)).isTrue() - } + @Test + void 'check if external_secrets_io and monitoring_coreos_com is not set'() { - @Test - void 'check if external_secrets_io and monitoring_coreos_com is not set'() { + config.features.monitoring.active = false + config.features.secrets.active = false - config.features.monitoring.active = false - config.features.secrets.active = false + String expectedMonitoring = 'monitoring.coreos.com' + String expectedExternalSecret = 'external-secrets.io' - String expectedMonitoring = 'monitoring.coreos.com' - String expectedExternalSecret = 'external-secrets.io' + def argocd = setupOperatorTest(openshift: true) + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - def argocd = setupOperatorTest(openshift: true) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) - def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) + def resourceInclusionsString = yaml['spec']['resourceInclusions'] as String - def resourceInclusionsString = yaml['spec']['resourceInclusions'] as String + assertThat(resourceInclusionsString.contains(expectedMonitoring)).isFalse() + assertThat(resourceInclusionsString.contains(expectedExternalSecret)).isFalse() + } - assertThat(resourceInclusionsString.contains(expectedMonitoring)).isFalse() - assertThat(resourceInclusionsString.contains(expectedExternalSecret)).isFalse() - } + @Test + void 'Correctly sets resourceInclusions from config'() { + def argocd = setupOperatorTest() + // Set the config to a custom resourceInclusionsCluster value + config.features.argocd.resourceInclusionsCluster = 'https://192.168.0.1:6443' - @Test - void 'Correctly sets resourceInclusions from config'() { - def argocd = setupOperatorTest() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - // Set the config to a custom resourceInclusionsCluster value - config.features.argocd.resourceInclusionsCluster = 'https://192.168.0.1:6443' + def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) + def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + def expectedClusterUrl = 'https://192.168.0.1:6443' - def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) - def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) + // Retrieve and parse the resourceInclusions string into structured YAML + def resourceInclusionsString = yaml['spec']['resourceInclusions'] as String + def parsedResourceInclusions = new YamlSlurper().parseText(resourceInclusionsString) - def expectedClusterUrl = 'https://192.168.0.1:6443' + // Iterate over the parsed resource inclusions and check the 'clusters' field + parsedResourceInclusions.each { resource -> + assertThat(resource as Map).containsKey('clusters') + assertThat(resource['clusters'] as List).contains(expectedClusterUrl) + } + } - // Retrieve and parse the resourceInclusions string into structured YAML - def resourceInclusionsString = yaml['spec']['resourceInclusions'] as String - def parsedResourceInclusions = new YamlSlurper().parseText(resourceInclusionsString) + @Test + void 'resourceInclusionsCluster from config file trumps ENVs'() { + def argocd = setupOperatorTest() - // Iterate over the parsed resource inclusions and check the 'clusters' field - parsedResourceInclusions.each { resource -> - assertThat(resource as Map).containsKey('clusters') - assertThat(resource['clusters'] as List).contains(expectedClusterUrl) - } - } + // Set the config to a custom internalKubernetesApiUrl value + config.application.internalKubernetesApiUrl = 'https://192.168.0.1:6443' - @Test - void 'resourceInclusionsCluster from config file trumps ENVs'() { - def argocd = setupOperatorTest() + // Set environment variables for Kubernetes API server + withEnvironmentVariable("KUBERNETES_SERVICE_HOST", "100.125.0.1") + .and("KUBERNETES_SERVICE_PORT", "443") + .execute { + argocd.install() + } - // Set the config to a custom internalKubernetesApiUrl value - config.application.internalKubernetesApiUrl = 'https://192.168.0.1:6443' + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - // Set environment variables for Kubernetes API server - withEnvironmentVariable("KUBERNETES_SERVICE_HOST", "100.125.0.1") - .and("KUBERNETES_SERVICE_PORT", "443") - .execute { - argocd.install() - } + def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) + def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) + def expectedClusterUrlFromConfig = "https://192.168.0.1:6443" - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + // Retrieve and parse the resourceInclusions string into structured YAML + def resourceInclusionsString = yaml['spec']['resourceInclusions'] as String + def parsedResourceInclusions = new YamlSlurper().parseText(resourceInclusionsString) - def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) - def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) - def expectedClusterUrlFromConfig = "https://192.168.0.1:6443" + // Ensure that the clusters field uses the config value, not the env variables + parsedResourceInclusions.each { resource -> + assertThat(resource as Map).containsKey('clusters') + assertThat(resource['clusters'] as List).contains(expectedClusterUrlFromConfig) + // Make sure the environment variable value does not appear + assertThat(resource['clusters'] as List).doesNotContain("https://100.125.0.1:443") + } + } - // Retrieve and parse the resourceInclusions string into structured YAML - def resourceInclusionsString = yaml['spec']['resourceInclusions'] as String - def parsedResourceInclusions = new YamlSlurper().parseText(resourceInclusionsString) + @Test + void 'Sets env variables in ArgoCD components when provided'() { + def argocd = setupOperatorTest() - // Ensure that the clusters field uses the config value, not the env variables - parsedResourceInclusions.each { resource -> - assertThat(resource as Map).containsKey('clusters') - assertThat(resource['clusters'] as List).contains(expectedClusterUrlFromConfig) - // Make sure the environment variable value does not appear - assertThat(resource['clusters'] as List).doesNotContain("https://100.125.0.1:443") - } - } + // Set environment variables for ArgoCD + config.features.argocd.env = [[name: "ENV_VAR_1", value: "value1"], + [name: "ENV_VAR_2", value: "value2"]] as List - @Test - void 'Sets env variables in ArgoCD components when provided'() { - def argocd = setupOperatorTest() - - // Set environment variables for ArgoCD - config.features.argocd.env = [ - [name: "ENV_VAR_1", value: "value1"], - [name: "ENV_VAR_2", value: "value2"] - ] as List - - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) - def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) - - def expectedEnv = [ - [name: "ENV_VAR_1", value: "value1"], - [name: "ENV_VAR_2", value: "value2"] - ] - - // Check that the env variables are added to the relevant components - assertThat(yaml['spec']['applicationSet']['env']).isEqualTo(expectedEnv) - assertThat(yaml['spec']['notifications']['env']).isEqualTo(expectedEnv) - assertThat(yaml['spec']['controller']['env']).isEqualTo(expectedEnv) - assertThat(yaml['spec']['repo']['env']).isEqualTo(expectedEnv) - assertThat(yaml['spec']['server']['env']).isEqualTo(expectedEnv) - } - - @Test - void 'Does not set env variables when none are provided'() { - def argocd = setupOperatorTest() - - // Ensure env is an empty list (default) - config.features.argocd.env = [] - - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) - def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) - - // Check that the env variables are not present - assertThat(yaml['spec']['applicationSet'] as Map).doesNotContainKey('env') - assertThat(yaml['spec']['notifications'] as Map).doesNotContainKey('env') - assertThat(yaml['spec']['controller'] as Map).doesNotContainKey('env') - assertThat(yaml['spec']['redis'] as Map).doesNotContainKey('env') - assertThat(yaml['spec']['repo'] as Map).doesNotContainKey('env') - assertThat(yaml['spec']['server'] as Map).doesNotContainKey('env') - } - - @Test - void 'Sets single env variable in ArgoCD components when provided'() { - def argocd = setupOperatorTest() - - // Set a single environment variable for ArgoCD - config.features.argocd.env = [ - [name: "ENV_VAR_SINGLE", value: "singleValue"] - ] as List - - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) - def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) - - def expectedEnv = [ - [name: "ENV_VAR_SINGLE", value: "singleValue"] - ] - - // Check that the single env variable is added to the relevant components - assertThat(yaml['spec']['applicationSet']['env']).isEqualTo(expectedEnv) - assertThat(yaml['spec']['notifications']['env']).isEqualTo(expectedEnv) - assertThat(yaml['spec']['controller']['env']).isEqualTo(expectedEnv) - assertThat(yaml['spec']['server']['env']).isEqualTo(expectedEnv) - } - - @Test - void 'Creates all necessary namespaces'() { - def argoCD = createArgoCD() - simulateNamespaceCreation() - argoCD.install() - - config.application.namespaces.getActiveNamespaces().each { namespace -> - k8sCommands.assertExecuted("kubectl create namespace ${namespace}") - } - } - - @Test - void 'Operator config sets server insecure to true when insecure is set'() { - config.application.insecure = true - def argocd = setupOperatorTest() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) - assertThat(yaml['spec']['server']['insecure']).isEqualTo(true) - } - - @Test - void 'Operator config sets custom values'() { - config.features.argocd.values = [key: 'value'] - config.features.argocd.values = [spec: [key: 'value']] - def argocd = setupOperatorTest() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) - assertThat(yaml['spec']['key']).isEqualTo('value') - } - - @Test - void 'Operator config sets server_insecure to false when insecure is not set'() { - def argocd = setupOperatorTest() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) - assertThat(yaml['spec']['server']['insecure']).isEqualTo(false) - } - - @Test - void 'Generates correct ingress yaml with expected host when insecure is true and not on OpenShift'() { - config.application.insecure = true - config.features.argocd.url = "http://argocd.localhost" - def argocd = setupOperatorTest(openshift: false) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") - assertThat(ingressFile) - .as("Ingress file should be generated for insecure mode on non-OpenShift") - .exists() - - def ingressYaml = parseActualYaml(ingressFile.toString()) - - def rules = ingressYaml['spec']['rules'] as List - def host = rules[0]['host'] - assertThat(host) - .as("Ingress host should match configured ArgoCD hostname") - .isEqualTo(new URL(config.features.argocd.url).host) - } - - @Test - void 'Does not generate ingress yaml when insecure is false'() { - config.application.insecure = false - def argocd = setupOperatorTest(openshift: false) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") - assertThat(ingressFile) - .as("Ingress file should not be generated when insecure is false") - .doesNotExist() - } - - @Test - void 'Does not generate ingress yaml when running on OpenShift'() { - config.application.insecure = true - def argocd = setupOperatorTest(openshift: true) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") - assertThat(ingressFile) - .as("Ingress file should not be generated on OpenShift") - .doesNotExist() - } - - @Test - void 'Does not generate ingress yaml when insecure is false and OpenShift is true'() { - config.application.insecure = false - def argocd = setupOperatorTest(openshift: true) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") - assertThat(ingressFile) - .as("Ingress file should not be generated when both flags are false") - .doesNotExist() - } - - @Test - void 'Central Bootstrapping for Tenant Applications'() { - setupDedicatedInstanceMode() - - def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") - assertThat(ingressFile) - .as("Ingress file should not be generated when both flags are false") - .doesNotExist() - } - - @Test - void 'GOP DedicatedInstances Central templating works correctly'() { - setupDedicatedInstanceMode() - //Central Applications - assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/applications/argocd.yaml")).exists() - assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/applications/bootstrap.yaml")).exists() - assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/applications/projects.yaml")).exists() - assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/applications/example-apps.yaml")).doesNotExist() - - def argocdYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.argocdRoot(), "/applications/argocd.yaml") - def bootstrapYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.argocdRoot(), "/applications/bootstrap.yaml") - def projectsYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.argocdRoot(), "/applications/projects.yaml") - - assertThat(argocdYaml['metadata']['name']).isEqualTo('testPrefix-argocd') - assertThat(argocdYaml['metadata']['namespace']).isEqualTo('argocd') - assertThat(argocdYaml['spec']['project']).isEqualTo('testPrefix') - assertThat(argocdYaml['spec']['source']['path']).isEqualTo('apps/argocd/operator/') - - assertThat(bootstrapYaml['metadata']['name']).isEqualTo('testPrefix-bootstrap') - assertThat(bootstrapYaml['metadata']['namespace']).isEqualTo('argocd') - assertThat(bootstrapYaml['spec']['project']).isEqualTo('testPrefix') - assertThat(bootstrapYaml['spec']['source']['repoURL']).isEqualTo("scmm.testhost/scm/repo/testPrefix-argocd/cluster-resources.git") - - assertThat(projectsYaml['metadata']['name']).isEqualTo('testPrefix-projects') - assertThat(projectsYaml['metadata']['namespace']).isEqualTo('argocd') - assertThat(projectsYaml['spec']['project']).isEqualTo('testPrefix') - - //Central Project - assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/projects/tenant.yaml")).exists() - - def tenantProject = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.argocdRoot(), "/projects/tenant.yaml") - - assertThat(tenantProject['metadata']['name']).isEqualTo('testPrefix') - assertThat(tenantProject['metadata']['namespace']).isEqualTo('argocd') - def sourceRepos = (List) tenantProject['spec']['sourceRepos'] - assertThat(sourceRepos[0]).isEqualTo('scmm.testhost/scm/repo/testPrefix-argocd/cluster-resources.git') - } - - @Test - void 'Append namespaces to Argocd argocd-default-cluster-config secrets'() { - config.application.namespaces.dedicatedNamespaces = new LinkedHashSet(['dedi-test1', 'dedi-test2', 'dedi-test3']) - config.application.namespaces.tenantNamespaces = new LinkedHashSet(['tenant-test1', 'tenant-test2', 'tenant-test3']) - setupDedicatedInstanceMode() - k8sCommands.assertExecuted('kubectl get secret argocd-default-cluster-config -n argocd -ojsonpath={.data.namespaces}') - k8sCommands.assertExecuted('kubectl patch secret argocd-default-cluster-config -n argocd --patch-file=/tmp/gitops-playground-patch-') - } - - @Test - void 'multiTenant folder gets deleted correctly if not in dedicated mode'() { - config.multiTenant.useDedicatedInstance = false - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'multiTenant/')).doesNotExist() - assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'applications/')).exists() - assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'projects/')).exists() - } - - @Test - void 'deleting unused folder in dedicated mode'() { - config.multiTenant.useDedicatedInstance = true - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'multiTenant/')).doesNotExist() - assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'applications/')).exists() - assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'projects/')).exists() - } - - @Test - void 'RBACs generated correctly'() { - config.application.namespaces.tenantNamespaces = new LinkedHashSet(['testprefix-tenant-test1', 'testprefix-tenant-test2', 'testprefix-tenant-test3']) - setupDedicatedInstanceMode() - - File rbacFolder = new File(clusterResourcesRepoLayout.operatorRbacDir()) - File rbacTenantFolder = new File(clusterResourcesRepoLayout.operatorRbacDir() + "/tenant") - assertThat(rbacFolder).exists() - assertThat(rbacTenantFolder).exists() - - assertThat(rbacFolder.listFiles().count { it.isFile() }).isEqualTo(14) - assertThat(rbacTenantFolder.listFiles().count { it.isFile() }).isEqualTo(6) - - rbacFolder.eachFile { file -> - if (file.name.startsWith("role-") && file.name.contains('dedi')) { - def rbacFile = new YamlSlurper().parse(Path.of file.path) - assertThat(rbacFile['metadata']['namespace']).isIn(config.application.namespaces.getActiveNamespaces()) - } - if (file.name.startsWith("rolebinding-") && file.name.contains('dedi')) { - def rbacFile = new YamlSlurper().parse(Path.of file.path) - assertThat(rbacFile['subjects']['namespace']).isEqualTo(["argocd", "argocd", "argocd"]) - } - } - - rbacTenantFolder.eachFile { file -> - if (file.name.startsWith("role-")) { - def rbacFile = new YamlSlurper().parse(Path.of file.path) - assertThat(rbacFile['metadata']['namespace']).isIn(config.application.namespaces.tenantNamespaces) - } - - if (file.name.startsWith("rolebinding-")) { - def rbacFile = new YamlSlurper().parse(Path.of file.path) - assertThat(rbacFile['subjects']['namespace']).isEqualTo(["testPrefix-argocd", "testPrefix-argocd", "testPrefix-argocd"]) - } - } - - } - - @Test - void 'Operator RBAC includes node access rules when not on OpenShift'() { - config.application.namePrefix = "testprefix-" - - def argocd = setupOperatorTest(openshift: false) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - print config.toMap() - - File rbacDir = Path.of(clusterResourcesRepoLayout.operatorRbacDir()).toFile() - File roleFile = new File(rbacDir, "role-argocd-testprefix-monitoring.yaml") - - Map yaml = new YamlSlurper().parse(roleFile) as Map - List> rules = yaml["rules"] as List> - - assertThat(rules).anyMatch { rule -> - List resources = rule["resources"] as List - resources.contains("nodes") && resources.contains("nodes/metrics") - } - } - - @Test - void 'Operator RBAC does not include node access rules when on OpenShift'() { - config.application.namePrefix = "testprefix-" - - def argocd = setupOperatorTest(openshift: true) - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - File rbacDir = Path.of(clusterResourcesRepoLayout.operatorRbacDir()).toFile() - File roleFile = new File(rbacDir, "role-argocd-testprefix-monitoring.yaml") - println roleFile - - Map yaml = new YamlSlurper().parse(roleFile) as Map - List> rules = yaml["rules"] as List> - - assertThat(rules).noneMatch { rule -> - List resources = rule["resources"] as List - resources.contains("nodes") && resources.contains("nodes/metrics") - } - } - - @Test - void 'If not using mirror, ensure source repos in cluster-resources got right URL'() { - config.application.mirrorRepos = false - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') - clusterRessourcesYaml['spec']['sourceRepos'] - - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains( - 'https://charts.external-secrets.io', - 'https://codecentric.github.io/helm-charts', - 'https://prometheus-community.github.io/helm-charts', - 'https://traefik.github.io/charts', - 'https://helm.releases.hashicorp.com', - 'https://charts.jetstack.io' - ) - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain( - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/traefik', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/external-secrets', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/vault', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/cert-manager' - ) - - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain( - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/kube-prometheus-stack.git', - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/traefik.git', - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/external-secrets.git', - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/vault.git', - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/cert-manager.git' - ) - } - - @Test - void 'If using mirror, ensure source repos in cluster-resources got right URL'() { - config.application.mirrorRepos = true - - def argocd = createArgoCD() - argocd.install() - - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') - clusterRessourcesYaml['spec']['sourceRepos'] - - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains( - 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack', - 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/traefik', - 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/external-secrets', - 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/vault', - 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/cert-manager' - - ) - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain( - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/kube-prometheus-stack.git', - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/traefik.git', - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/external-secrets.git', - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/vault.git', - 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/cert-manager.git' - ) - } - - @Test - void 'If using mirror with GitLab, ensure source repos in cluster-resources got right URL'() { - config.application.mirrorRepos = true - config.scm.scmProviderType = 'GITLAB' - config.scm.gitlab.url = 'https://testGitLab.com/testgroup' - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') - clusterRessourcesYaml['spec']['sourceRepos'] - - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains( - 'https://testGitLab.com/testgroup/3rd-party-dependencies/kube-prometheus-stack.git', - 'https://testGitLab.com/testgroup/3rd-party-dependencies/traefik.git', - 'https://testGitLab.com/testgroup/3rd-party-dependencies/external-secrets.git', - 'https://testGitLab.com/testgroup/3rd-party-dependencies/vault.git', - 'https://testGitLab.com/testgroup/3rd-party-dependencies/cert-manager.git' - ) - } - - @Test - void 'If using mirror with GitLab with prefix, ensure source repos in cluster-resources got right URL'() { - config.application.mirrorRepos = true - config.scm.scmProviderType = 'GITLAB' - config.scm.gitlab.url = "https://testGitLab.com/testgroup" - config.application.namePrefix = 'test1-' - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') - clusterRessourcesYaml['spec']['sourceRepos'] - - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains( - 'https://testGitLab.com/testgroup/3rd-party-dependencies/kube-prometheus-stack.git', - 'https://testGitLab.com/testgroup/3rd-party-dependencies/traefik.git', - 'https://testGitLab.com/testgroup/3rd-party-dependencies/external-secrets.git', - 'https://testGitLab.com/testgroup/3rd-party-dependencies/vault.git', - 'https://testGitLab.com/testgroup/3rd-party-dependencies/cert-manager.git' - ) - - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain( - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/traefik', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/external-secrets', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/vault', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/cert-manager' - ) - } - - @Test - void 'If using mirror with name-prefix, ensure source repos in cluster-resources got right URL'() { - config.application.mirrorRepos = true - config.application.namePrefix = 'test1-' - - def argocd = createArgoCD() - argocd.install() - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') - clusterRessourcesYaml['spec']['sourceRepos'] - - - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains( - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/traefik', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/external-secrets', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/vault', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/cert-manager' - ) - - assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain( - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/kube-prometheus-stack.git', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/traefik.git', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/external-secrets.git', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/vault.git', - 'http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/cert-manager.git' - ) - } - - void setupDedicatedInstanceMode() { - config.application.namePrefix = 'testPrefix-' - config.multiTenant.scmManager.url = 'scmm.testhost/scm' - config.multiTenant.scmManager.username = 'testUserName' - config.multiTenant.scmManager.password = 'testPassword' - config.multiTenant.useDedicatedInstance = true - this.argocd = setupOperatorTest() - argocd.install() - this.clusterResourcesRepo = (argocd as ArgoCDForTest).clusterResourcesRepo - clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() - - } - - protected ArgoCD setupOperatorTest(Map options = [:]) { - config.features.argocd.operator = true - config.features.argocd.resourceInclusionsCluster = 'https://192.168.0.1:6443' - config.application.openshift = options.openshift ?: false - - def argoCD = createArgoCD() - - if (config.multiTenant.useDedicatedInstance) { - config.content.repos ? setupMockResponsesFor(MockReponses.MULTI_TENANT_WITH_EXAMPLES) : setupMockResponsesFor(MockReponses.MULTI_TENANT) - } else { - setupMockResponsesFor(MockReponses.SINGLE_TENANT) - } - - return argoCD - } - - enum MockReponses { - SINGLE_TENANT, - MULTI_TENANT, - MULTI_TENANT_WITH_EXAMPLES - } - - //Mock Responses for Testing - void setupMockResponsesFor(MockReponses mockReponses) { - switch (mockReponses) { - case MockReponses.SINGLE_TENANT -> { - k8sCommands.enqueueOutputs([ - queueUpAllNamespacesExist(), - new CommandExecutor.Output('', '', 0), // Monitoring CRDs applied - new CommandExecutor.Output('', '', 0), // ArgoCD Secret applied - new CommandExecutor.Output('', '', 0), // Labeling ArgoCD Secret - new CommandExecutor.Output('', '', 0), // ArgoCD operator YAML applied - new CommandExecutor.Output('', 'Available', 0), // ArgoCD resource reached desired phase - ].flatten() as Queue) - } - case MockReponses.MULTI_TENANT_WITH_EXAMPLES -> mockReponseMultiTenant() - case MockReponses.MULTI_TENANT -> mockReponseMultiTenant() - } - } - - void mockReponseMultiTenant() { - k8sCommands.enqueueOutputs([ - queueUpAllNamespacesExist(), - new CommandExecutor.Output('', '', 0), // Monitoring CRDs applied - - new CommandExecutor.Output('', '', 0), // ArgoCD SCM Secret applied - new CommandExecutor.Output('', '', 0), // Labeling ArgoCD SCM Secret - new CommandExecutor.Output('', '', 0), // ArgoCD SCM central Secret applied - new CommandExecutor.Output('', '', 0), // Labeling ArgoCD central SCM Secret - - new CommandExecutor.Output('', '', 0), // ArgoCD operator YAML applied - new CommandExecutor.Output('', 'Available', 0), // ArgoCD resource reached desired phase - - new CommandExecutor.Output('', '', 0), // ArgoCD argocd-cluster password secret - new CommandExecutor.Output('', '', 0), // ArgoCD argocd-secret - - new CommandExecutor.Output('', '', 0), // argocd-default-cluster-config patched - new CommandExecutor.Output('', '', 0), // ArgoCD argocd-secret - new CommandExecutor.Output('', 'dGVzdG5hbWVzcGFjZTEsdGVzdG5hbWVzcGFjZTI=', 0), // getting argocd-default-cluster-config from central Argocd - new CommandExecutor.Output('', '', 0), // setting argocd-default-cluster-config from central Argocd - ].flatten() as Queue) - } - - private void simulateNamespaceCreation() { - Queue outputs = new LinkedList() - config.application.namespaces.getActiveNamespaces().each { namespace -> - outputs.add(new CommandExecutor.Output("${namespace} not found", "", 1)) - outputs.add(new CommandExecutor.Output("${namespace} created", "", 0)) - } - k8sCommands.enqueueOutputs(outputs) - } - - private Queue queueUpAllNamespacesExist() { - return new LinkedList( - config.application.namespaces.getActiveNamespaces().collect { namespace -> new CommandExecutor.Output(namespace, "", 0) } - ) - } - - private static void mockPrefixActiveNamespaces(Config config) { - def prefix = config.application.namePrefix ?: "" - - config.application.namespaces.with { - dedicatedNamespaces = new LinkedHashSet<>( - dedicatedNamespaces.collect { (prefix + it).toString() } - ) - tenantNamespaces = new LinkedHashSet<>( - tenantNamespaces.collect { (prefix + it).toString() } - ) - } - } - - static class ArgoCDForTest extends ArgoCD { - final Config cfg - final GitProvider tenantProvider - final GitProvider centralProvider - - static ArgoCDForTest newWithAutoProviders(Config cfg, - CommandExecutorForTest k8sCommands, - CommandExecutorForTest helmCommands) { - def provider = TestGitProvider.buildProviders(cfg) - return new ArgoCDForTest( - cfg, - k8sCommands, - helmCommands, - provider.tenant as GitProvider, - provider.central as GitProvider - ) - } - - ArgoCDForTest(Config cfg, - CommandExecutorForTest k8sCommands, - CommandExecutorForTest helmCommands, - GitProvider tenantProvider, - GitProvider centralProvider) { - super( - cfg, - new K8sClientForTest(cfg, k8sCommands), - new HelmClient(helmCommands), - new FileSystemUtils(), - new TestGitRepoFactory(cfg, new FileSystemUtils()), - new GitHandlerForTests(cfg, tenantProvider, centralProvider) - ) - this.cfg = cfg - this.tenantProvider = tenantProvider - this.centralProvider = centralProvider - mockPrefixActiveNamespaces(cfg) - } - - GitRepo getClusterResourcesRepo() { - return getRepoSetup().clusterResources?.repo - } - - RepoLayout getClusterRepoLayout() { - return getRepoSetup().clusterRepoLayout() - } - - } - - private Map parseActualYaml(String pathToYamlFile) { - File yamlFile = new File(pathToYamlFile) - def ys = new YamlSlurper() - return ys.parse(yamlFile) as Map - } - -} + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) + def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) + + def expectedEnv = [[name: "ENV_VAR_1", value: "value1"], + [name: "ENV_VAR_2", value: "value2"]] + + // Check that the env variables are added to the relevant components + assertThat(yaml['spec']['applicationSet']['env']).isEqualTo(expectedEnv) + assertThat(yaml['spec']['notifications']['env']).isEqualTo(expectedEnv) + assertThat(yaml['spec']['controller']['env']).isEqualTo(expectedEnv) + assertThat(yaml['spec']['repo']['env']).isEqualTo(expectedEnv) + assertThat(yaml['spec']['server']['env']).isEqualTo(expectedEnv) + } + + @Test + void 'Does not set env variables when none are provided'() { + def argocd = setupOperatorTest() + + // Ensure env is an empty list (default) + config.features.argocd.env = [] + + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) + def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) + + // Check that the env variables are not present + assertThat(yaml['spec']['applicationSet'] as Map).doesNotContainKey('env') + assertThat(yaml['spec']['notifications'] as Map).doesNotContainKey('env') + assertThat(yaml['spec']['controller'] as Map).doesNotContainKey('env') + assertThat(yaml['spec']['redis'] as Map).doesNotContainKey('env') + assertThat(yaml['spec']['repo'] as Map).doesNotContainKey('env') + assertThat(yaml['spec']['server'] as Map).doesNotContainKey('env') + } + + @Test + void 'Sets single env variable in ArgoCD components when provided'() { + def argocd = setupOperatorTest() + + // Set a single environment variable for ArgoCD + config.features.argocd.env = [[name: "ENV_VAR_SINGLE", value: "singleValue"]] as List + + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def argocdConfigPath = Path.of(clusterResourcesRepoLayout.operatorConfigFile()) + def yaml = parseActualYaml(argocdConfigPath.toFile().toString()) + + def expectedEnv = [[name: "ENV_VAR_SINGLE", value: "singleValue"]] + + // Check that the single env variable is added to the relevant components + assertThat(yaml['spec']['applicationSet']['env']).isEqualTo(expectedEnv) + assertThat(yaml['spec']['notifications']['env']).isEqualTo(expectedEnv) + assertThat(yaml['spec']['controller']['env']).isEqualTo(expectedEnv) + assertThat(yaml['spec']['server']['env']).isEqualTo(expectedEnv) + } + + @Test + void 'Creates all necessary namespaces'() { + def argoCD = createArgoCD() + simulateNamespaceCreation() + argoCD.install() + + config.application.namespaces.getActiveNamespaces().each { namespace -> k8sCommands.assertExecuted("kubectl create namespace ${namespace}") + } + } + + @Test + void 'Operator config sets server insecure to true when insecure is set'() { + config.application.insecure = true + def argocd = setupOperatorTest() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) + assertThat(yaml['spec']['server']['insecure']).isEqualTo(true) + } + + @Test + void 'Operator config sets custom values'() { + config.features.argocd.values = [key: 'value'] + config.features.argocd.values = [spec: [key: 'value']] + def argocd = setupOperatorTest() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) + assertThat(yaml['spec']['key']).isEqualTo('value') + } + + @Test + void 'Operator config sets server_insecure to false when insecure is not set'() { + def argocd = setupOperatorTest() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def yaml = parseActualYaml(Path.of(clusterResourcesRepoLayout.operatorConfigFile()).toString()) + assertThat(yaml['spec']['server']['insecure']).isEqualTo(false) + } + + @Test + void 'Generates correct ingress yaml with expected host when insecure is true and not on OpenShift'() { + config.application.insecure = true + config.features.argocd.url = "http://argocd.localhost" + def argocd = setupOperatorTest(openshift: false) + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") + assertThat(ingressFile) + .as("Ingress file should be generated for insecure mode on non-OpenShift") + .exists() + + def ingressYaml = parseActualYaml(ingressFile.toString()) + + def rules = ingressYaml['spec']['rules'] as List + def host = rules[0]['host'] + assertThat(host) + .as("Ingress host should match configured ArgoCD hostname") + .isEqualTo(new URL(config.features.argocd.url).host) + } + + @Test + void 'Does not generate ingress yaml when insecure is false'() { + config.application.insecure = false + def argocd = setupOperatorTest(openshift: false) + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") + assertThat(ingressFile) + .as("Ingress file should not be generated when insecure is false") + .doesNotExist() + } + + @Test + void 'Does not generate ingress yaml when running on OpenShift'() { + config.application.insecure = true + def argocd = setupOperatorTest(openshift: true) + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") + assertThat(ingressFile) + .as("Ingress file should not be generated on OpenShift") + .doesNotExist() + } + + @Test + void 'Does not generate ingress yaml when insecure is false and OpenShift is true'() { + config.application.insecure = false + def argocd = setupOperatorTest(openshift: true) + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") + assertThat(ingressFile) + .as("Ingress file should not be generated when both flags are false") + .doesNotExist() + } + + @Test + void 'Central Bootstrapping for Tenant Applications'() { + setupDedicatedInstanceMode() + + def ingressFile = new File(clusterResourcesRepoLayout.operatorDir(), "ingress.yaml") + assertThat(ingressFile) + .as("Ingress file should not be generated when both flags are false") + .doesNotExist() + } + + @Test + void 'GOP DedicatedInstances Central templating works correctly'() { + setupDedicatedInstanceMode() + //Central Applications + assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/applications/argocd.yaml")).exists() + assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/applications/bootstrap.yaml")).exists() + assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/applications/projects.yaml")).exists() + assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/applications/example-apps.yaml")).doesNotExist() + + def argocdYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.argocdRoot(), "/applications/argocd.yaml") + def bootstrapYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.argocdRoot(), "/applications/bootstrap.yaml") + def projectsYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.argocdRoot(), "/applications/projects.yaml") + + assertThat(argocdYaml['metadata']['name']).isEqualTo('testPrefix-argocd') + assertThat(argocdYaml['metadata']['namespace']).isEqualTo('argocd') + assertThat(argocdYaml['spec']['project']).isEqualTo('testPrefix') + assertThat(argocdYaml['spec']['source']['path']).isEqualTo('apps/argocd/operator/') + + assertThat(bootstrapYaml['metadata']['name']).isEqualTo('testPrefix-bootstrap') + assertThat(bootstrapYaml['metadata']['namespace']).isEqualTo('argocd') + assertThat(bootstrapYaml['spec']['project']).isEqualTo('testPrefix') + assertThat(bootstrapYaml['spec']['source']['repoURL']).isEqualTo("scmm.testhost/scm/repo/testPrefix-argocd/cluster-resources.git") + + assertThat(projectsYaml['metadata']['name']).isEqualTo('testPrefix-projects') + assertThat(projectsYaml['metadata']['namespace']).isEqualTo('argocd') + assertThat(projectsYaml['spec']['project']).isEqualTo('testPrefix') + + //Central Project + assertThat(new File(clusterResourcesRepoLayout.argocdRoot() + "/projects/tenant.yaml")).exists() + + def tenantProject = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.argocdRoot(), "/projects/tenant.yaml") + + assertThat(tenantProject['metadata']['name']).isEqualTo('testPrefix') + assertThat(tenantProject['metadata']['namespace']).isEqualTo('argocd') + def sourceRepos = (List) tenantProject['spec']['sourceRepos'] + assertThat(sourceRepos[0]).isEqualTo('scmm.testhost/scm/repo/testPrefix-argocd/cluster-resources.git') + } + + @Test + void 'Append namespaces to Argocd argocd-default-cluster-config secrets'() { + config.application.namespaces.dedicatedNamespaces = new LinkedHashSet(['dedi-test1', 'dedi-test2', 'dedi-test3']) + config.application.namespaces.tenantNamespaces = new LinkedHashSet(['tenant-test1', 'tenant-test2', 'tenant-test3']) + setupDedicatedInstanceMode() + k8sCommands.assertExecuted('kubectl get secret argocd-default-cluster-config -n argocd -ojsonpath={.data.namespaces}') + k8sCommands.assertExecuted('kubectl patch secret argocd-default-cluster-config -n argocd --patch-file=/tmp/gitops-playground-patch-') + } + + @Test + void 'multiTenant folder gets deleted correctly if not in dedicated mode'() { + config.multiTenant.useDedicatedInstance = false + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'multiTenant/')).doesNotExist() + assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'applications/')).exists() + assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'projects/')).exists() + } + + @Test + void 'deleting unused folder in dedicated mode'() { + config.multiTenant.useDedicatedInstance = true + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'multiTenant/')).doesNotExist() + assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'applications/')).exists() + assertThat(Path.of(clusterResourcesRepoLayout.argocdRoot(), 'projects/')).exists() + } + + @Test + void 'RBACs generated correctly'() { + config.application.namespaces.tenantNamespaces = new LinkedHashSet(['testprefix-tenant-test1', 'testprefix-tenant-test2', 'testprefix-tenant-test3']) + setupDedicatedInstanceMode() + + File rbacFolder = new File(clusterResourcesRepoLayout.operatorRbacDir()) + File rbacTenantFolder = new File(clusterResourcesRepoLayout.operatorRbacDir() + "/tenant") + assertThat(rbacFolder).exists() + assertThat(rbacTenantFolder).exists() + + assertThat(rbacFolder.listFiles().count { it.isFile() }).isEqualTo(14) + assertThat(rbacTenantFolder.listFiles().count { it.isFile() }).isEqualTo(6) + + rbacFolder.eachFile { file -> + if (file.name.startsWith("role-") && file.name.contains('dedi')) { + def rbacFile = new YamlSlurper().parse(Path.of file.path) + assertThat(rbacFile['metadata']['namespace']).isIn(config.application.namespaces.getActiveNamespaces()) + } + if (file.name.startsWith("rolebinding-") && file.name.contains('dedi')) { + def rbacFile = new YamlSlurper().parse(Path.of file.path) + assertThat(rbacFile['subjects']['namespace']).isEqualTo(["argocd", "argocd", "argocd"]) + } + } + + rbacTenantFolder.eachFile { file -> + if (file.name.startsWith("role-")) { + def rbacFile = new YamlSlurper().parse(Path.of file.path) + assertThat(rbacFile['metadata']['namespace']).isIn(config.application.namespaces.tenantNamespaces) + } + + if (file.name.startsWith("rolebinding-")) { + def rbacFile = new YamlSlurper().parse(Path.of file.path) + assertThat(rbacFile['subjects']['namespace']).isEqualTo(["testPrefix-argocd", "testPrefix-argocd", "testPrefix-argocd"]) + } + } + + } + + @Test + void 'Operator RBAC includes node access rules when not on OpenShift'() { + config.application.namePrefix = "testprefix-" + + def argocd = setupOperatorTest(openshift: false) + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + print config.toMap() + + File rbacDir = Path.of(clusterResourcesRepoLayout.operatorRbacDir()).toFile() + File roleFile = new File(rbacDir, "role-argocd-testprefix-monitoring.yaml") + + Map yaml = new YamlSlurper().parse(roleFile) as Map + List> rules = yaml["rules"] as List> + + assertThat(rules).anyMatch { rule -> + List resources = rule["resources"] as List + resources.contains("nodes") && resources.contains("nodes/metrics") + } + } + + @Test + void 'Operator RBAC does not include node access rules when on OpenShift'() { + config.application.namePrefix = "testprefix-" + + def argocd = setupOperatorTest(openshift: true) + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + File rbacDir = Path.of(clusterResourcesRepoLayout.operatorRbacDir()).toFile() + File roleFile = new File(rbacDir, "role-argocd-testprefix-monitoring.yaml") + println roleFile + + Map yaml = new YamlSlurper().parse(roleFile) as Map + List> rules = yaml["rules"] as List> + + assertThat(rules).noneMatch { rule -> + List resources = rule["resources"] as List + resources.contains("nodes") && resources.contains("nodes/metrics") + } + } + + @Test + void 'If not using mirror, ensure source repos in cluster-resources got right URL'() { + config.application.mirrorRepos = false + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') + clusterRessourcesYaml['spec']['sourceRepos'] + + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains('https://charts.external-secrets.io', + 'https://codecentric.github.io/helm-charts', + 'https://prometheus-community.github.io/helm-charts', + 'https://traefik.github.io/charts', + 'https://helm.releases.hashicorp.com', + 'https://charts.jetstack.io') + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain('http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/traefik', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/external-secrets', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/vault', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/cert-manager') + + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain('http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/kube-prometheus-stack.git', + 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/traefik.git', + 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/external-secrets.git', + 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/vault.git', + 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/cert-manager.git') + } + + @Test + void 'If using mirror, ensure source repos in cluster-resources got right URL'() { + config.application.mirrorRepos = true + + def argocd = createArgoCD() + argocd.install() + + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') + clusterRessourcesYaml['spec']['sourceRepos'] + + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains('http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack', + 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/traefik', + 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/external-secrets', + 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/vault', + 'http://scmm.scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/cert-manager' + + ) + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain('http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/kube-prometheus-stack.git', + 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/traefik.git', + 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/external-secrets.git', + 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/vault.git', + 'http://scmm.scm-manager.svc.cluster.local/scm/3rd-party-dependencies/cert-manager.git') + } + + @Test + void 'If using mirror with GitLab, ensure source repos in cluster-resources got right URL'() { + config.application.mirrorRepos = true + config.scm.scmProviderType = 'GITLAB' + config.scm.gitlab.url = 'https://testGitLab.com/testgroup' + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') + clusterRessourcesYaml['spec']['sourceRepos'] + + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains('https://testGitLab.com/testgroup/3rd-party-dependencies/kube-prometheus-stack.git', + 'https://testGitLab.com/testgroup/3rd-party-dependencies/traefik.git', + 'https://testGitLab.com/testgroup/3rd-party-dependencies/external-secrets.git', + 'https://testGitLab.com/testgroup/3rd-party-dependencies/vault.git', + 'https://testGitLab.com/testgroup/3rd-party-dependencies/cert-manager.git') + } + + @Test + void 'If using mirror with GitLab with prefix, ensure source repos in cluster-resources got right URL'() { + config.application.mirrorRepos = true + config.scm.scmProviderType = 'GITLAB' + config.scm.gitlab.url = "https://testGitLab.com/testgroup" + config.application.namePrefix = 'test1-' + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') + clusterRessourcesYaml['spec']['sourceRepos'] + + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains('https://testGitLab.com/testgroup/3rd-party-dependencies/kube-prometheus-stack.git', + 'https://testGitLab.com/testgroup/3rd-party-dependencies/traefik.git', + 'https://testGitLab.com/testgroup/3rd-party-dependencies/external-secrets.git', + 'https://testGitLab.com/testgroup/3rd-party-dependencies/vault.git', + 'https://testGitLab.com/testgroup/3rd-party-dependencies/cert-manager.git') + + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain('http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/traefik', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/external-secrets', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/vault', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/cert-manager') + } + + @Test + void 'If using mirror with name-prefix, ensure source repos in cluster-resources got right URL'() { + config.application.mirrorRepos = true + config.application.namePrefix = 'test1-' + + def argocd = createArgoCD() + argocd.install() + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + def clusterRessourcesYaml = new YamlSlurper().parse(Path.of clusterResourcesRepoLayout.projectsDir(), '/cluster-resources.yaml') + clusterRessourcesYaml['spec']['sourceRepos'] + + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).contains('http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/kube-prometheus-stack', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/traefik', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/external-secrets', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/vault', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/repo/3rd-party-dependencies/cert-manager') + + assertThat(clusterRessourcesYaml['spec']['sourceRepos'] as List).doesNotContain('http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/kube-prometheus-stack.git', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/traefik.git', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/external-secrets.git', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/vault.git', + 'http://scmm.test1-scm-manager.svc.cluster.local/scm/3rd-party-dependencies/cert-manager.git') + } + + void setupDedicatedInstanceMode() { + config.application.namePrefix = 'testPrefix-' + config.multiTenant.scmManager.url = 'scmm.testhost/scm' + config.multiTenant.scmManager.username = 'testUserName' + config.multiTenant.scmManager.password = 'testPassword' + config.multiTenant.useDedicatedInstance = true + this.argocd = setupOperatorTest() + argocd.install() + this.clusterResourcesRepo = (argocd as ArgoCDForTest).clusterResourcesRepo + clusterResourcesRepoLayout = (argocd as ArgoCDForTest).getClusterRepoLayout() + + } + + protected ArgoCD setupOperatorTest(Map options = [:]) { + config.features.argocd.operator = true + config.features.argocd.resourceInclusionsCluster = 'https://192.168.0.1:6443' + config.application.openshift = options.openshift ?: false + + def argoCD = createArgoCD() + + if (config.multiTenant.useDedicatedInstance) { + config.content.repos ? setupMockResponsesFor(MockReponses.MULTI_TENANT_WITH_EXAMPLES) : setupMockResponsesFor(MockReponses.MULTI_TENANT) + } else { + setupMockResponsesFor(MockReponses.SINGLE_TENANT) + } + + return argoCD + } + + enum MockReponses { + SINGLE_TENANT, + MULTI_TENANT, + MULTI_TENANT_WITH_EXAMPLES + } + + //Mock Responses for Testing + void setupMockResponsesFor(MockReponses mockReponses) { + switch (mockReponses) { + case MockReponses.SINGLE_TENANT -> { + k8sCommands.enqueueOutputs([queueUpAllNamespacesExist(), + new CommandExecutor.Output('', '', 0), // Monitoring CRDs applied + new CommandExecutor.Output('', '', 0), // ArgoCD Secret applied + new CommandExecutor.Output('', '', 0), // Labeling ArgoCD Secret + new CommandExecutor.Output('', '', 0), // ArgoCD operator YAML applied + new CommandExecutor.Output('', 'Available', 0), // ArgoCD resource reached desired phase + ].flatten() as Queue) + } + case MockReponses.MULTI_TENANT_WITH_EXAMPLES -> mockReponseMultiTenant() + case MockReponses.MULTI_TENANT -> mockReponseMultiTenant() + } + } + + void mockReponseMultiTenant() { + k8sCommands.enqueueOutputs([queueUpAllNamespacesExist(), + new CommandExecutor.Output('', '', 0), // Monitoring CRDs applied + + new CommandExecutor.Output('', '', 0), // ArgoCD SCM Secret applied + new CommandExecutor.Output('', '', 0), // Labeling ArgoCD SCM Secret + new CommandExecutor.Output('', '', 0), // ArgoCD SCM central Secret applied + new CommandExecutor.Output('', '', 0), // Labeling ArgoCD central SCM Secret + + new CommandExecutor.Output('', '', 0), // ArgoCD operator YAML applied + new CommandExecutor.Output('', 'Available', 0), // ArgoCD resource reached desired phase + + new CommandExecutor.Output('', '', 0), // ArgoCD argocd-cluster password secret + new CommandExecutor.Output('', '', 0), // ArgoCD argocd-secret + + new CommandExecutor.Output('', '', 0), // argocd-default-cluster-config patched + new CommandExecutor.Output('', '', 0), // ArgoCD argocd-secret + new CommandExecutor.Output('', 'dGVzdG5hbWVzcGFjZTEsdGVzdG5hbWVzcGFjZTI=', 0), // getting argocd-default-cluster-config from central Argocd + new CommandExecutor.Output('', '', 0), // setting argocd-default-cluster-config from central Argocd + ].flatten() as Queue) + } + + private void simulateNamespaceCreation() { + Queue outputs = new LinkedList() + config.application.namespaces.getActiveNamespaces().each { namespace -> + outputs.add(new CommandExecutor.Output("${namespace} not found", "", 1)) + outputs.add(new CommandExecutor.Output("${namespace} created", "", 0)) + } + k8sCommands.enqueueOutputs(outputs) + } + + private Queue queueUpAllNamespacesExist() { + return new LinkedList(config.application.namespaces.getActiveNamespaces().collect { namespace -> new CommandExecutor.Output(namespace, "", 0) }) + } + + private static void mockPrefixActiveNamespaces(Config config) { + def prefix = config.application.namePrefix ?: "" + + config.application.namespaces.with { + dedicatedNamespaces = new LinkedHashSet<>(dedicatedNamespaces.collect { (prefix + it).toString() }) + tenantNamespaces = new LinkedHashSet<>(tenantNamespaces.collect { (prefix + it).toString() }) + } + } + + static class ArgoCDForTest extends ArgoCD { + final Config cfg + final GitProvider tenantProvider + final GitProvider centralProvider + + static ArgoCDForTest newWithAutoProviders( + Config cfg, + CommandExecutorForTest k8sCommands, + CommandExecutorForTest helmCommands) { + def provider = TestGitProvider.buildProviders(cfg) + return new ArgoCDForTest(cfg, + k8sCommands, + helmCommands, + provider.tenant as GitProvider, + provider.central as GitProvider) + } + + ArgoCDForTest( + Config cfg, + CommandExecutorForTest k8sCommands, + CommandExecutorForTest helmCommands, + GitProvider tenantProvider, + GitProvider centralProvider) { + super(cfg, + new K8sClientForTest(cfg, k8sCommands), + new FileSystemUtils(), + new TestGitRepoFactory(cfg, new FileSystemUtils()), + new GitHandlerForTests(cfg, tenantProvider, centralProvider)) + this.cfg = cfg + this.tenantProvider = tenantProvider + this.centralProvider = centralProvider + mockPrefixActiveNamespaces(cfg) + } + + GitRepo getClusterResourcesRepo() { + return getRepoSetup().clusterResources?.repo + } + + RepoLayout getClusterRepoLayout() { + return getRepoSetup().clusterRepoLayout() + } + + } + + private Map parseActualYaml(String pathToYamlFile) { + File yamlFile = new File(pathToYamlFile) + def ys = new YamlSlurper() + return ys.parse(yamlFile) as Map + } + +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/deployment/DeployerTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/deployment/DeployerTest.groovy index 8c7d516a6..c791b2886 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/deployment/DeployerTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/deployment/DeployerTest.groovy @@ -1,47 +1,40 @@ package com.cloudogu.gitops.features.deployment -import com.cloudogu.gitops.config.Config - -import org.junit.jupiter.api.Test - -import java.nio.file.Path - import static org.mockito.ArgumentMatchers.any import static org.mockito.ArgumentMatchers.anyString -import static org.mockito.Mockito.mock -import static org.mockito.Mockito.never -import static org.mockito.Mockito.verify +import static org.mockito.Mockito.* -class DeployerTest { - private ArgoCdApplicationStrategy argoCdStrat = mock(ArgoCdApplicationStrategy.class) - private HelmStrategy helmStrat = mock(HelmStrategy.class) +import java.nio.file.Path - @Test - void 'When argocd disabled, deploys imperatively via helm'() { - def deployer = createDeployer(false) +import org.junit.jupiter.api.Test - deployer.deployFeature("repoURL", "repoName", "chart", "version", "namespace", "releaseName", Path.of("values.yaml")) +class DeployerTest { + private ArgoCdApplicationStrategy argoCdStrat = mock(ArgoCdApplicationStrategy.class) + private HelmStrategy helmStrat = mock(HelmStrategy.class) - verify(argoCdStrat, never()).deployFeature(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), any(Path)) - verify(helmStrat).deployFeature("repoURL", "repoName", "chart", "version", "namespace", - "releaseName", Path.of("values.yaml"), DeploymentStrategy.RepoType.HELM) - } + @Test + void 'When init via Helm is active, deploys imperatively via helm'() { + def deployer = createDeployer() + deployer.deployFeature("repoURL", "repoName", "chart", "version", "namespace", "releaseName", Path.of("values.yaml"), DeploymentStrategy.RepoType.HELM, true) - @Test - void 'When Argo CD enabled, deploys natively via Argo CD'() { - def deployer = createDeployer(true) + verify(argoCdStrat, never()).deployFeature(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), any(Path)) + verify(helmStrat).deployFeature("repoURL", "repoName", "chart", "version", "namespace", + "releaseName", Path.of("values.yaml"), DeploymentStrategy.RepoType.HELM) + } - deployer.deployFeature("repoURL", "repoName", "chart", "version", "namespace", "releaseName", Path.of("values.yaml")) + @Test + void 'When Argo CD enabled, deploys natively via Argo CD'() { + def deployer = createDeployer() - verify(argoCdStrat).deployFeature("repoURL", "repoName", "chart", "version", "namespace", - "releaseName", Path.of("values.yaml"), DeploymentStrategy.RepoType.HELM) - verify(helmStrat, never()).deployFeature(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), any(Path)) - } + deployer.deployFeature("repoURL", "repoName", "chart", "version", "namespace", "releaseName", Path.of("values.yaml"), DeploymentStrategy.RepoType.HELM) - private Deployer createDeployer(boolean argoCDActive) { - Config config = new Config(features: new Config.FeaturesSchema(argocd: new Config.ArgoCDSchema(active:argoCDActive))) + verify(argoCdStrat).deployFeature("repoURL", "repoName", "chart", "version", "namespace", + "releaseName", Path.of("values.yaml"), DeploymentStrategy.RepoType.HELM) + verify(helmStrat, never()).deployFeature(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), any(Path)) + } - return new Deployer(config, argoCdStrat, helmStrat) - } -} + private Deployer createDeployer() { + return new Deployer(argoCdStrat, helmStrat) + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetupTest.groovy b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetupTest.groovy index 6a84e8e32..a8ba44307 100644 --- a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetupTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetupTest.groovy @@ -1,89 +1,78 @@ package com.cloudogu.gitops.git.providers.scmmanager +import static org.mockito.ArgumentMatchers.any +import static org.mockito.ArgumentMatchers.eq +import static org.mockito.Mockito.* + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.HelmStrategy import com.cloudogu.gitops.git.providers.scmmanager.api.PluginApi import com.cloudogu.gitops.git.providers.scmmanager.api.ScmManagerApi import com.cloudogu.gitops.git.providers.scmmanager.api.ScmManagerApiClient + import org.junit.jupiter.api.Test import retrofit2.Call import retrofit2.Response -import static org.mockito.ArgumentMatchers.any -import static org.mockito.ArgumentMatchers.eq -import static org.mockito.Mockito.* - class ScmManagerSetupTest { - ScmManager scmManager = mock(ScmManager.class) + ScmManager scmManager = mock(ScmManager.class) - HelmStrategy helmStrategy = mock(HelmStrategy.class) - ScmManagerApiClient apiClient = mock(ScmManagerApiClient.class) + HelmStrategy helmStrategy = mock(HelmStrategy.class) + ScmManagerApiClient apiClient = mock(ScmManagerApiClient.class) - PluginApi pluginApi = mock(PluginApi.class) - ScmManagerApi generalApi = mock(ScmManagerApi.class) + PluginApi pluginApi = mock(PluginApi.class) + ScmManagerApi generalApi = mock(ScmManagerApi.class) - Config config = Config.fromMap([ - application: [ - namePrefix: 'test', - ], - scm : [ - scmManager: [ - internal : true, - url : "", - namespace : "scm-manager", - username : "admin", - password : "admin", - helm : [ - chart : "scm-manager", - repoURL: "https://packages.scm-manager.org/repository/helm-v2-releases/", - version: "3.11.2", - values : [:] - ], - urlForJenkins : "http://scmm.scm-manager.svc.cluster.local/scm", - ingress : "scmm.master.localhost", - skipRestart : false, - skipPlugins : false, - gitOpsUsername: "" - ] - ] - ]) + Config config = Config.fromMap([application: [namePrefix: 'test',], + scm : [scmManager: [internal : true, + url : "", + namespace : "scm-manager", + username : "admin", + password : "admin", + helm : [chart : "scm-manager", + repoURL: "https://packages.scm-manager.org/repository/helm-v2-releases/", + version: "3.11.2", + values : [:]], + urlForJenkins : "http://scmm.scm-manager.svc.cluster.local/scm", + ingress : "scmm.master.localhost", + skipRestart : false, + skipPlugins : false, + gitOpsUsername: ""]]]) - @Test - void 'Helm chart is installed correctly'() { - when(scmManager.getConfig()).thenReturn(config) - when(scmManager.getHelmStrategy()).thenReturn(helmStrategy) - when(scmManager.getScmmConfig()).thenReturn(config.scm.scmManager) - ScmManagerSetup scmManagerSetup = new ScmManagerSetup(scmManager) - scmManagerSetup.setupHelm() - verify(helmStrategy).deployFeature( - eq( "https://packages.scm-manager.org/repository/helm-v2-releases/"), - eq("scm-manager"), - any(), - eq("3.11.2"), - eq("scm-manager"), - eq("scmm"), - any() - ) - } + @Test + void 'Helm chart is installed correctly'() { + when(scmManager.getConfig()).thenReturn(config) + when(scmManager.deployer.getHelmStrategy()).thenReturn(helmStrategy) + when(scmManager.getScmmConfig()).thenReturn(config.scm.scmManager) + ScmManagerSetup scmManagerSetup = new ScmManagerSetup(scmManager) + scmManagerSetup.setupHelm() + verify(helmStrategy).deployFeature(eq("https://packages.scm-manager.org/repository/helm-v2-releases/"), + eq("scm-manager"), + any(), + eq("3.11.2"), + eq("scm-manager"), + eq("scmm"), + any()) + } - @Test - void 'ScmManager Plugins are installed correctly'() { - when(scmManager.getConfig()).thenReturn(config) - when(scmManager.getHelmStrategy()).thenReturn(helmStrategy) - when(scmManager.getScmmConfig()).thenReturn(config.scm.scmManager) - when(scmManager.getApiClient()).thenReturn(apiClient) + @Test + void 'ScmManager Plugins are installed correctly'() { + when(scmManager.getConfig()).thenReturn(config) + when(scmManager.deployer.getHelmStrategy()).thenReturn(helmStrategy) + when(scmManager.getScmmConfig()).thenReturn(config.scm.scmManager) + when(scmManager.getApiClient()).thenReturn(apiClient) - Call apiCall = mock(Call.class) + Call apiCall = mock(Call.class) - when(pluginApi.install(any(),any())).thenReturn(apiCall) - when(generalApi.checkScmmAvailable()).thenReturn(apiCall) - when(apiClient.pluginApi()).thenReturn(pluginApi) - when(apiClient.generalApi()).thenReturn(generalApi) - when(apiCall.execute()).thenReturn(Response.success(null)) - ScmManagerSetup scmManagerSetup = new ScmManagerSetup(scmManager) - scmManagerSetup.installScmmPlugins() - verify(pluginApi,atLeast(10)).install(any(),any()) - } + when(pluginApi.install(any(), any())).thenReturn(apiCall) + when(generalApi.checkScmmAvailable()).thenReturn(apiCall) + when(apiClient.pluginApi()).thenReturn(pluginApi) + when(apiClient.generalApi()).thenReturn(generalApi) + when(apiCall.execute()).thenReturn(Response.success(null)) + ScmManagerSetup scmManagerSetup = new ScmManagerSetup(scmManager) + scmManagerSetup.installScmmPlugins() + verify(pluginApi, atLeast(10)).install(any(), any()) + } -} +} \ No newline at end of file From f2f4eb15e4e5f16f24b4fb6794559ef42d53c4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Wed, 29 Apr 2026 11:04:36 +0200 Subject: [PATCH 08/10] merge fixed --- .../argocd/argocd/argocd-helm-values.ftl.yaml | 4 +- .../com/cloudogu/gitops/Application.groovy | 94 ++--- .../groovy/com/cloudogu/gitops/Feature.groovy | 1 + .../com/cloudogu/gitops/config/Config.groovy | 11 +- .../gitops/config/ConfigConstants.groovy | 334 +++++++++--------- .../gitops/features/argocd/ArgoCD.groovy | 18 +- .../ArgoCdApplicationStrategy.groovy | 271 +++++++------- .../gitops/features/git/GitHandler.groovy | 4 +- .../scmmanager/ScmManagerSetup.groovy | 47 ++- 9 files changed, 387 insertions(+), 397 deletions(-) diff --git a/argocd/cluster-resources/apps/argocd/argocd/argocd-helm-values.ftl.yaml b/argocd/cluster-resources/apps/argocd/argocd/argocd-helm-values.ftl.yaml index f6aa0f6bd..4cb3ec57b 100644 --- a/argocd/cluster-resources/apps/argocd/argocd/argocd-helm-values.ftl.yaml +++ b/argocd/cluster-resources/apps/argocd/argocd/argocd-helm-values.ftl.yaml @@ -69,7 +69,7 @@ argo-cd: # password: ... cm: timeout.reconciliation: 15s - repository.check.interval: 30s + repository.check.interval: 5s notifications: # secrets are created dynamically in groovy, so they are not stored in git @@ -190,4 +190,4 @@ argo-cd: - app-sync-status-longer-10s when: app.status.operationState.phase in ['Running'] and time.Now().Sub(time.Parse(app.status.operationState.startedAt)).Seconds() >= 10 - + \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/Application.groovy b/src/main/groovy/com/cloudogu/gitops/Application.groovy index 6e395bebb..e0621419e 100644 --- a/src/main/groovy/com/cloudogu/gitops/Application.groovy +++ b/src/main/groovy/com/cloudogu/gitops/Application.groovy @@ -2,67 +2,67 @@ package com.cloudogu.gitops import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.utils.TemplatingEngine + +import jakarta.inject.Singleton +import groovy.util.logging.Slf4j + import freemarker.template.Configuration import freemarker.template.DefaultObjectWrapperBuilder -import groovy.util.logging.Slf4j -import jakarta.inject.Singleton @Slf4j @Singleton class Application { - final List features - final Config config + final List features + final Config config - Application(Config config, - List features - ) { - this.config = config - // Order is important. Enforced by @Order-Annotation on the Singletons - this.features = features - } + Application( + Config config, + List features) { + this.config = config + // Order is important. Enforced by @Order-Annotation on the Singletons + this.features = features + } - def start() { - log.debug("Starting Application") + def start() { + log.debug("Starting Application") - setNamespaceListToConfig(config) + setNamespaceListToConfig(config) - features.forEach(feature -> { - feature.validate() - }) - features.forEach(feature -> { - feature.install() - }) - log.debug("Application finished") - } + features.forEach(feature -> { + feature.validate() + }) + features.forEach(feature -> { + feature.install() + }) + log.debug("Application finished") + } - List getFeatures() { - return features - } + List getFeatures() { + return features + } - void setNamespaceListToConfig(Config config) { - LinkedHashSet dedicatedNamespaces = new LinkedHashSet<>() - LinkedHashSet tenantNamespaces = new LinkedHashSet<>() - def engine = new TemplatingEngine() + void setNamespaceListToConfig(Config config) { + LinkedHashSet dedicatedNamespaces = new LinkedHashSet<>() + LinkedHashSet tenantNamespaces = new LinkedHashSet<>() + def engine = new TemplatingEngine() - config.content.namespaces.each { String ns -> - tenantNamespaces.add(engine.template(ns, [ - config : config, - // Allow for using static classes inside the templates - statics: new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_32).build().getStaticModels() - ])) - } - config.content.namespaces = tenantNamespaces.toList() + config.content.namespaces.each { String ns -> + tenantNamespaces.add(engine.template(ns, [config : config, + // Allow for using static classes inside the templates + statics: new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_32).build().getStaticModels()])) + } + config.content.namespaces = tenantNamespaces.toList() - //iterates over all FeatureWithImages and gets their namespaces - dedicatedNamespaces.addAll(this.features - .collect { it.activeNamespaceFromFeature } - .findAll { it } - .unique() - .collect { "${it}".toString() }) + //iterates over all FeatureWithImages and gets their namespaces + dedicatedNamespaces.addAll(this.features + .collect { it.activeNamespaceFromFeature } + .findAll { it } + .unique() + .collect { "${it}".toString() }) - config.application.namespaces.dedicatedNamespaces = dedicatedNamespaces - config.application.namespaces.tenantNamespaces = tenantNamespaces - log.debug("Active namespaces retrieved: {}", config.application.namespaces.activeNamespaces) - } + config.application.namespaces.dedicatedNamespaces = dedicatedNamespaces + config.application.namespaces.tenantNamespaces = tenantNamespaces + log.debug("Active namespaces retrieved: {}", config.application.namespaces.activeNamespaces) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/Feature.groovy b/src/main/groovy/com/cloudogu/gitops/Feature.groovy index da26b4491..54bd8dd3c 100644 --- a/src/main/groovy/com/cloudogu/gitops/Feature.groovy +++ b/src/main/groovy/com/cloudogu/gitops/Feature.groovy @@ -63,6 +63,7 @@ abstract class Feature { } enable() + log.info("Feature installed: ${getClass().getSimpleName()}") return true } else { log.debug("Feature ${getClass().getSimpleName()} is disabled") diff --git a/src/main/groovy/com/cloudogu/gitops/config/Config.groovy b/src/main/groovy/com/cloudogu/gitops/config/Config.groovy index 6b546e73d..f374464a8 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/Config.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/Config.groovy @@ -333,8 +333,8 @@ class Config { @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) HelmConfigWithValues helm = new HelmConfigWithValues(chart: 'jenkins', - repoURL: 'https://charts.jenkins.io', - version: '5.9.18') + repoURL: 'https://charts.jenkins.io', + version: '5.9.18') } static class ApplicationSchema { @@ -492,6 +492,13 @@ class Config { @JsonPropertyDescription(ARGOCD_OPERATOR_DESCRIPTION) Boolean operator = false + @Option(names = ['--install-argocd-operator'], description = ARGOCD_INSTALL_OPERATOR_DESCRIPTION) + @JsonPropertyDescription(ARGOCD_INSTALL_OPERATOR_DESCRIPTION) + Boolean installOperator = false + + @JsonPropertyDescription(ARGOCD_INSTALL_OPERATOR_VERSION_DESCRIPTION) + Boolean operatorVersion = '0.17' + @Option(names = ['--argocd-url'], description = ARGOCD_URL_DESCRIPTION) @JsonPropertyDescription(ARGOCD_URL_DESCRIPTION) String url = '' diff --git a/src/main/groovy/com/cloudogu/gitops/config/ConfigConstants.groovy b/src/main/groovy/com/cloudogu/gitops/config/ConfigConstants.groovy index f6a5150be..08073f96a 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/ConfigConstants.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/ConfigConstants.groovy @@ -2,169 +2,171 @@ package com.cloudogu.gitops.config interface ConfigConstants { - public static final String BINARY_NAME = 'apply-ng' - public static final String APP_NAME = 'gitops-playground (GOP)' - public static final String APP_DESCRIPTION = 'CLI-tool to deploy gitops-playground.' - - // group registry - String REGISTRY_ENABLE_DESCRIPTION = 'Installs a simple cluster-local registry for demonstration purposes. Warning: Registry does not provide authentication!' - String REGISTRY_DESCRIPTION = 'Config parameters for Registry' - String REGISTRY_INTERNAL_PORT_DESCRIPTION = 'Port of registry registry. Ignored when a registry*url params are set' - String REGISTRY_URL_DESCRIPTION = 'The url of your external registry, used for pushing images' - String REGISTRY_PATH_DESCRIPTION = 'Optional when registry-url is set' - String REGISTRY_USERNAME_DESCRIPTION = 'Optional when registry-url is set' - String REGISTRY_PASSWORD_DESCRIPTION = 'Optional when registry-url is set' - - String REGISTRY_PROXY_URL_DESCRIPTION = 'The url of your proxy-registry. Used in pipelines to authorize pull base images. Use in conjunction with petclinic base image. Used in helm charts when create-image-pull-secrets is set. Use in conjunction with helm.*image fields.' - String REGISTRY_PROXY_PATH_DESCRIPTION = 'Optional when registry-proxy-url is set and the registry is running on a non root web path.' - String REGISTRY_PROXY_USERNAME_DESCRIPTION = 'Use with registry-proxy-url, added to Jenkins as credentials and created as pull secrets, when create-image-pull-secrets is set.' - String REGISTRY_PROXY_PASSWORD_DESCRIPTION = 'Use with registry-proxy-url, added to Jenkins as credentials and created as pull secrets, when create-image-pull-secrets is set.' - - String REGISTRY_USERNAME_RO_DESCRIPTION = 'Optional alternative username for registry-url with read-only permissions that is used when create-image-pull-secrets is set.' - String REGISTRY_PASSWORD_RO_DESCRIPTION = 'Optional alternative password for registry-url with read-only permissions that is used when create-image-pull-secrets is set.' - String REGISTRY_CREATE_IMAGE_PULL_SECRETS_DESCRIPTION = 'Create image pull secrets for registry and proxy-registry for all GOP namespaces and helm charts. Uses proxy-username, read-only-username or registry-username (in this order). Use this if your cluster is not auto-provisioned with credentials for your private registries or if you configure individual helm images to be pulled from the proxy-registry that requires authentication.' - - String FEATURES_DESCRIPTION = 'Config parameters for features or tools' - - String CONTENT_DESCRIPTION = 'Config parameters for content, i.e. end-user or tenant applications as opposed to cluster-resources' - - // ContentLoader - String CONTENT_NAMESPACES_DESCRIPTION = 'Additional kubernetes namespaces. These are authorized to Argo CD, supplied with image pull secrets, monitored by prometheus, etc. Namespaces can be templates, e.g. ${config.application.namePrefix}staging' - String CONTENT_REPO_DESCRIPTION = "ContentLoader repos to push into target environment" - String CONTENT_REPO_URL_DESCRIPTION = "URL of the content repo. Mandatory for each type." - String CONTENT_REPO_PATH_DESCRIPTION = "Path within the content repo to process" - String CONTENT_REPO_REF_DESCRIPTION = "Reference for a specific branch, tag, or commit. Emtpy defaults to default branch of the repo. With type MIRROR: ref must not be a commit hash; Choosing a ref only mirrors the ref but does not delete other branches/tags!" - String CONTENT_REPO_TARGET_REF_DESCRIPTION = "Reference for a specific branch or tag in the target repo of a MIRROR or COPY repo. If ref is a tag, targetRef is treated as tag as well. Except: targetRef is full ref like refs/heads/my-branch or refs/tags/my-tag. Empty defaults to the source ref." - String CONTENT_REPO_CREDENTIALS_DESCRIPTION = "Credentials Object to authenticate against content repo. Allows using a K8s Secret" - String CONTENT_REPO_TEMPLATING_DESCRIPTION = "When true, template all files ending in .ftl within the repo" - String CONTENT_REPO_TYPE_DESCRIPTION = "ContentLoader Repos can either be:\ncopied (only the files, starting on ref, starting at path within the repo. Requires target)\n, mirrored (FORCE pushes ref or the whole git repo if no ref set). Requires target, does not allow path and template.)\nfolderBased (folder structure is interpreted as repos. That is, root folder becomes namespace in SCM, sub folders become repository names in SCM, files are copied. Requires target.)" - String CONTENT_REPO_TARGET_DESCRIPTION = "Target repo for the repository in the for of namespace/name. Must contain one slash to separate namespace from name." - String CONTENT_REPO_TARGET_OVERWRITE_MODE_DESCRIPTION = "This defines, how customer repos will be updated.\nINIT - push only if repo does not exist.\nRESET - delete all files after cloning source - files not in content are deleted\nUPGRADE - clone and copy - existing files will be overwritten, files not in content are kept. For type: MIRROR reset and upgrade have same result: in both cases source repo will be force pushed to target repo." - String CONTENT_REPO_CREATE_JENKINS_JOB_DESCRIPTION = "If true, creates a Jenkins job, if jenkinsfile exists in one of the content repo's branches." - String CONTENT_VARIABLES_DESCRIPTION = "Additional variables to use in custom templates." - String CONTENT_STATICSWHITELIST_ENABLED_DESCRIPTION = 'Enables the whitelist for statics in content templating' - String CONTENT_STATICSWHITELIST_DESCRIPTION = 'Whitelist for Statics freemarker is allowing in user templates' - String CONTENT_HELM_RELEASE_NAME_DESCRIPTION = "Logical name of the Helm release. Used as the feature folder name under 'apps/' and as default for 'releaseName' if not set." - - String CONTENT_HELM_RELEASE_REPO_URL_DESCRIPTION = "Helm repository URL to fetch the chart from. Use an HTTP(S) Helm repo (must provide an index.yaml) or an OCI registry URL (oci://...)." - String CONTENT_HELM_RELEASE_CHART_DESCRIPTION = "Helm chart name to install. For HTTP(S) repos this is the chart name from the repo index; for OCI this is the chart artifact name." - String CONTENT_HELM_RELEASE_VERSION_DESCRIPTION = "Chart version to deploy. Required for Helm charts in Argo CD. For HTTP(S) Helm repos you may use a SemVer range like '*' to always pick the newest version. For OCI registries, specify an explicit version/tag." - String CONTENT_HELM_RELEASE_NAMESPACE_DESCRIPTION = "Kubernetes namespace to deploy the release into." - String CONTENT_HELM_RELEASE_RELEASE_NAME_DESCRIPTION = "Helm release name. If empty, the value of 'name' is used." - String CONTENT_HELM_RELEASE_VALUES_FILE_DESCRIPTION = "Optional path to a YAML values file to load Helm values from.The file must be accessible locally on the machine running GOP. Inline 'values' will be merged on top (inline overrides file)." - String CONTENT_HELM_RELEASE_VALUES_DESCRIPTION = "Optional inline Helm values. These values are merged on top of 'valuesFile' (if set) and override keys from the file. Use this for small overrides without maintaining a separate file." - - // group jenkins - String JENKINS_ENABLE_DESCRIPTION = 'Installs Jenkins as CI server' - String JENKINS_SKIP_RESTART_DESCRIPTION = 'Skips restarting Jenkins after plugin installation. Use with caution! If the plugins are not installed up front, the installation will likely fail. The intended use case for this is after the first installation, for config changes only. Do not use on first installation or upgrades.' - String JENKINS_SKIP_PLUGINS_DESCRIPTION = 'Skips plugin installation. Use with caution! If the plugins are not installed up front, the installation will likely fail. The intended use case for this is after the first installation, for config changes only. Do not use on first installation or upgrades.' - String JENKINS_DESCRIPTION = 'Config parameters for Jenkins CI/CD Pipeline Server' - String JENKINS_URL_DESCRIPTION = 'The url of your external jenkins' - String JENKINS_USERNAME_DESCRIPTION = 'Mandatory when jenkins-url is set' - String JENKINS_PASSWORD_DESCRIPTION = 'Mandatory when jenkins-url is set' - String JENKINS_METRICS_USERNAME_DESCRIPTION = 'Mandatory when jenkins-url is set and monitoring enabled' - String JENKINS_METRICS_PASSWORD_DESCRIPTION = 'Mandatory when jenkins-url is set and monitoring enabled' - String MAVEN_CENTRAL_MIRROR_DESCRIPTION = 'URL for maven mirror, used by applications built in Jenkins' - String JENKINS_ADDITIONAL_ENVS_DESCRIPTION = 'Set additional environments to Jenkins' - - // group scmm - String SCM_DESCRIPTION = 'Config parameters for Scm' - String GIT_NAME_DESCRIPTION = 'Sets git author and committer name used for initial commits' - String GIT_EMAIL_DESCRIPTION = 'Sets git author and committer email used for initial commits' - - //MutliTentant - String MULTITENANT_DESCRIPTION = 'Multi Tenant Configs' - - // group remote - String INSECURE_DESCRIPTION = 'Sets insecure-mode in cURL which skips cert validation' - - // group tool configuration - String APPLICATION_DESCRIPTION = 'Application configuration parameter for GOP' - String GRAFANA_IMAGE_DESCRIPTION = 'Sets image for grafana' - String GRAFANA_SIDECAR_IMAGE_DESCRIPTION = 'Sets image for grafana\'s sidecar' - String PROMETHEUS_IMAGE_DESCRIPTION = 'Sets image for prometheus' - String PROMETHEUS_OPERATOR_IMAGE_DESCRIPTION = 'Sets image for prometheus-operator' - String PROMETHEUS_CONFIG_RELOADER_IMAGE_DESCRIPTION = 'Sets image for prometheus-operator\'s config-reloader' - String EXTERNAL_SECRETS_IMAGE_DESCRIPTION = 'Sets image for external secrets operator' - String EXTERNAL_SECRETS_CERT_CONTROLLER_IMAGE_DESCRIPTION = 'Sets image for external secrets operator\'s controller' - String EXTERNAL_SECRETS_WEBHOOK_IMAGE_DESCRIPTION = 'Sets image for external secrets operator\'s webhook' - String VAULT_IMAGE_DESCRIPTION = 'Sets image for vault' - String BASE_URL_DESCRIPTION = 'the external base url (TLD) for all tools, e.g. https://example.com or http://localhost:8080. The individual -url params for argocd, grafana and vault take precedence.' - String URL_SEPARATOR_HYPHEN_DESCRIPTION = 'Use hyphens instead of dots to separate application name from base-url' - String SKIP_CRDS_DESCRIPTION = 'Skip installation of CRDs. This requires prior installation of CRDs' - String NAMESPACE_ISOLATION_DESCRIPTION = 'Configure tools to explicitly work with the given namespaces only, and not cluster-wide. This way GOP can be installed without having cluster-admin permissions.' - String MIRROR_REPOS_DESCRIPTION = 'Changes the sources of deployed tools so they are not pulled from the internet, but are pulled from git and work in air-gapped environments.' - String NETPOLS_DESCRIPTION = 'Sets Network Policies' - String CLUSTER_ADMIN_DESCRIPTION = 'Binds ArgoCD controllers to cluster-admin ClusterRole' - String OPENSHIFT_DESCRIPTION = 'When set, openshift specific resources and configurations are applied' - String APPLICATION_PROFIL = 'Use predefined profile (full, only-argocd, operator-mandants aso.)' - - // group metrics - String MONITORING_DESCRIPTION = 'Config parameters for the Monitoring system (prometheus)' - String MONITORING_ENABLE_DESCRIPTION = 'Installs the Kube-Prometheus-Stack. This includes Prometheus, the Prometheus operator, Grafana and some extra resources' - String GRAFANA_URL_DESCRIPTION = 'Sets url for grafana' - String GRAFANA_EMAIL_FROM_DESCRIPTION = 'Notifications, define grafana alerts sender email address' - String GRAFANA_EMAIL_TO_DESCRIPTION = 'Notifications, define grafana alerts recipient email address' - - // group vault / secrets - String SECRETS_DESCRIPTION = 'Config parameters for the secrets management' - String ESO_DESCRIPTION = 'Config parameters for the external secrets operator' - String VAULT_DESCRIPTION = 'Config parameters for the secrets-vault' - String VAULT_ENABLE_DESCRIPTION = "Installs Hashicorp vault and the external secrets operator. Possible values: dev, prod." - String VAULT_URL_DESCRIPTION = 'Sets url for vault ui' - - // group external Mailserver - String MAIL_DESCRIPTION = 'Config parameters for mail servers' - String SMTP_ADDRESS_DESCRIPTION = 'Sets smtp port of external Mailserver' - String SMTP_PORT_DESCRIPTION = 'Sets smtp port of external Mailserver' - String SMTP_USER_DESCRIPTION = 'Sets smtp username for external Mailserver' - String SMTP_PASSWORD_DESCRIPTION = 'Sets smtp password of external Mailserver' - - // group debug - String DEBUG_DESCRIPTION = 'Debug output' - String TRACE_DESCRIPTION = 'Debug + Show each command executed (set -x)' - - // group configuration - String USERNAME_DESCRIPTION = 'Set initial admin username' - String PASSWORD_DESCRIPTION = 'Set initial admin passwords' - String PIPE_YES_DESCRIPTION = 'Skip confirmation' - String NAME_PREFIX_DESCRIPTION = 'Set name-prefix for repos, jobs, namespaces' - String DESTROY_DESCRIPTION = 'Unroll playground' - String CONFIG_FILE_DESCRIPTION = 'Config file for the application' - String CONFIG_MAP_DESCRIPTION = 'Kubernetes configuration map. Should contain a key `config.yaml`.' - String OUTPUT_CONFIG_FILE_DESCRIPTION = 'Output current config as config file as much as possible' - String POD_RESOURCES_DESCRIPTION = 'Write kubernetes resource requests and limits on each pod' - - // group ArgoCD Operator - String ARGOCD_DESCRIPTION = 'Config Parameter for the ArgoCD Operator' - String ARGOCD_ENABLE_DESCRIPTION = 'Install ArgoCD' - String ARGOCD_URL_DESCRIPTION = 'The URL where argocd is accessible. It has to be the full URL with http:// or https://' - String ARGOCD_EMAIL_FROM_DESCRIPTION = 'Notifications, define Argo CD sender email address' - String ARGOCD_EMAIL_TO_USER_DESCRIPTION = 'Notifications, define Argo CD user / app-team recipient email address' - String ARGOCD_EMAIL_TO_ADMIN_DESCRIPTION = 'Notifications, define Argo CD admin recipient email address' - String ARGOCD_OPERATOR_DESCRIPTION = 'Install ArgoCD via an already running ArgoCD Operator' - String ARGOCD_ENV_DESCRIPTION = 'Pass a list of env vars to Argo CD components. Currently only works with operator' - String ARGOCD_RESOURCE_INCLUSIONS_CLUSTER = 'Internal Kubernetes API Server URL https://IP:PORT (kubernetes.default.svc). Needed in argocd-operator resourceInclusions. Use this parameter if argocd.operator=true and NOT running inside a Pod (remote mode). Full URL needed, for example: https://100.125.0.1:443' - String ARGOCD_CUSTOM_NAMESPACE_DESCRIPTION= 'Defines the kubernetes namespace for ArgoCD' - - // group ingress-class - String INGRESS_DESCRIPTION = 'Config parameters for the Ingress Controller' - String INGRESS_ENABLE_DESCRIPTION = 'Sets and enables Ingress Controller' - - // group CERTMANAGER - String CERTMANAGER_DESCRIPTION = 'Config parameters for the Cert Manager' - String CERTMANAGER_ENABLE_DESCRIPTION = 'Sets and enables Cert Manager' - String CERTMANAGER_IMAGE_DESCRIPTION = 'Sets image for Cert Manager' - String CERTMANAGER_WEBHOOK_IMAGE_DESCRIPTION = 'Sets webhook Image for Cert Manager' - String CERTMANAGER_CAINJECTOR_IMAGE_DESCRIPTION = 'Sets cainjector Image for Cert Manager' - String CERTMANAGER_ACME_SOLVER_IMAGE_DESCRIPTION = 'Sets acmeSolver Image for Cert Manager' - String CERTMANAGER_STARTUP_API_CHECK_IMAGE_DESCRIPTION = 'Sets startupAPICheck Image for Cert Manager' - - // group helm - String HELM_CONFIG_DESCRIPTION = 'Common Config parameters for the Helm package manager: Name of Chart (chart), URl of Helm-Repository (repoURL) and Chart Version (version). Note: These config is intended to obtain the chart from a different source (e.g. in air-gapped envs), not to use a different version of a helm chart. Using a different helm chart or version to the one used in the GOP version will likely cause errors.' - String HELM_CONFIG_CHART_DESCRIPTION = 'Name of the Helm chart' - String HELM_CONFIG_REPO_URL_DESCRIPTION = 'Repository url from which the Helm chart should be obtained' - String HELM_CONFIG_VERSION_DESCRIPTION = 'The version of the Helm chart to be installed' - String HELM_CONFIG_IMAGE_DESCRIPTION = 'The image of the Helm chart to be installed' - String HELM_CONFIG_VALUES_DESCRIPTION = 'Helm values of the chart, allows overriding defaults and setting values that are not exposed as explicit configuration' -} + public static final String BINARY_NAME = 'apply-ng' + public static final String APP_NAME = 'gitops-playground (GOP)' + public static final String APP_DESCRIPTION = 'CLI-tool to deploy gitops-playground.' + + // group registry + String REGISTRY_ENABLE_DESCRIPTION = 'Installs a simple cluster-local registry for demonstration purposes. Warning: Registry does not provide authentication!' + String REGISTRY_DESCRIPTION = 'Config parameters for Registry' + String REGISTRY_INTERNAL_PORT_DESCRIPTION = 'Port of registry registry. Ignored when a registry*url params are set' + String REGISTRY_URL_DESCRIPTION = 'The url of your external registry, used for pushing images' + String REGISTRY_PATH_DESCRIPTION = 'Optional when registry-url is set' + String REGISTRY_USERNAME_DESCRIPTION = 'Optional when registry-url is set' + String REGISTRY_PASSWORD_DESCRIPTION = 'Optional when registry-url is set' + + String REGISTRY_PROXY_URL_DESCRIPTION = 'The url of your proxy-registry. Used in pipelines to authorize pull base images. Use in conjunction with petclinic base image. Used in helm charts when create-image-pull-secrets is set. Use in conjunction with helm.*image fields.' + String REGISTRY_PROXY_PATH_DESCRIPTION = 'Optional when registry-proxy-url is set and the registry is running on a non root web path.' + String REGISTRY_PROXY_USERNAME_DESCRIPTION = 'Use with registry-proxy-url, added to Jenkins as credentials and created as pull secrets, when create-image-pull-secrets is set.' + String REGISTRY_PROXY_PASSWORD_DESCRIPTION = 'Use with registry-proxy-url, added to Jenkins as credentials and created as pull secrets, when create-image-pull-secrets is set.' + + String REGISTRY_USERNAME_RO_DESCRIPTION = 'Optional alternative username for registry-url with read-only permissions that is used when create-image-pull-secrets is set.' + String REGISTRY_PASSWORD_RO_DESCRIPTION = 'Optional alternative password for registry-url with read-only permissions that is used when create-image-pull-secrets is set.' + String REGISTRY_CREATE_IMAGE_PULL_SECRETS_DESCRIPTION = 'Create image pull secrets for registry and proxy-registry for all GOP namespaces and helm charts. Uses proxy-username, read-only-username or registry-username (in this order). Use this if your cluster is not auto-provisioned with credentials for your private registries or if you configure individual helm images to be pulled from the proxy-registry that requires authentication.' + + String FEATURES_DESCRIPTION = 'Config parameters for features or tools' + + String CONTENT_DESCRIPTION = 'Config parameters for content, i.e. end-user or tenant applications as opposed to cluster-resources' + + // ContentLoader + String CONTENT_NAMESPACES_DESCRIPTION = 'Additional kubernetes namespaces. These are authorized to Argo CD, supplied with image pull secrets, monitored by prometheus, etc. Namespaces can be templates, e.g. ${config.application.namePrefix}staging' + String CONTENT_REPO_DESCRIPTION = "ContentLoader repos to push into target environment" + String CONTENT_REPO_URL_DESCRIPTION = "URL of the content repo. Mandatory for each type." + String CONTENT_REPO_PATH_DESCRIPTION = "Path within the content repo to process" + String CONTENT_REPO_REF_DESCRIPTION = "Reference for a specific branch, tag, or commit. Emtpy defaults to default branch of the repo. With type MIRROR: ref must not be a commit hash; Choosing a ref only mirrors the ref but does not delete other branches/tags!" + String CONTENT_REPO_TARGET_REF_DESCRIPTION = "Reference for a specific branch or tag in the target repo of a MIRROR or COPY repo. If ref is a tag, targetRef is treated as tag as well. Except: targetRef is full ref like refs/heads/my-branch or refs/tags/my-tag. Empty defaults to the source ref." + String CONTENT_REPO_CREDENTIALS_DESCRIPTION = "Credentials Object to authenticate against content repo. Allows using a K8s Secret" + String CONTENT_REPO_TEMPLATING_DESCRIPTION = "When true, template all files ending in .ftl within the repo" + String CONTENT_REPO_TYPE_DESCRIPTION = "ContentLoader Repos can either be:\ncopied (only the files, starting on ref, starting at path within the repo. Requires target)\n, mirrored (FORCE pushes ref or the whole git repo if no ref set). Requires target, does not allow path and template.)\nfolderBased (folder structure is interpreted as repos. That is, root folder becomes namespace in SCM, sub folders become repository names in SCM, files are copied. Requires target.)" + String CONTENT_REPO_TARGET_DESCRIPTION = "Target repo for the repository in the for of namespace/name. Must contain one slash to separate namespace from name." + String CONTENT_REPO_TARGET_OVERWRITE_MODE_DESCRIPTION = "This defines, how customer repos will be updated.\nINIT - push only if repo does not exist.\nRESET - delete all files after cloning source - files not in content are deleted\nUPGRADE - clone and copy - existing files will be overwritten, files not in content are kept. For type: MIRROR reset and upgrade have same result: in both cases source repo will be force pushed to target repo." + String CONTENT_REPO_CREATE_JENKINS_JOB_DESCRIPTION = "If true, creates a Jenkins job, if jenkinsfile exists in one of the content repo's branches." + String CONTENT_VARIABLES_DESCRIPTION = "Additional variables to use in custom templates." + String CONTENT_STATICSWHITELIST_ENABLED_DESCRIPTION = 'Enables the whitelist for statics in content templating' + String CONTENT_STATICSWHITELIST_DESCRIPTION = 'Whitelist for Statics freemarker is allowing in user templates' + String CONTENT_HELM_RELEASE_NAME_DESCRIPTION = "Logical name of the Helm release. Used as the feature folder name under 'apps/' and as default for 'releaseName' if not set." + + String CONTENT_HELM_RELEASE_REPO_URL_DESCRIPTION = "Helm repository URL to fetch the chart from. Use an HTTP(S) Helm repo (must provide an index.yaml) or an OCI registry URL (oci://...)." + String CONTENT_HELM_RELEASE_CHART_DESCRIPTION = "Helm chart name to install. For HTTP(S) repos this is the chart name from the repo index; for OCI this is the chart artifact name." + String CONTENT_HELM_RELEASE_VERSION_DESCRIPTION = "Chart version to deploy. Required for Helm charts in Argo CD. For HTTP(S) Helm repos you may use a SemVer range like '*' to always pick the newest version. For OCI registries, specify an explicit version/tag." + String CONTENT_HELM_RELEASE_NAMESPACE_DESCRIPTION = "Kubernetes namespace to deploy the release into." + String CONTENT_HELM_RELEASE_RELEASE_NAME_DESCRIPTION = "Helm release name. If empty, the value of 'name' is used." + String CONTENT_HELM_RELEASE_VALUES_FILE_DESCRIPTION = "Optional path to a YAML values file to load Helm values from.The file must be accessible locally on the machine running GOP. Inline 'values' will be merged on top (inline overrides file)." + String CONTENT_HELM_RELEASE_VALUES_DESCRIPTION = "Optional inline Helm values. These values are merged on top of 'valuesFile' (if set) and override keys from the file. Use this for small overrides without maintaining a separate file." + + // group jenkins + String JENKINS_ENABLE_DESCRIPTION = 'Installs Jenkins as CI server' + String JENKINS_SKIP_RESTART_DESCRIPTION = 'Skips restarting Jenkins after plugin installation. Use with caution! If the plugins are not installed up front, the installation will likely fail. The intended use case for this is after the first installation, for config changes only. Do not use on first installation or upgrades.' + String JENKINS_SKIP_PLUGINS_DESCRIPTION = 'Skips plugin installation. Use with caution! If the plugins are not installed up front, the installation will likely fail. The intended use case for this is after the first installation, for config changes only. Do not use on first installation or upgrades.' + String JENKINS_DESCRIPTION = 'Config parameters for Jenkins CI/CD Pipeline Server' + String JENKINS_URL_DESCRIPTION = 'The url of your external jenkins' + String JENKINS_USERNAME_DESCRIPTION = 'Mandatory when jenkins-url is set' + String JENKINS_PASSWORD_DESCRIPTION = 'Mandatory when jenkins-url is set' + String JENKINS_METRICS_USERNAME_DESCRIPTION = 'Mandatory when jenkins-url is set and monitoring enabled' + String JENKINS_METRICS_PASSWORD_DESCRIPTION = 'Mandatory when jenkins-url is set and monitoring enabled' + String MAVEN_CENTRAL_MIRROR_DESCRIPTION = 'URL for maven mirror, used by applications built in Jenkins' + String JENKINS_ADDITIONAL_ENVS_DESCRIPTION = 'Set additional environments to Jenkins' + + // group scmm + String SCM_DESCRIPTION = 'Config parameters for Scm' + String GIT_NAME_DESCRIPTION = 'Sets git author and committer name used for initial commits' + String GIT_EMAIL_DESCRIPTION = 'Sets git author and committer email used for initial commits' + + //MutliTentant + String MULTITENANT_DESCRIPTION = 'Multi Tenant Configs' + + // group remote + String INSECURE_DESCRIPTION = 'Sets insecure-mode in cURL which skips cert validation' + + // group tool configuration + String APPLICATION_DESCRIPTION = 'Application configuration parameter for GOP' + String GRAFANA_IMAGE_DESCRIPTION = 'Sets image for grafana' + String GRAFANA_SIDECAR_IMAGE_DESCRIPTION = 'Sets image for grafana\'s sidecar' + String PROMETHEUS_IMAGE_DESCRIPTION = 'Sets image for prometheus' + String PROMETHEUS_OPERATOR_IMAGE_DESCRIPTION = 'Sets image for prometheus-operator' + String PROMETHEUS_CONFIG_RELOADER_IMAGE_DESCRIPTION = 'Sets image for prometheus-operator\'s config-reloader' + String EXTERNAL_SECRETS_IMAGE_DESCRIPTION = 'Sets image for external secrets operator' + String EXTERNAL_SECRETS_CERT_CONTROLLER_IMAGE_DESCRIPTION = 'Sets image for external secrets operator\'s controller' + String EXTERNAL_SECRETS_WEBHOOK_IMAGE_DESCRIPTION = 'Sets image for external secrets operator\'s webhook' + String VAULT_IMAGE_DESCRIPTION = 'Sets image for vault' + String BASE_URL_DESCRIPTION = 'the external base url (TLD) for all tools, e.g. https://example.com or http://localhost:8080. The individual -url params for argocd, grafana and vault take precedence.' + String URL_SEPARATOR_HYPHEN_DESCRIPTION = 'Use hyphens instead of dots to separate application name from base-url' + String SKIP_CRDS_DESCRIPTION = 'Skip installation of CRDs. This requires prior installation of CRDs' + String NAMESPACE_ISOLATION_DESCRIPTION = 'Configure tools to explicitly work with the given namespaces only, and not cluster-wide. This way GOP can be installed without having cluster-admin permissions.' + String MIRROR_REPOS_DESCRIPTION = 'Changes the sources of deployed tools so they are not pulled from the internet, but are pulled from git and work in air-gapped environments.' + String NETPOLS_DESCRIPTION = 'Sets Network Policies' + String CLUSTER_ADMIN_DESCRIPTION = 'Binds ArgoCD controllers to cluster-admin ClusterRole' + String OPENSHIFT_DESCRIPTION = 'When set, openshift specific resources and configurations are applied' + String APPLICATION_PROFIL = 'Use predefined profile (full, only-argocd, operator-mandants aso.)' + + // group metrics + String MONITORING_DESCRIPTION = 'Config parameters for the Monitoring system (prometheus)' + String MONITORING_ENABLE_DESCRIPTION = 'Installs the Kube-Prometheus-Stack. This includes Prometheus, the Prometheus operator, Grafana and some extra resources' + String GRAFANA_URL_DESCRIPTION = 'Sets url for grafana' + String GRAFANA_EMAIL_FROM_DESCRIPTION = 'Notifications, define grafana alerts sender email address' + String GRAFANA_EMAIL_TO_DESCRIPTION = 'Notifications, define grafana alerts recipient email address' + + // group vault / secrets + String SECRETS_DESCRIPTION = 'Config parameters for the secrets management' + String ESO_DESCRIPTION = 'Config parameters for the external secrets operator' + String VAULT_DESCRIPTION = 'Config parameters for the secrets-vault' + String VAULT_ENABLE_DESCRIPTION = "Installs Hashicorp vault and the external secrets operator. Possible values: dev, prod." + String VAULT_URL_DESCRIPTION = 'Sets url for vault ui' + + // group external Mailserver + String MAIL_DESCRIPTION = 'Config parameters for mail servers' + String SMTP_ADDRESS_DESCRIPTION = 'Sets smtp port of external Mailserver' + String SMTP_PORT_DESCRIPTION = 'Sets smtp port of external Mailserver' + String SMTP_USER_DESCRIPTION = 'Sets smtp username for external Mailserver' + String SMTP_PASSWORD_DESCRIPTION = 'Sets smtp password of external Mailserver' + + // group debug + String DEBUG_DESCRIPTION = 'Debug output' + String TRACE_DESCRIPTION = 'Debug + Show each command executed (set -x)' + + // group configuration + String USERNAME_DESCRIPTION = 'Set initial admin username' + String PASSWORD_DESCRIPTION = 'Set initial admin passwords' + String PIPE_YES_DESCRIPTION = 'Skip confirmation' + String NAME_PREFIX_DESCRIPTION = 'Set name-prefix for repos, jobs, namespaces' + String DESTROY_DESCRIPTION = 'Unroll playground' + String CONFIG_FILE_DESCRIPTION = 'Config file for the application' + String CONFIG_MAP_DESCRIPTION = 'Kubernetes configuration map. Should contain a key `config.yaml`.' + String OUTPUT_CONFIG_FILE_DESCRIPTION = 'Output current config as config file as much as possible' + String POD_RESOURCES_DESCRIPTION = 'Write kubernetes resource requests and limits on each pod' + + // group ArgoCD Operator + String ARGOCD_DESCRIPTION = 'Config Parameter for the ArgoCD Operator' + String ARGOCD_ENABLE_DESCRIPTION = 'Install ArgoCD' + String ARGOCD_URL_DESCRIPTION = 'The URL where argocd is accessible. It has to be the full URL with http:// or https://' + String ARGOCD_EMAIL_FROM_DESCRIPTION = 'Notifications, define Argo CD sender email address' + String ARGOCD_EMAIL_TO_USER_DESCRIPTION = 'Notifications, define Argo CD user / app-team recipient email address' + String ARGOCD_EMAIL_TO_ADMIN_DESCRIPTION = 'Notifications, define Argo CD admin recipient email address' + String ARGOCD_OPERATOR_DESCRIPTION = 'Install ArgoCD via an already running ArgoCD Operator' + String ARGOCD_INSTALL_OPERATOR_DESCRIPTION = 'Installs ArgoCD Operator itself' + String ARGOCD_INSTALL_OPERATOR_VERSION_DESCRIPTION = 'Operator Version to install' + String ARGOCD_ENV_DESCRIPTION = 'Pass a list of env vars to Argo CD components. Currently only works with operator' + String ARGOCD_RESOURCE_INCLUSIONS_CLUSTER = 'Internal Kubernetes API Server URL https://IP:PORT (kubernetes.default.svc). Needed in argocd-operator resourceInclusions. Use this parameter if argocd.operator=true and NOT running inside a Pod (remote mode). Full URL needed, for example: https://100.125.0.1:443' + String ARGOCD_CUSTOM_NAMESPACE_DESCRIPTION = 'Defines the kubernetes namespace for ArgoCD' + + // group ingress-class + String INGRESS_DESCRIPTION = 'Config parameters for the Ingress Controller' + String INGRESS_ENABLE_DESCRIPTION = 'Sets and enables Ingress Controller' + + // group CERTMANAGER + String CERTMANAGER_DESCRIPTION = 'Config parameters for the Cert Manager' + String CERTMANAGER_ENABLE_DESCRIPTION = 'Sets and enables Cert Manager' + String CERTMANAGER_IMAGE_DESCRIPTION = 'Sets image for Cert Manager' + String CERTMANAGER_WEBHOOK_IMAGE_DESCRIPTION = 'Sets webhook Image for Cert Manager' + String CERTMANAGER_CAINJECTOR_IMAGE_DESCRIPTION = 'Sets cainjector Image for Cert Manager' + String CERTMANAGER_ACME_SOLVER_IMAGE_DESCRIPTION = 'Sets acmeSolver Image for Cert Manager' + String CERTMANAGER_STARTUP_API_CHECK_IMAGE_DESCRIPTION = 'Sets startupAPICheck Image for Cert Manager' + + // group helm + String HELM_CONFIG_DESCRIPTION = 'Common Config parameters for the Helm package manager: Name of Chart (chart), URl of Helm-Repository (repoURL) and Chart Version (version). Note: These config is intended to obtain the chart from a different source (e.g. in air-gapped envs), not to use a different version of a helm chart. Using a different helm chart or version to the one used in the GOP version will likely cause errors.' + String HELM_CONFIG_CHART_DESCRIPTION = 'Name of the Helm chart' + String HELM_CONFIG_REPO_URL_DESCRIPTION = 'Repository url from which the Helm chart should be obtained' + String HELM_CONFIG_VERSION_DESCRIPTION = 'The version of the Helm chart to be installed' + String HELM_CONFIG_IMAGE_DESCRIPTION = 'The image of the Helm chart to be installed' + String HELM_CONFIG_VALUES_DESCRIPTION = 'Helm values of the chart, allows overriding defaults and setting values that are not exposed as explicit configuration' +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy index 2ed599140..0c5acb5a4 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy @@ -110,7 +110,7 @@ class ArgoCD extends Feature { if (config.features.argocd.operator) { generateRBAC() - installOperator() + if (config.features.argocd.installOperator) { installOperator() } deployWithOperator() } else { if (this.config.features.argocd?.values) { @@ -186,12 +186,12 @@ class ArgoCD extends Feature { k8sClient.applyYaml("${argocdRbacPath} --recursive") } - private static void installOperator() { + private void installOperator() { def cmd = """ git clone https://github.com/argoproj-labs/argocd-operator && cd argocd-operator && -git checkout release-0.17 && -make deploy IMG=quay.io/argoprojlabs/argocd-operator:v0.17.0 && +git checkout release-${config.features.argocd.operatorVersion} && +make deploy IMG=quay.io/argoprojlabs/argocd-operator:v${config.features.argocd.operatorVersion}.0 && rm -Rf ../argocd-operator/ """ @@ -199,16 +199,20 @@ rm -Rf ../argocd-operator/ process.in.eachLine { log.debug(it) } process.err.eachLine { log.debug(it) } process.waitFor() + log.info("Successfully installed ArgoCD Operator version ${config.features.argocd.operatorVersion}") } private void deployWithHelm() { - deployHelmChart('argo-cd', - 'argo-cd', + addHelmValuesData('argocd', [host: config.features.argocd.url ? new URL(config.features.argocd.url).host : '']) + + deployHelmChart('argocd', + 'argocd', namespace, config.features.argocd.helm, HELM_VALUES_PATH, - config) + config, + true) log.debug("Setting new argocd admin password") // Set admin password imperatively here instead of values.yaml, because we don't want it to show in git repo diff --git a/src/main/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategy.groovy b/src/main/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategy.groovy index 9de357b02..67c07114b 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategy.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategy.groovy @@ -5,156 +5,135 @@ import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.utils.FileSystemUtils -import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator -import com.fasterxml.jackson.dataformat.yaml.YAMLMapper -import groovy.util.logging.Slf4j -import jakarta.inject.Singleton import java.nio.file.Path +import jakarta.inject.Singleton +import groovy.util.logging.Slf4j + +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper @Singleton @Slf4j class ArgoCdApplicationStrategy implements DeploymentStrategy { - private FileSystemUtils fileSystemUtils - private Config config - private final GitRepoFactory gitRepoProvider - - private GitHandler gitHandler - - ArgoCdApplicationStrategy( - Config config, - FileSystemUtils fileSystemUtils, - GitRepoFactory gitRepoProvider, - GitHandler gitHandler - ) { - this.gitRepoProvider = gitRepoProvider - this.fileSystemUtils = fileSystemUtils - this.config = config - this.gitHandler = gitHandler - } - - @Override - @SuppressWarnings('GroovyGStringKey') - // Using dynamic strings as keys seems an easy to read way to avoid more ifs - void deployFeature(String repoURL, String repoName, String chartOrPath, String version, String namespace, - String releaseName, Path helmValuesPath, RepoType repoType) { - log.trace("Deploying helm chart via ArgoCD: ${releaseName}. Reading values from ${helmValuesPath}") - def namePrefix = config.application.namePrefix - def shallCreateNamespace = config.features['argocd']['operator'] ? "CreateNamespace=false" : "CreateNamespace=true" - - GitRepo clusterResourcesRepo = gitRepoProvider.getRepo('argocd/cluster-resources', this.gitHandler.resourcesScm) - clusterResourcesRepo.cloneRepo() - - String project = "cluster-resources" - String namespaceName = "${namePrefix}argocd" - String featureName = repoName - //DedicatedInstances - if (config.multiTenant.useDedicatedInstance) { - repoName = "${config.application.namePrefix}${repoName}" - namespaceName = "${config.multiTenant.centralArgocdNamespace}" - project = config.application.namePrefix.replaceFirst(/-$/, "") - } - - // Feature-Name -> Ordner under apps/ - String featurePath = "apps/${featureName}" - - // --- ensure folders exist before writing files --- - String repoRoot = clusterResourcesRepo.getAbsoluteLocalRepoTmpDir() - Path.of(repoRoot, featurePath).toFile().mkdirs() - - // 1) GOP-managed values (may be overwritten each run) - String gopValuesPath = "${featurePath}/${featureName}-gop-helm.yaml" // relative to repo-root - def inlineValues = helmValuesPath.toFile().text - clusterResourcesRepo.writeFile(gopValuesPath, inlineValues) - - // 2) User values (must NEVER be overwritten by GOP) - String userValuesPath = "${featurePath}/${featureName}-user-values.yaml" - Path userValuesAbsPath = Path.of(repoRoot, userValuesPath) - if (!userValuesAbsPath.toFile().exists()) { - clusterResourcesRepo.writeFile(userValuesPath, "") - } - - // 1) helm source (external chart source) - def helmSource = [ - repoURL : repoURL, - (chooseKeyChartOrPath(repoType)) : chartOrPath, - targetRevision : version, - helm : [ - releaseName: releaseName, - valueFiles : [ - "\$values/${gopValuesPath}".toString(), - "\$values/${userValuesPath}".toString() - ], - ignoreMissingValueFiles: true - ] - ] - - // 2) Git source for values - // - repoURL: cluster-resources repo - // - ref: values → used in valueFiles as $values - // - path: apps/ → additional manifests - def featureRepoUrl = "${clusterResourcesRepo.gitProvider.repoPrefix()}argocd/cluster-resources.git".toString() - def gitSource = [ - repoURL : featureRepoUrl, - targetRevision: "main", - ref : "values", - path : featurePath, - directory : [recurse: true] - ] - - def sources = [helmSource, gitSource] - - // Prepare ArgoCD Application YAML - def yamlMapper = YAMLMapper.builder() - .enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE) - .build() - - def yamlResult = yamlMapper.writeValueAsString([ - apiVersion: "argoproj.io/v1alpha1", - kind : "Application", - metadata : [ - name : repoName, - namespace: namespaceName - ], - spec : [ - destination: [ - server : "https://kubernetes.default.svc", - namespace: namespace - ], - project : project, - sources : sources, - syncPolicy : [ - automated : [ - prune : true, - selfHeal: true - ], - syncOptions: [ - // So that we can apply very large resources (e.g. prometheus CRD) - "ServerSideApply=true", - // Create namespaces for helm charts (while not using the argocd-operater mode) - shallCreateNamespace - ] - ] - ] - ]) - - String appManifestPath="apps/argocd/applications/${releaseName}.yaml" - - clusterResourcesRepo.writeFile(appManifestPath, yamlResult) - - log.debug("Deploying helm release ${releaseName} basing on chart ${chartOrPath} from ${repoURL}, version " + - "${version}, into namespace ${namespace}. Using Argo CD application:\n${yamlResult}") - - clusterResourcesRepo.commitAndPush("Added $repoName/$chartOrPath to ArgoCD") - } - - String chooseKeyChartOrPath(RepoType repoType) { - switch (repoType) { - case RepoType.HELM: 'chart' - break - case RepoType.GIT: 'path' - break - default: throw new RuntimeException("Repo type ${repoType} not implemented for ${this.class.simpleName}") - } - } + private FileSystemUtils fileSystemUtils + private Config config + private final GitRepoFactory gitRepoProvider + + private GitHandler gitHandler + + ArgoCdApplicationStrategy( + Config config, + FileSystemUtils fileSystemUtils, + GitRepoFactory gitRepoProvider, + GitHandler gitHandler) { + this.gitRepoProvider = gitRepoProvider + this.fileSystemUtils = fileSystemUtils + this.config = config + this.gitHandler = gitHandler + } + + @Override + @SuppressWarnings('GroovyGStringKey') + // Using dynamic strings as keys seems an easy to read way to avoid more ifs + void deployFeature( + String repoURL, String repoName, String chartOrPath, String version, String namespace, + String releaseName, Path helmValuesPath, RepoType repoType) { + log.trace("Deploying helm chart via ArgoCD: ${releaseName}. Reading values from ${helmValuesPath}") + def namePrefix = config.application.namePrefix + def shallCreateNamespace = config.features['argocd']['operator'] ? "CreateNamespace=false" : "CreateNamespace=true" + + GitRepo clusterResourcesRepo = gitRepoProvider.getRepo('argocd/cluster-resources', this.gitHandler.resourcesScm) + clusterResourcesRepo.cloneRepo() + + String project = "cluster-resources" + String namespaceName = "${namePrefix}argocd" + String featureName = repoName + //DedicatedInstances + if (config.multiTenant.useDedicatedInstance) { + repoName = "${config.application.namePrefix}${repoName}" + namespaceName = "${config.multiTenant.centralArgocdNamespace}" + project = config.application.namePrefix.replaceFirst(/-$/, "") + } + + // Feature-Name -> Ordner under apps/ + String featurePath = "apps/${featureName}" + + // --- ensure folders exist before writing files --- + String repoRoot = clusterResourcesRepo.getAbsoluteLocalRepoTmpDir() + Path.of(repoRoot, featurePath).toFile().mkdirs() + + // 1) GOP-managed values (may be overwritten each run) + String gopValuesPath = "${featurePath}/${featureName}-gop-helm.yaml" + // relative to repo-root + def inlineValues = helmValuesPath.toFile().text + clusterResourcesRepo.writeFile(gopValuesPath, inlineValues) + + // 2) User values (must NEVER be overwritten by GOP) + String userValuesPath = "${featurePath}/${featureName}-user-values.yaml" + Path userValuesAbsPath = Path.of(repoRoot, userValuesPath) + if (!userValuesAbsPath.toFile().exists()) { + clusterResourcesRepo.writeFile(userValuesPath, "") + } + + // 1) helm source (external chart source) + def helmSource = [repoURL : repoURL, + (chooseKeyChartOrPath(repoType)): chartOrPath, + targetRevision : version, + helm : [releaseName : releaseName, + valueFiles : ["\$values/${gopValuesPath}".toString(), + "\$values/${userValuesPath}".toString()], + ignoreMissingValueFiles: true]] + + // 2) Git source for values + // - repoURL: cluster-resources repo + // - ref: values → used in valueFiles as $values + // - path: apps/ → additional manifests + def featureRepoUrl = "${clusterResourcesRepo.gitProvider.repoPrefix()}argocd/cluster-resources.git".toString() + def gitSource = [repoURL : featureRepoUrl, + targetRevision: "main", + ref : "values", + path : featurePath, + directory : [recurse: true]] + + def sources = [helmSource, gitSource] + + // Prepare ArgoCD Application YAML + def yamlMapper = YAMLMapper.builder() + .enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE) + .build() + + def yamlResult = yamlMapper.writeValueAsString([apiVersion: "argoproj.io/v1alpha1", + kind : "Application", + metadata : [name : repoName, + namespace: namespaceName], + spec : [destination: [server : "https://kubernetes.default.svc", + namespace: namespace], + project : project, + sources : sources, + syncPolicy : [automated : [prune : true, + selfHeal: true], + syncOptions: [// So that we can apply very large resources (e.g. prometheus CRD) + "ServerSideApply=true", + // Create namespaces for helm charts (while not using the argocd-operater mode) + shallCreateNamespace]]]]) + + String appManifestPath = "apps/argocd/applications/${releaseName}.yaml" + + clusterResourcesRepo.writeFile(appManifestPath, yamlResult, false) + + log.debug("Deploying helm release ${releaseName} basing on chart ${chartOrPath} from ${repoURL}, version " + "${version}, into namespace ${namespace}. Using Argo CD application:\n${yamlResult}") + + clusterResourcesRepo.commitAndPush("Added $repoName/$chartOrPath to ArgoCD") + } + + String chooseKeyChartOrPath(RepoType repoType) { + switch (repoType) { + case RepoType.HELM: 'chart' + break + case RepoType.GIT: 'path' + break + default: throw new RuntimeException("Repo type ${repoType} not implemented for ${this.class.simpleName}") + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy index ae9b4b00b..372051f95 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/GitHandler.groovy @@ -91,7 +91,7 @@ class GitHandler extends Feature { case ScmProviderType.SCM_MANAGER: String prefixedNamespace = "${config.application.namePrefix}scm-manager" config.scm.scmManager.namespace = prefixedNamespace - this.tenant = new ScmManager(this.config, config.scm.scmManager, deployerProvider.get(), k8sClient, networkingUtils) + this.tenant = new ScmManager(this.config, config.scm.scmManager, deployer, k8sClient, networkingUtils) (tenant as ScmManager).init(true) break default: @@ -104,7 +104,7 @@ class GitHandler extends Feature { this.central = new Gitlab(this.config, this.config.multiTenant.gitlab) break case ScmProviderType.SCM_MANAGER: - this.central = new ScmManager(this.config, config.multiTenant.scmManager, deployerProvider.get(), k8sClient, networkingUtils) + this.central = new ScmManager(this.config, config.multiTenant.scmManager, deployer, k8sClient, networkingUtils) break default: throw new IllegalArgumentException("Unsupported SCM-Central provider: ${config.scm.scmProviderType}") diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy index badf2635b..c9f4535c0 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy @@ -7,6 +7,7 @@ import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.MapUtils import com.cloudogu.gitops.utils.TemplatingEngine +import java.nio.file.Path import groovy.util.logging.Slf4j @Slf4j @@ -14,10 +15,13 @@ class ScmManagerSetup { private ScmManager scmManager + private static final SCMM_RELEASE_NAME = 'scmm' static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/scm-manager/templates/values.ftl.yaml" + Path tempValuesPath ScmManagerSetup(ScmManager scmManager) { this.scmManager = scmManager + prepareHelmValues() } void waitForScmmAvailable(int timeoutSeconds = 180, int intervalMillis = 5000, int startDelay = 0) { @@ -51,48 +55,41 @@ class ScmManagerSetup { log.info("ScmManager Setup finished!") } - void setupHelm() { - def releaseName = 'scmm' - - def templatedMap = TemplatingEngine.templateToMap(HELM_VALUES_PATH, [host : this.scmManager.scmmConfig.ingress, + void prepareHelmValues() { + def templatedMap = TemplatingEngine.templateToMap(HELM_VALUES_PATH, [config : this.scmManager.config, + host : this.scmManager.scmmConfig.ingress, username : this.scmManager.scmmConfig.credentials.username, password : this.scmManager.scmmConfig.credentials.password, helm : this.scmManager.scmmConfig.helm, - releaseName: releaseName]) + releaseName: SCMM_RELEASE_NAME]) def helmConfig = this.scmManager.scmmConfig.helm def mergedMap = MapUtils.deepMerge(helmConfig.values, templatedMap) - def tempValuesPath = new FileSystemUtils().writeTempFile(mergedMap) + tempValuesPath = new FileSystemUtils().writeTempFile(mergedMap) + } + + void setupHelm() { + def helmConfig = this.scmManager.scmmConfig.helm scmManager.deployer.helmStrategy.deployFeature(helmConfig.repoURL, 'scm-manager', helmConfig.chart, helmConfig.version, this.scmManager.scmmConfig.namespace, - releaseName, + SCMM_RELEASE_NAME, tempValuesPath, DeploymentStrategy.RepoType.HELM) } void createArgocdApplication() { - def releaseName = 'scmm' - - def templatedMap = TemplatingEngine.templateToMap(HELM_VALUES_PATH, [host : this.scmManager.scmmConfig.ingress, - username : this.scmManager.scmmConfig.credentials.username, - password : this.scmManager.scmmConfig.credentials.password, - helm : this.scmManager.scmmConfig.helm, - releaseName: releaseName]) - def helmConfig = this.scmManager.scmmConfig.helm - def mergedMap = MapUtils.deepMerge(helmConfig.values, templatedMap) - def tempValuesPath = new FileSystemUtils().writeTempFile(mergedMap) - scmManager.deployer.argoCdStrategy.deployFeature(helmConfig.repoURL, - 'scm-manager', - helmConfig.chart, - helmConfig.version, - this.scmManager.scmmConfig.namespace, - releaseName, - tempValuesPath, - DeploymentStrategy.RepoType.HELM) + scmManager.deployer.argoCdStrategyProvider.get().deployFeature(helmConfig.repoURL, + 'scm-manager', + helmConfig.chart, + helmConfig.version, + this.scmManager.scmmConfig.namespace, + SCMM_RELEASE_NAME, + tempValuesPath, + DeploymentStrategy.RepoType.HELM) } def installScmmPlugins() { From b6eba10977d9171ede9511d20e18c8bdd112e44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Wed, 29 Apr 2026 13:09:43 +0200 Subject: [PATCH 09/10] merge fixed --- .../scmmanager/ScmManagerSetup.groovy | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy index c9f4535c0..14e480c12 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy @@ -56,39 +56,40 @@ class ScmManagerSetup { } void prepareHelmValues() { - def templatedMap = TemplatingEngine.templateToMap(HELM_VALUES_PATH, [config : this.scmManager.config, - host : this.scmManager.scmmConfig.ingress, - username : this.scmManager.scmmConfig.credentials.username, - password : this.scmManager.scmmConfig.credentials.password, - helm : this.scmManager.scmmConfig.helm, - releaseName: SCMM_RELEASE_NAME]) - - def helmConfig = this.scmManager.scmmConfig.helm - def mergedMap = MapUtils.deepMerge(helmConfig.values, templatedMap) + Map templateVars = [config : this.scmManager.config, + host : this.scmManager.scmmConfig.ingress, + username : this.scmManager.scmmConfig.credentials.username, + password : this.scmManager.scmmConfig.credentials.password, + helm : this.scmManager.scmmConfig.helm, + releaseName: SCMM_RELEASE_NAME] + + Map templatedMap = TemplatingEngine.templateToMap(HELM_VALUES_PATH, templateVars) + Map helmConfig = this.scmManager.scmmConfig.helm as Map + Map mergedMap = MapUtils.deepMerge(helmConfig.values as Map, templatedMap) tempValuesPath = new FileSystemUtils().writeTempFile(mergedMap) } void setupHelm() { - def helmConfig = this.scmManager.scmmConfig.helm - scmManager.deployer.helmStrategy.deployFeature(helmConfig.repoURL, + Map helmConfig = this.scmManager.scmmConfig.helm as Map + scmManager.deployer.helmStrategy.deployFeature(helmConfig.repoURL as String, 'scm-manager', - helmConfig.chart, - helmConfig.version, + helmConfig.chart as String, + helmConfig.version as String, this.scmManager.scmmConfig.namespace, SCMM_RELEASE_NAME, - tempValuesPath, + tempValuesPath as Path, DeploymentStrategy.RepoType.HELM) } void createArgocdApplication() { - def helmConfig = this.scmManager.scmmConfig.helm - scmManager.deployer.argoCdStrategyProvider.get().deployFeature(helmConfig.repoURL, + Map helmConfig = this.scmManager.scmmConfig.helm as Map + scmManager.deployer.argoCdStrategyProvider.get().deployFeature(helmConfig.repoURL as String, 'scm-manager', - helmConfig.chart, - helmConfig.version, + helmConfig.chart as String, + helmConfig.version as String, this.scmManager.scmmConfig.namespace, SCMM_RELEASE_NAME, - tempValuesPath, + tempValuesPath as Path, DeploymentStrategy.RepoType.HELM) } From 6e24674747044fd094f7919e434939245951c726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hu=C3=9Fmann?= Date: Thu, 30 Apr 2026 09:18:40 +0200 Subject: [PATCH 10/10] merge fixed --- .../gitops/features/ContentLoader.groovy | 16 +++++++++------- .../providers/scmmanager/ScmManagerSetup.groovy | 8 ++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy b/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy index c5abc3a74..165a259e1 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy @@ -165,13 +165,15 @@ class ContentLoader extends Feature { // always write a temp values file and pass its path to deployHelmChart Path mergedValuesFile = fileSystemUtils.writeTempFile(mergedValues) - - deployHelmChart(helmRelease.name, - helmRelease.releaseName ?: helmRelease.name, - helmRelease.namespace, - helmConfig, - mergedValuesFile.toString(), - config) + String mergedValuesFilePath = mergedValuesFile.toString() + + deployHelmChart(helmRelease.name as String, + (helmRelease.releaseName ?: helmRelease.name) as String, + helmRelease.namespace as String, + helmConfig as Config.HelmConfigWithValues, + mergedValuesFilePath as String, + config as Config, + false) } } diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy index 14e480c12..5d04b4102 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerSetup.groovy @@ -13,11 +13,11 @@ import groovy.util.logging.Slf4j @Slf4j class ScmManagerSetup { - private ScmManager scmManager + private static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/scm-manager/templates/values.ftl.yaml" + private static final String SCMM_RELEASE_NAME = 'scmm' - private static final SCMM_RELEASE_NAME = 'scmm' - static final String HELM_VALUES_PATH = "argocd/cluster-resources/apps/scm-manager/templates/values.ftl.yaml" - Path tempValuesPath + private final ScmManager scmManager + private Path tempValuesPath ScmManagerSetup(ScmManager scmManager) { this.scmManager = scmManager