diff --git a/src/main/groovy/com/cloudogu/gitops/Feature.groovy b/src/main/groovy/com/cloudogu/gitops/Feature.groovy index cd1aa4495..545998aa6 100644 --- a/src/main/groovy/com/cloudogu/gitops/Feature.groovy +++ b/src/main/groovy/com/cloudogu/gitops/Feature.groovy @@ -1,12 +1,10 @@ package com.cloudogu.gitops -import com.cloudogu.gitops.utils.MapUtils +import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.utils.TemplatingEngine import groovy.util.logging.Slf4j import groovy.yaml.YamlSlurper -import java.nio.file.Path - /** * A single tool to be deployed by GOP. * @@ -86,4 +84,15 @@ abstract class Feature { */ 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/GitopsPlaygroundCli.groovy b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCli.groovy index 4cbdd106b..5bf77b984 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCli.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCli.groovy @@ -7,7 +7,9 @@ import ch.qos.logback.classic.encoder.PatternLayoutEncoder import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.core.ConsoleAppender import com.cloudogu.gitops.Application +import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.ApplicationConfigurator +import com.cloudogu.gitops.config.CommonFeatureConfig import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.schema.JsonSchemaValidator import com.cloudogu.gitops.destroy.Destroyer @@ -60,7 +62,12 @@ class GitopsPlaygroundCli { return ReturnCode.SUCCESS } + def context = createApplicationContext() + Application app = context.getBean(Application) + def config = readConfigs(args) + runHook(app, 'preConfigInit', config) + if (config.application.outputConfigFile) { println(config.toYaml(false)) return ReturnCode.SUCCESS @@ -70,8 +77,9 @@ class GitopsPlaygroundCli { // eg a simple docker run .. --help should not fail with connection refused config = applicationConfigurator.initConfig(config) log.debug("Actual config: ${config.toYaml(true)}") + runHook(app, 'postConfigInit', config) - def context = createApplicationContext() + context = createApplicationContext() register(config, context) if (config.application.destroy) { @@ -87,7 +95,7 @@ class GitopsPlaygroundCli { if (!confirm("Applying gitops playground to kubernetes cluster '${k8sClient.currentContext}'.", config)) { return ReturnCode.NOT_CONFIRMED } - Application app = context.getBean(Application) + app = context.getBean(Application) app.start() printWelcomeScreen() @@ -208,8 +216,6 @@ class GitopsPlaygroundCli { log.debug("Writing CLI params into config") Config mergedConfig = Config.fromMap(mergedConfigs) new CommandLine(mergedConfig).parseArgs(args) - - applicationConfigurator.validateConfig(mergedConfig) return mergedConfig } @@ -241,4 +247,15 @@ class GitopsPlaygroundCli { |----------------------------------------------------------------------------------------------| ''' } + + static void runHook(Application app, String methodName, def config) { + ([new CommonFeatureConfig(), *app.features]).each { feature -> + // Executing only the method if the derived feature class has implemented the passed methodName + def mm = feature.metaClass.getMetaMethod(methodName, config) + if (mm && mm.declaringClass.theClass != Feature) { + log.debug("Executing ${methodName} hook on feature ${feature.class.name}") + mm.invoke(feature, config) + } + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/config/ApplicationConfigurator.groovy b/src/main/groovy/com/cloudogu/gitops/config/ApplicationConfigurator.groovy index 716e42363..9b6f6157b 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/ApplicationConfigurator.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/ApplicationConfigurator.groovy @@ -4,9 +4,6 @@ package com.cloudogu.gitops.config import com.cloudogu.gitops.utils.FileSystemUtils import groovy.util.logging.Slf4j -import static com.cloudogu.gitops.config.Config.ContentRepoType -import static com.cloudogu.gitops.config.Config.ContentSchema.ContentRepositorySchema - @Slf4j class ApplicationConfigurator { @@ -32,8 +29,6 @@ class ApplicationConfigurator { addFeatureConfig(newConfig) - validateEnvConfigForArgoCDOperator(newConfig) - evaluateBaseUrl(newConfig) setResourceInclusionsCluster(newConfig) @@ -258,95 +253,6 @@ class ApplicationConfigurator { return newUrl } - /** - * Make sure that config does not contain contradictory values. - * Throws RuntimeException which meaningful message, if invalid. - */ - void validateConfig(Config configToSet) { - validateScmmAndJenkinsAreBothSet(configToSet) - validateMirrorReposHelmChartFolderSet(configToSet) - validateContent(configToSet) - } - - private void validateMirrorReposHelmChartFolderSet(Config configToSet) { - if (configToSet.application.mirrorRepos && !configToSet.application.localHelmChartFolder) { - // This should only happen when run outside the image, i.e. during development - throw new RuntimeException("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") - } - } - - static void validateContent(Config config) { - 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 - } - } - } - - private void validateScmmAndJenkinsAreBothSet(Config configToSet) { - if (configToSet.jenkins.active && - (configToSet.scm.scmManager.url && !configToSet.jenkins.url || - !configToSet.scm.scmManager.url && configToSet.jenkins.url)) { - throw new RuntimeException('When setting jenkins URL, scmm URL must also be set and the other way round') - } - } - - // Validate that the env list has proper maps with 'name' and 'value' - private static void validateEnvConfigForArgoCDOperator(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.") - } - private void setResourceInclusionsCluster(Config configToSet) { // Return early if NOT deploying via operator if (!configToSet.features.argocd.operator) { diff --git a/src/main/groovy/com/cloudogu/gitops/config/CommonFeatureConfig.groovy b/src/main/groovy/com/cloudogu/gitops/config/CommonFeatureConfig.groovy new file mode 100644 index 000000000..fb8841974 --- /dev/null +++ b/src/main/groovy/com/cloudogu/gitops/config/CommonFeatureConfig.groovy @@ -0,0 +1,34 @@ +package com.cloudogu.gitops.config + +import com.cloudogu.gitops.Feature +import groovy.util.logging.Slf4j + +@Slf4j +class CommonFeatureConfig extends Feature { + @Override + void preConfigInit(Config configToSet) { + validateConfig(configToSet) + } + + /** + * Make sure that config does not contain contradictory values. + * Throws RuntimeException which meaningful message, if invalid. + */ + void validateConfig(Config configToSet) { + validateMirrorReposHelmChartFolderSet(configToSet) + } + + private void validateMirrorReposHelmChartFolderSet(Config configToSet) { + if (configToSet.application.mirrorRepos && !configToSet.application.localHelmChartFolder) { + // This should only happen when run outside the image, i.e. during development + throw new RuntimeException("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") + } + } + + @Override + boolean isEnabled() { + return false + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy b/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy index 93ab21b12..64679fb24 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy @@ -73,6 +73,48 @@ class ContentLoader extends Feature { } + @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 + } + } + } + void createImagePullSecrets() { if (config.registry.createImagePullSecrets) { String registryUsername = config.registry.readOnlyUsername ?: config.registry.username 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 0aff2a8a6..58dc1cf81 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy @@ -71,6 +71,27 @@ class ArgoCD extends Feature { 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() { diff --git a/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy b/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy index 7662c6fb0..91cbf9fe2 100644 --- a/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy @@ -1,12 +1,24 @@ package com.cloudogu.gitops 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.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.utils.HelmClient +import com.cloudogu.gitops.utils.K8sClient 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 @@ -23,6 +35,13 @@ class ApplicationConfiguratorTest { 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', @@ -69,6 +88,16 @@ class ApplicationConfiguratorTest { 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) + + + GitHandler gitHandler = new GitHandlerForTests(testConfig, scmManagerMock) + featureContent = Mockito.spy(new ContentLoader(testConfig, k8sClient, gitRepoFactory, Mockito.mock(Jenkins), gitHandler)) + featureArgoCd = Mockito.spy(new ArgoCD(testConfig, k8sClient, helmClient, fileSystemUtils, gitRepoFactory, gitHandler)) } @Test @@ -108,38 +137,13 @@ class ApplicationConfiguratorTest { assertThat(actualConfig.jenkins.urlForScm).isEmpty() } - @Test - void 'Fails if jenkins is external and scmm is internal or the other way round'() { - testConfig.jenkins.active = true - testConfig.jenkins.url = 'external' - testConfig.scm.scmManager.url = '' - - def exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) - } - assertThat(exception.message).isEqualTo('When setting jenkins URL, scmm URL must also be set and the other way round') - - testConfig.jenkins.url = '' - testConfig.scm.scmManager.url = 'external' - - exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) - } - assertThat(exception.message).isEqualTo('When setting jenkins URL, scmm URL must also be set and the other way round') - - - testConfig.jenkins.active = false - applicationConfigurator.validateConfig(testConfig) - // no exception when jenkins is not active - } - @Test void 'Fails if monitoring local is not set'() { testConfig.application.mirrorRepos = true testConfig.application.localHelmChartFolder = '' def exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) + 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\' ' + @@ -158,11 +162,12 @@ class ApplicationConfiguratorTest { @Test void 'Fails if content repo is set without mandatory params'() { + testConfig.content.repos = [ new Config.ContentSchema.ContentRepositorySchema(url: ''), ] def exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) + featureContent.preConfigInit(testConfig) } assertThat(exception.message).isEqualTo('content.repos requires a url parameter.') @@ -171,7 +176,7 @@ class ApplicationConfiguratorTest { new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.COPY, target: "missing_slash"), ] exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) + featureContent.preConfigInit(testConfig) } assertThat(exception.message).isEqualTo('content.target needs / to separate namespace/group from repo name. Repo: abc') } @@ -182,7 +187,7 @@ class ApplicationConfiguratorTest { new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.COPY), ] def exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) + featureContent.preConfigInit(testConfig) } assertThat(exception.message).isEqualTo('content.repos.type COPY requires content.repos.target to be set. Repo: abc') } @@ -193,7 +198,7 @@ class ApplicationConfiguratorTest { new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.FOLDER_BASED, target: 'namespace/repo'), ] def exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) + featureContent.preConfigInit(testConfig) } assertThat(exception.message).isEqualTo('content.repos.type FOLDER_BASED does not support target parameter. Repo: abc') @@ -202,7 +207,7 @@ class ApplicationConfiguratorTest { new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.FOLDER_BASED, targetRef: 'someRef'), ] exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) + featureContent.preConfigInit(testConfig) } assertThat(exception.message).isEqualTo('content.repos.type FOLDER_BASED does not support targetRef parameter. Repo: abc') } @@ -214,7 +219,7 @@ class ApplicationConfiguratorTest { new Config.ContentSchema.ContentRepositorySchema(url: 'abc', type: Config.ContentRepoType.MIRROR), ] def exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) + featureContent.preConfigInit(testConfig) } assertThat(exception.message).isEqualTo('content.repos.type MIRROR requires content.repos.target to be set. Repo: abc') @@ -224,7 +229,7 @@ class ApplicationConfiguratorTest { target: 'namespace/repo', path: 'non-default-path'), ] exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) + featureContent.preConfigInit(testConfig) } assertThat(exception.message).isEqualTo("content.repos.type MIRROR does not support path. Current path: non-default-path. Repo: abc") @@ -234,7 +239,7 @@ class ApplicationConfiguratorTest { target: 'namespace/repo', templating: true), ] exception = shouldFail(RuntimeException) { - applicationConfigurator.validateConfig(testConfig) + featureContent.preConfigInit(testConfig) } assertThat(exception.message).isEqualTo('content.repos.type MIRROR does not support templating. Repo: abc') } @@ -435,6 +440,7 @@ class ApplicationConfiguratorTest { 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]") @@ -451,6 +457,7 @@ class ApplicationConfiguratorTest { 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]") @@ -467,6 +474,7 @@ class ApplicationConfiguratorTest { 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") diff --git a/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScriptedTest.groovy b/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScriptedTest.groovy index 043e4211d..c7310bcee 100644 --- a/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScriptedTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScriptedTest.groovy @@ -77,7 +77,7 @@ class GitopsPlaygroundCliMainScriptedTest { .enableAnnotationInfo() .scan().withCloseable { scanResult -> scanResult.getAllClasses().each { ClassInfo classInfo -> - if (classInfo.name.endsWith("Test") || classInfo.isAbstract()) { + if (classInfo.name.endsWith("Test") || classInfo.isAbstract() || !classInfo.hasAnnotation(jakarta.inject.Singleton)) { return }