diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..f21bf4c48 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,621 @@ +[*] +charset = utf-8 +end_of_line = lf +ij_formatter_off_tag = @formatter:off,<#if +ij_formatter_on_tag = @formatter:on, + + + \ No newline at end of file diff --git a/docs/code-format/code-format.md b/docs/code-format/code-format.md new file mode 100644 index 000000000..3ca0f2a5a --- /dev/null +++ b/docs/code-format/code-format.md @@ -0,0 +1,28 @@ +# Code Formatting Guide + +When you open a `.editorconfig` file in IntelliJ IDEA for the first time, you'll see an "Enable EditorConfig support" +button at the top of the editor. Simply click this button to activate EditorConfig support. + +Alternatively, you can enable it manually: + +1. Go to Settings/Preferences +2. Search for "EditorConfig" +3. Check "Enable EditorConfig support" + +Once enabled, the project's `.editorconfig` file will be automatically recognized and its rules will be applied to your +code. + +## Auto-Format on Commit + +To ensure consistent code formatting across the project, you can configure IntelliJ to automatically format code before +committing: + +![Format on Commit Configuration](format-commit.png) + +## CodeNarc Inspection Profile + +To use custom CodeNarc rules in IntelliJ: + +![Loading CodeNarc Profile](loadCodeNarcProfile.png) + +and load Project_Default.xml \ No newline at end of file diff --git a/docs/code-format/format-commit.png b/docs/code-format/format-commit.png new file mode 100644 index 000000000..e54c429e0 Binary files /dev/null and b/docs/code-format/format-commit.png differ diff --git a/docs/code-format/formatsave.png b/docs/code-format/formatsave.png new file mode 100644 index 000000000..a9ccde041 Binary files /dev/null and b/docs/code-format/formatsave.png differ diff --git a/docs/code-format/loadCodeNarcProfile.png b/docs/code-format/loadCodeNarcProfile.png new file mode 100644 index 000000000..b6433ec0e Binary files /dev/null and b/docs/code-format/loadCodeNarcProfile.png differ diff --git a/scripts/jenkins/pluginCheck.groovy b/scripts/jenkins/pluginCheck.groovy index c8f77c309..745e0027a 100644 --- a/scripts/jenkins/pluginCheck.groovy +++ b/scripts/jenkins/pluginCheck.groovy @@ -4,21 +4,19 @@ List expected = [] String input = "${PLUGIN_LIST}" input.split(",").toList().stream() - .filter { it.split(":")[0] != "prometheus" } // prometheus does not support dynamic loading and will be installed when restarting. - .collect() - .each { it -> expected.add(it.substring(0, it.indexOf(":"))) } + .filter { it.split(":")[0] != "prometheus" } // prometheus does not support dynamic loading and will be installed when restarting. + .collect() + .each { it -> expected.add(it.substring(0, it.indexOf(":"))) } Jenkins.instance.pluginManager.failedPlugins.each { - available.add(it.name) + available.add(it.name) } Jenkins.instance.pluginManager.plugins.each { - available.add(it.shortName) + available.add(it.shortName) } -available.each { p -> - if (expected.find { (it == p) }) - plugins.add(p) +available.each { p -> if (expected.find { (it == p) }) plugins.add(p) } def commons = plugins.intersect(expected) diff --git a/src/main/groovy/com/cloudogu/gitops/Application.groovy b/src/main/groovy/com/cloudogu/gitops/Application.groovy index 6e395bebb..177b85623 100644 --- a/src/main/groovy/com/cloudogu/gitops/Application.groovy +++ b/src/main/groovy/com/cloudogu/gitops/Application.groovy @@ -2,67 +2,66 @@ 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 857359d75..aaa97c0e5 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,131 @@ 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) { + 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) {} } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/FeatureWithImage.groovy b/src/main/groovy/com/cloudogu/gitops/FeatureWithImage.groovy index e7d487b6b..5f0f2f135 100644 --- a/src/main/groovy/com/cloudogu/gitops/FeatureWithImage.groovy +++ b/src/main/groovy/com/cloudogu/gitops/FeatureWithImage.groovy @@ -2,30 +2,32 @@ package com.cloudogu.gitops import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.kubernetes.api.K8sClient + import org.slf4j.Logger import org.slf4j.LoggerFactory /** - * A feature that relies on container images running inside the kubernetes cluster. - */ + * A feature that relies on container images running inside the kubernetes cluster.*/ trait FeatureWithImage { - final Logger log = LoggerFactory.getLogger(this.class) - - void createImagePullSecret() { - if (config.registry.createImagePullSecrets) { - - log.trace("Creating image pull secret 'proxy-registry' in namespace ${this.namespace}") - String url = config.registry.proxyUrl ?: config.registry.url - String user = config.registry.proxyUsername ?: config.registry.readOnlyUsername ?: config.registry.username - String password = config.registry.proxyPassword ?: config.registry.readOnlyPassword ?: config.registry.password - - k8sClient.createNamespace(this.namespace) - k8sClient.createImagePullSecret('proxy-registry', namespace, url, user, password) - } - } - - abstract String getNamespace() - abstract K8sClient getK8sClient() - abstract Config getConfig() + final Logger log = LoggerFactory.getLogger(this.class) + + void createImagePullSecret() { + if (config.registry.createImagePullSecrets) { + + log.trace("Creating image pull secret 'proxy-registry' in namespace ${this.namespace}") + String url = config.registry.proxyUrl ?: config.registry.url + String user = config.registry.proxyUsername ?: config.registry.readOnlyUsername ?: config.registry.username + String password = config.registry.proxyPassword ?: config.registry.readOnlyPassword ?: config.registry.password + + k8sClient.createNamespace(this.namespace) + k8sClient.createImagePullSecret('proxy-registry', namespace, url, user, password) + } + } + + abstract String getNamespace() + + abstract K8sClient getK8sClient() + + abstract Config getConfig() } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/cli/GenerateJsonSchema.groovy b/src/main/groovy/com/cloudogu/gitops/cli/GenerateJsonSchema.groovy index 75b4ad8b5..96bf2a581 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/GenerateJsonSchema.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/GenerateJsonSchema.groovy @@ -1,9 +1,12 @@ package com.cloudogu.gitops.cli import com.cloudogu.gitops.config.schema.JsonSchemaGenerator + +import io.micronaut.context.ApplicationContext + import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode -import io.micronaut.context.ApplicationContext + /** * Generates the JSON Config for the configuration file and prints it to docs/configuration.schema.json. * Passing '-' as parameter prints the schema to stdout @@ -12,16 +15,16 @@ import io.micronaut.context.ApplicationContext * @see com.cloudogu.gitops.config.Config */ class GenerateJsonSchema { - static void main(String[] args) { - ObjectNode jsonSchema = ApplicationContext.run().getBean(JsonSchemaGenerator).createSchema() - def prettyJson = new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(jsonSchema) + static void main(String[] args) { + ObjectNode jsonSchema = ApplicationContext.run().getBean(JsonSchemaGenerator).createSchema() + def prettyJson = new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(jsonSchema) - if (args.length > 0 && args[0] == "-") { - println(prettyJson) - } else { - def schemaFile = 'docs/configuration.schema.json' - new File(schemaFile).setText(prettyJson) - println "Wrote schema to file ${schemaFile}" - } - } -} + if (args.length > 0 && args[0] == "-") { + println(prettyJson) + } else { + def schemaFile = 'docs/configuration.schema.json' + new File(schemaFile).setText(prettyJson) + println "Wrote schema to file ${schemaFile}" + } + } +} \ 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 e7a45c4f1..df078bff8 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCli.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCli.groovy @@ -1,11 +1,8 @@ package com.cloudogu.gitops.cli -import ch.qos.logback.classic.Level -import ch.qos.logback.classic.Logger -import ch.qos.logback.classic.LoggerContext -import ch.qos.logback.classic.encoder.PatternLayoutEncoder -import ch.qos.logback.classic.spi.ILoggingEvent -import ch.qos.logback.core.ConsoleAppender +import static com.cloudogu.gitops.config.ConfigConstants.APP_NAME +import static com.cloudogu.gitops.utils.MapUtils.deepMerge + import com.cloudogu.gitops.Application import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.ApplicationConfigurator @@ -13,18 +10,24 @@ 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 +import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.CommandExecutor import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.K8sClient + +import io.micronaut.context.ApplicationContext + import groovy.util.logging.Slf4j import groovy.yaml.YamlSlurper -import io.micronaut.context.ApplicationContext + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.ConsoleAppender import org.slf4j.LoggerFactory import picocli.CommandLine -import static com.cloudogu.gitops.config.ConfigConstants.APP_NAME -import static com.cloudogu.gitops.utils.MapUtils.deepMerge - /** * Provides the entrypoint to the application as well as all config parameters. * When changing parameters, make sure to update the Config for the config file as well @@ -34,221 +37,219 @@ import static com.cloudogu.gitops.utils.MapUtils.deepMerge @Slf4j class GitopsPlaygroundCli { - K8sClient k8sClient - ApplicationConfigurator applicationConfigurator - - GitopsPlaygroundCli(K8sClient k8sClient = new K8sClient(new CommandExecutor(), new FileSystemUtils(), null), - ApplicationConfigurator applicationConfigurator = new ApplicationConfigurator()) { - this.k8sClient = k8sClient - this.applicationConfigurator = applicationConfigurator - } - - ReturnCode run(String[] args) { - setLogging(args) - - log.debug("Reading initial CLI params") - def cliParams = new Config() - new CommandLine(cliParams).parseArgs(args) - - if (cliParams.application.usageHelpRequested) { - // if help is requested picocli help is used and printed by execute automatically - new CommandLine(cliParams).execute(args) - return ReturnCode.SUCCESS - } - - def version = createVersionOutput() - if (cliParams.application.versionInfoRequested) { - println version - 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 - } - - // Set internal values in config after help/version/output because these should work without connecting to k8s - // 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) - - context = createApplicationContext() - register(config, context) - - if (config.application.destroy) { - log.info version - if (!confirm("Destroying gitops playground in kubernetes cluster '${k8sClient.currentContext}'.", config)) { - return ReturnCode.NOT_CONFIRMED - } - - Destroyer destroyer = context.getBean(Destroyer) - destroyer.destroy() - } else { - log.info version - if (!confirm("Applying gitops playground to kubernetes cluster '${k8sClient.currentContext}'.", config)) { - return ReturnCode.NOT_CONFIRMED - } - app = context.getBean(Application) - app.start() - - printWelcomeScreen() - } - - return ReturnCode.SUCCESS - } - - protected String createVersionOutput() { - def versionName = Version.NAME.replace('\\n', '\n') - - if (versionName.trim().startsWith('(')) { - // When there is no git tag, print commit without parentheses - versionName = versionName.trim() - .replace('(', '') - .replace(')', '') - } - return "${APP_NAME} ${versionName}" - } - - /** Can be used as a hook by child classes */ - @SuppressWarnings('GrMethodMayBeStatic') - // static methods cannot be overridden - protected void register(Config config, ApplicationContext context) { - context.registerSingleton(config) - } - - private static boolean confirm(String message, Config config) { - if (config.application.yes) { - return true - } - - log.info("\n${message}\nContinue? y/n [n]") - - def input = System.in.newReader().readLine() - - return input == 'y' - } - - /** Can be used as a hook by tests */ - protected ApplicationContext createApplicationContext() { - ApplicationContext.run() - } - - private void setLogging(String[] args) { - Logger logger = (Logger) LoggerFactory.getLogger("com.cloudogu.gitops") - if (args.contains('--trace') || args.contains('-x')) { - log.info("Setting loglevel to trace") - logger.setLevel(Level.TRACE) - // log levels can be set via picocli.trace sys env - defaults to 'WARN' - System.setProperty("picocli.trace", "DEBUG") - } else if (args.contains('--debug') || args.contains('-d')) { - System.setProperty("picocli.trace", "INFO") - logger.setLevel(Level.DEBUG) - log.info("Setting loglevel to debug") - } else { - setSimpleLogPattern() - } - } - - /** - * Changes log pattern to a simpler one, to reduce noise for normal users - */ - void setSimpleLogPattern() { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory() - def rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME) - def defaultPattern = ((rootLogger.getAppender('STDOUT') as ConsoleAppender) - .getEncoder() as PatternLayoutEncoder).pattern - - // Avoid duplicate output by existing appender - rootLogger.detachAppender('STDOUT') - PatternLayoutEncoder encoder = new PatternLayoutEncoder() - // Remove less relevant details from log pattern - encoder.setPattern(defaultPattern - .replaceAll(" \\S*%thread\\S* ", " ") - .replaceAll(" \\S*%logger\\S* ", " ")) - encoder.setContext(loggerContext) - encoder.start() - ConsoleAppender appender = new ConsoleAppender<>() - appender.setName('STDOUT') - appender.setContext(loggerContext) - appender.setEncoder(encoder) - appender.start() - rootLogger.addAppender(appender) - } - - private Config readConfigs(String[] args) { - def cliParams = new Config() - new CommandLine(cliParams).parseArgs(args) - - // first evaluate profile for setting predefined values e.g. examples, if applicable - Config profileConfig = extractProfile(cliParams) - - Boolean contentExamples = cliParams.content.examples || profileConfig.content.examples - Boolean multiTenancyExamples = cliParams.content.multitenancyExamples || profileConfig.content.multitenancyExamples - - List configFile = [] - List configMap = [] - Map contentExamplesFile = [:] - Map multiTenancyContentExamplesFile = [:] - - for(String configFileItem : cliParams.application.configFiles) { - log.debug("Reading config file ${configFileItem}") - configFile.add(validateConfig(new File(configFileItem).text)) - } - - for (String configMapItem : cliParams.application.configMaps) { - log.debug("Reading config map ${configMapItem}") - def configValues = k8sClient.getConfigMap(configMapItem, 'config.yaml') - configMap.add(validateConfig(configValues)) - } - - if (contentExamples) { - String contentExamplesConfigPath = "examples/example-apps-via-content-loader/config.yaml" - log.debug("Adding example-apps-via-content-loader configuration from '${contentExamplesConfigPath}'") - contentExamplesFile = validateConfig(new File(contentExamplesConfigPath).text) - } - - if (multiTenancyExamples) { - String multiTenancyContentExamplesConfigPath = "examples/init-multi-tenancy/managementConfig.yaml" - log.debug("Adding multi tenancy example-apps config loader from '${multiTenancyContentExamplesConfigPath}'") - multiTenancyContentExamplesFile = validateConfig(new File(multiTenancyContentExamplesConfigPath).text) - } - - - // Last one takes precedence - def configPrecedence = [profileConfig.toMap(), multiTenancyContentExamplesFile, contentExamplesFile, configMap, configFile] - Map mergedConfigs = [:] - configPrecedence.flatten().each { element -> - deepMerge(element as Map, mergedConfigs) - } - - // DeepMerge with default Config values to keep the default values defined in Config.groovy - mergedConfigs = deepMerge(mergedConfigs, new Config().toMap()) - - log.debug("Writing CLI params into config") - Config mergedConfig = Config.fromMap(mergedConfigs) - new CommandLine(mergedConfig).parseArgs(args) - - return mergedConfig - } - - static Map validateConfig(String configValues) { - def map = new YamlSlurper().parseText(configValues) - if (!(map instanceof Map)) { - throw new RuntimeException("Could not parse YAML as map: $map") - } - JsonSchemaValidator.validate(map as Map) - return map as Map - } - - void printWelcomeScreen() { - log.info '''\n + K8sClient k8sClient + ApplicationConfigurator applicationConfigurator + + GitopsPlaygroundCli(K8sClient k8sClient = new K8sClient(new CommandExecutor(), new FileSystemUtils(), null), + ApplicationConfigurator applicationConfigurator = new ApplicationConfigurator()) { + this.k8sClient = k8sClient + this.applicationConfigurator = applicationConfigurator + } + + ReturnCode run(String[] args) { + setLogging(args) + + log.debug("Reading initial CLI params") + def cliParams = new Config() + new CommandLine(cliParams).parseArgs(args) + + if (cliParams.application.usageHelpRequested) { + // if help is requested picocli help is used and printed by execute automatically + new CommandLine(cliParams).execute(args) + return ReturnCode.SUCCESS + } + + def version = createVersionOutput() + if (cliParams.application.versionInfoRequested) { + println version + 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 + } + + // Set internal values in config after help/version/output because these should work without connecting to k8s + // 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) + + context = createApplicationContext() + register(config, context) + + if (config.application.destroy) { + log.info version + if (!confirm("Destroying gitops playground in kubernetes cluster '${k8sClient.currentContext}'.", config)) { + return ReturnCode.NOT_CONFIRMED + } + + Destroyer destroyer = context.getBean(Destroyer) + destroyer.destroy() + } else { + log.info version + if (!confirm("Applying gitops playground to kubernetes cluster '${k8sClient.currentContext}'.", config)) { + return ReturnCode.NOT_CONFIRMED + } + app = context.getBean(Application) + app.start() + + printWelcomeScreen() + } + + return ReturnCode.SUCCESS + } + + protected String createVersionOutput() { + def versionName = Version.NAME.replace('\\n', '\n') + + if (versionName.trim().startsWith('(')) { + // When there is no git tag, print commit without parentheses + versionName = versionName.trim() + .replace('(', '') + .replace(')', '') + } + return "${APP_NAME} ${versionName}" + } + + /** Can be used as a hook by child classes */ + @SuppressWarnings('GrMethodMayBeStatic') + // static methods cannot be overridden + protected void register(Config config, ApplicationContext context) { + context.registerSingleton(config) + } + + private static boolean confirm(String message, Config config) { + if (config.application.yes) { + return true + } + + log.info("\n${message}\nContinue? y/n [n]") + + def input = System.in.newReader().readLine() + + return input == 'y' + } + + /** Can be used as a hook by tests */ + protected ApplicationContext createApplicationContext() { + ApplicationContext.run() + } + + private void setLogging(String[] args) { + Logger logger = (Logger) LoggerFactory.getLogger("com.cloudogu.gitops") + if (args.contains('--trace') || args.contains('-x')) { + log.info("Setting loglevel to trace") + logger.setLevel(Level.TRACE) + // log levels can be set via picocli.trace sys env - defaults to 'WARN' + System.setProperty("picocli.trace", "DEBUG") + } else if (args.contains('--debug') || args.contains('-d')) { + System.setProperty("picocli.trace", "INFO") + logger.setLevel(Level.DEBUG) + log.info("Setting loglevel to debug") + } else { + setSimpleLogPattern() + } + } + + /** + * Changes log pattern to a simpler one, to reduce noise for normal users*/ + void setSimpleLogPattern() { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory() + def rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME) + def defaultPattern = ((rootLogger.getAppender('STDOUT') as ConsoleAppender) + .getEncoder() as PatternLayoutEncoder).pattern + + // Avoid duplicate output by existing appender + rootLogger.detachAppender('STDOUT') + PatternLayoutEncoder encoder = new PatternLayoutEncoder() + // Remove less relevant details from log pattern + encoder.setPattern(defaultPattern + .replaceAll(" \\S*%thread\\S* ", " ") + .replaceAll(" \\S*%logger\\S* ", " ")) + encoder.setContext(loggerContext) + encoder.start() + ConsoleAppender appender = new ConsoleAppender<>() + appender.setName('STDOUT') + appender.setContext(loggerContext) + appender.setEncoder(encoder) + appender.start() + rootLogger.addAppender(appender) + } + + private Config readConfigs(String[] args) { + def cliParams = new Config() + new CommandLine(cliParams).parseArgs(args) + + // first evaluate profile for setting predefined values e.g. examples, if applicable + Config profileConfig = extractProfile(cliParams) + + Boolean contentExamples = cliParams.content.examples || profileConfig.content.examples + Boolean multiTenancyExamples = cliParams.content.multitenancyExamples || profileConfig.content.multitenancyExamples + + List configFile = [] + List configMap = [] + Map contentExamplesFile = [:] + Map multiTenancyContentExamplesFile = [:] + + for (String configFileItem : cliParams.application.configFiles) { + log.debug("Reading config file ${configFileItem}") + configFile.add(validateConfig(new File(configFileItem).text)) + } + + for (String configMapItem : cliParams.application.configMaps) { + log.debug("Reading config map ${configMapItem}") + def configValues = k8sClient.getConfigMap(configMapItem, 'config.yaml') + configMap.add(validateConfig(configValues)) + } + + if (contentExamples) { + String contentExamplesConfigPath = "examples/example-apps-via-content-loader/config.yaml" + log.debug("Adding example-apps-via-content-loader configuration from '${contentExamplesConfigPath}'") + contentExamplesFile = validateConfig(new File(contentExamplesConfigPath).text) + } + + if (multiTenancyExamples) { + String multiTenancyContentExamplesConfigPath = "examples/init-multi-tenancy/managementConfig.yaml" + log.debug("Adding multi tenancy example-apps config loader from '${multiTenancyContentExamplesConfigPath}'") + multiTenancyContentExamplesFile = validateConfig(new File(multiTenancyContentExamplesConfigPath).text) + } + + + // Last one takes precedence + def configPrecedence = [profileConfig.toMap(), multiTenancyContentExamplesFile, contentExamplesFile, configMap, configFile] + Map mergedConfigs = [:] + configPrecedence.flatten().each { element -> deepMerge(element as Map, mergedConfigs) + } + + // DeepMerge with default Config values to keep the default values defined in Config.groovy + mergedConfigs = deepMerge(mergedConfigs, new Config().toMap()) + + log.debug("Writing CLI params into config") + Config mergedConfig = Config.fromMap(mergedConfigs) + new CommandLine(mergedConfig).parseArgs(args) + + return mergedConfig + } + + static Map validateConfig(String configValues) { + def map = new YamlSlurper().parseText(configValues) + if (!(map instanceof Map)) { + throw new RuntimeException("Could not parse YAML as map: $map") + } + JsonSchemaValidator.validate(map as Map) + return map as Map + } + + void printWelcomeScreen() { + log.info '''\n |----------------------------------------------------------------------------------------------| | Welcome to the GitOps playground by Cloudogu! |----------------------------------------------------------------------------------------------| @@ -264,37 +265,37 @@ class GitopsPlaygroundCli { | Please be aware, Jenkins and Argo CD may take some time to build and deploy all apps. |----------------------------------------------------------------------------------------------| ''' - } - - 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) - } - } - } - - private static Config extractProfile(Config newConfig) { - - String profile = newConfig.application.profile - - Config profileConfig = new Config() - if (profile) { - String profileName = "src/main/resources/application-${profile}.yaml" - log.debug("Loading profile '${profileName}'") - def file - try { - file = new File(profileName) - - } catch (Exception e) { - throw new RuntimeException("Profile '${profileName}' does not exist.") - } - Map profileFile = validateConfig(file.text) - profileConfig = Config.fromMap(profileFile) - } - return profileConfig - } + } + + 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) + } + } + } + + private static Config extractProfile(Config newConfig) { + + String profile = newConfig.application.profile + + Config profileConfig = new Config() + if (profile) { + String profileName = "src/main/resources/application-${profile}.yaml" + log.debug("Loading profile '${profileName}'") + def file + try { + file = new File(profileName) + + } catch (Exception e) { + throw new RuntimeException("Profile '${profileName}' does not exist.") + } + Map profileFile = validateConfig(file.text) + profileConfig = Config.fromMap(profileFile) + } + return profileConfig + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMain.groovy b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMain.groovy index 4c3baa21a..d34f10dd4 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMain.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMain.groovy @@ -1,30 +1,29 @@ package com.cloudogu.gitops.cli - import groovy.util.logging.Slf4j @Slf4j class GitopsPlaygroundCliMain { - static void main(String[] args) throws Exception { - new GitopsPlaygroundCliMain().exec(args, GitopsPlaygroundCli.class) - } + static void main(String[] args) throws Exception { + new GitopsPlaygroundCliMain().exec(args, GitopsPlaygroundCli.class) + } + + @SuppressWarnings('GrMethodMayBeStatic') + // Non-static for easier testing and reuse + void exec(String[] args, Class commandClass) { + GitopsPlaygroundCli app = commandClass.getDeclaredConstructor().newInstance() - @SuppressWarnings('GrMethodMayBeStatic') - // Non-static for easier testing and reuse - void exec(String[] args, Class commandClass) { - GitopsPlaygroundCli app = commandClass.getDeclaredConstructor().newInstance() - - try { - System.exit(app.run(args).ordinal()) - } catch (RuntimeException e) { - if (log.isDebugEnabled()) { - log.error('', e) - } else { - log.error(e.message) - } - System.exit(ReturnCode.GENERIC_ERROR.ordinal()) - } - } + try { + System.exit(app.run(args).ordinal()) + } catch (RuntimeException e) { + if (log.isDebugEnabled()) { + log.error('', e) + } else { + log.error(e.message) + } + System.exit(ReturnCode.GENERIC_ERROR.ordinal()) + } + } -} +} \ 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 e45b2d0a7..2e23240a8 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, 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/cli/ReturnCode.groovy b/src/main/groovy/com/cloudogu/gitops/cli/ReturnCode.groovy index a1d6e7274..26e0a4631 100644 --- a/src/main/groovy/com/cloudogu/gitops/cli/ReturnCode.groovy +++ b/src/main/groovy/com/cloudogu/gitops/cli/ReturnCode.groovy @@ -1,3 +1,5 @@ package com.cloudogu.gitops.cli -enum ReturnCode { SUCCESS, NOT_CONFIRMED, GENERIC_ERROR } \ No newline at end of file +enum ReturnCode { + SUCCESS, NOT_CONFIRMED, GENERIC_ERROR +} \ 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 a058e09d9..51f9e8a26 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/ApplicationConfigurator.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/ApplicationConfigurator.groovy @@ -1,324 +1,319 @@ package com.cloudogu.gitops.config import com.cloudogu.gitops.utils.FileSystemUtils + import groovy.util.logging.Slf4j @Slf4j class ApplicationConfigurator { - private FileSystemUtils fileSystemUtils - - ApplicationConfigurator(FileSystemUtils fileSystemUtils = new FileSystemUtils()) { - this.fileSystemUtils = fileSystemUtils - } - - /** - * Sets dynamic fields and validates params - */ - Config initConfig(Config newConfig) { - - addAdditionalApplicationConfig(newConfig) - addNamePrefix(newConfig) - - addScmConfig(newConfig) - - addRegistryConfig(newConfig) - - addJenkinsConfig(newConfig) - - addFeatureConfig(newConfig) - - evaluateBaseUrl(newConfig) - - setResourceInclusionsCluster(newConfig) - - setMultiTenantModeConfig(newConfig) - - return newConfig - } - - private void addFeatureConfig(Config newConfig) { - if (newConfig.features.secrets.vault.mode) - newConfig.features.secrets.active = true - - if (newConfig.features.mail.smtpAddress || newConfig.features.mail.mailServer) - newConfig.features.mail.active = true - if (newConfig.features.mail.smtpAddress && newConfig.features.mail.mailServer) { - newConfig.features.mail.mailServer = false - log.warn("Enabled both external Mailserver and in-cluster Mailserver! Implicitly deactivating in-cluster mailserver") - } - - if (newConfig.features.ingress.active && !newConfig.application.baseUrl) { - log.warn("Ingress-controller is activated without baseUrl parameter. Services will not be accessible by hostnames. To avoid this use baseUrl with ingress. ") - } - if (newConfig.content.examples) { - if (!newConfig.registry.active) { - throw new RuntimeException("content.examples requires either registry.active or registry.url") - } - String prefix = newConfig.application.namePrefix - newConfig.content.namespaces += [prefix + "example-apps-staging", prefix + "example-apps-production"] - } - } - - private void addNamePrefix(Config newConfig) { - String namePrefix = newConfig.application.namePrefix - if (namePrefix) { - if (!namePrefix.endsWith('-')) { - newConfig.application.namePrefix = "${namePrefix}-" - } - newConfig.application.namePrefixForEnvVars = "${(newConfig.application.namePrefix as String).toUpperCase().replace('-', '_')}" - } - } - - private void addRegistryConfig(Config newConfig) { - // Process image pull secrets first, they might even be relevant if no registry is set - if (newConfig.registry.createImagePullSecrets) { - String username = newConfig.registry.readOnlyUsername ?: newConfig.registry.username - String password = newConfig.registry.readOnlyPassword ?: newConfig.registry.password - if (!username || !password) { - throw new RuntimeException("createImagePullSecrets needs to be used with either registry username and password or the readOnly variants") - } - } - - if (newConfig.registry.url) { - newConfig.registry.internal = false - newConfig.registry.active = true - } else if (newConfig.registry.active) { - /* Internal Docker registry must be on localhost. Otherwise docker will use HTTPS, leading to errors on - docker push in the example application's Jenkins Jobs. - Both setting up HTTPS or allowing insecure registry via daemon.json makes the playground difficult to use. - So, always use localhost. - Allow overriding the port, in case multiple playground instance run on a single host in different - k3d clusters. */ - newConfig.registry.internal = true - newConfig.registry.url = "localhost:${newConfig.registry.internalPort}" - } else { - // Registry not active, no need to set the following values - return - } - - if (newConfig.registry.proxyUrl) { - newConfig.registry.twoRegistries = true - if (!newConfig.registry.proxyUsername || !newConfig.registry.proxyPassword) { - throw new RuntimeException("Proxy URL needs to be used with proxy-username and proxy-password") - } - } - } - - private void addAdditionalApplicationConfig(Config newConfig) { - if (System.getenv("KUBERNETES_SERVICE_HOST")) { - log.debug("installation is running in kubernetes.") - newConfig.application.runningInsideK8s = true - } - } - - private void addScmConfig(Config newConfig) { - log.debug("Adding additional config for SCM") - - if (newConfig.scm.scmManager.url) { - log.debug("Setting external scmm config") - newConfig.scm.scmManager.internal = false - newConfig.scm.scmManager.urlForJenkins = newConfig.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. - newConfig.scm.scmManager.urlForJenkins = - "http://scmm.${newConfig.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) - } - - // We probably could get rid of some of the complexity by refactoring url, host and ingress into a single var - if (newConfig.application.baseUrl) { - newConfig.scm.scmManager.ingress = new URL(injectSubdomain("scmm", - newConfig.application.baseUrl as String, newConfig.application.urlSeparatorHyphen as Boolean)).host - } - // When specific user/pw are not set, set them to global values - if (newConfig.scm.scmManager.password === Config.DEFAULT_ADMIN_PW) { - newConfig.scm.scmManager.password = newConfig.application.password - } - if (newConfig.scm.scmManager.username === Config.DEFAULT_ADMIN_USER) { - newConfig.scm.scmManager.username = newConfig.application.username - } - - - } - - private void addJenkinsConfig(Config newConfig) { - log.debug("Adding additional config for Jenkins") - if (newConfig.jenkins.url) { - log.debug("Setting external jenkins config") - newConfig.jenkins.active = true - newConfig.jenkins.internal = false - newConfig.jenkins.urlForScm = newConfig.jenkins.url - } else if (newConfig.jenkins.active) { - log.debug("Setting configs for internal jenkins") - // We use the K8s service as default name here, because it is the only option: - // "jenkins.localhost" will not work inside the Pods and k3d-container IP + Port (e.g. 172.x.y.z:9090) - // will not work on Windows and MacOS. - newConfig.jenkins.urlForScm = "http://jenkins.${newConfig.application.namePrefix}jenkins.svc.cluster.local" - - // More internal fields are set lazily in Jenkins.groovy (after Jenkins is deployed and ports are known) - } else { - // Jenkins not active, no need to set the following values - return - } - - if (newConfig.application.baseUrl) { - newConfig.jenkins.ingress = new URL(injectSubdomain("jenkins", - newConfig.application.baseUrl, newConfig.application.urlSeparatorHyphen)).host - } - // When specific user/pw are not set, set them to global values - if (newConfig.jenkins.username === Config.DEFAULT_ADMIN_USER) { - newConfig.jenkins.username = newConfig.application.username - } - if (newConfig.jenkins.password === Config.DEFAULT_ADMIN_PW) { - newConfig.jenkins.password = newConfig.application.password - } - } - - private void evaluateBaseUrl(Config newConfig) { - String baseUrl = newConfig.application.baseUrl - if (!baseUrl) { - return - } - log.debug("Base URL set, adapting to individual tools") - def argocd = newConfig.features.argocd - def mail = newConfig.features.mail - def monitoring = newConfig.features.monitoring - def vault = newConfig.features.secrets.vault - boolean urlSeparatorHyphen = newConfig.application.urlSeparatorHyphen - - if (argocd.active && !argocd.url) { - argocd.url = injectSubdomain("argocd", baseUrl, urlSeparatorHyphen) - log.debug("Setting ArgoCD URL ${argocd.url}") - } - if (mail.mailServer && !mail.mailUrl) { - mail.mailUrl = injectSubdomain('mail', baseUrl, urlSeparatorHyphen) - log.debug("Setting Mail URL ${mail.mailUrl}") - } - if (monitoring.active && !monitoring.grafanaUrl) { - monitoring.grafanaUrl = injectSubdomain('grafana', baseUrl, urlSeparatorHyphen) - log.debug("Setting Monitoring URL ${monitoring.grafanaUrl}") - } - if (newConfig.features.secrets.active && !vault.url) { - vault.url = injectSubdomain('vault', baseUrl, urlSeparatorHyphen) - log.debug("Setting Vault URL ${vault.url}") - } - - } - - void setMultiTenantModeConfig(Config newConfig) { - if (newConfig.multiTenant.useDedicatedInstance) { - if (!newConfig.application.namePrefix) { - throw new RuntimeException('To enable Central Multi-Tenant mode, you must define a name prefix to distinguish between instances.') - } - - if (!newConfig.features.argocd.operator) { - newConfig.features.argocd.operator = true - } - - // Removes trailing slash from the input URL to avoid duplicated slashes in further URL handling - if (newConfig.multiTenant.scmManager.url) { - String urlString = newConfig.multiTenant.scmManager.url.toString() - if (urlString.endsWith("/")) { - urlString = urlString[0..-2] - } - newConfig.multiTenant.scmManager.url = urlString - } - - //Disabling Ingress in DedicatedInstances Mode for now. - //Ingress has to be handled by Cluster, not by this tenant. - //Ingress has to be handled manually for local dev. - //See /scripts/local/ for local dev. - newConfig.features.ingress.active = false - } - } - - /** - * - * @param subdomain , e.g. argocd - * @param baseUrl e.g. http://localhost:8080 - * @param urlSeparatorHyphen - * @return e.g. http://argocd.localhost:8080 - */ - private String injectSubdomain(String subdomain, String baseUrl, boolean urlSeparatorHyphen) { - URL url = new URL(baseUrl) - String newUrl - - if (urlSeparatorHyphen) { - newUrl = url.getProtocol() + "://" + subdomain + "-" + url.getHost() - } else { - newUrl = url.getProtocol() + "://" + subdomain + "." + url.getHost() - } - if (url.getPort() != -1) { - newUrl += ":" + url.getPort() - } - newUrl += url.getPath() - return newUrl - } - - private void setResourceInclusionsCluster(Config configToSet) { - // Return early if NOT deploying via operator - if (!configToSet.features.argocd.operator) { - log.debug("ArgoCD operator is not enabled. Skipping features.argocd.resourceInclusionsCluster setup.") - return - } - log.info("Starting setup of features.argocd.resourceInclusionsCluster for ArgoCD Operator") - - if (!isUrlSetAndValid(configToSet)) { - // If features.argocd.resourceInclusionsClus 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-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-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() + } - @Option(names = ['--argocd'], description = ARGOCD_ENABLE_DESCRIPTION) - @JsonPropertyDescription(ARGOCD_ENABLE_DESCRIPTION) - Boolean active = false + static class ArgoCDSchema { + Boolean configOnly = 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'], 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() + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/config/ConfigConstants.groovy b/src/main/groovy/com/cloudogu/gitops/config/ConfigConstants.groovy index 14f29c4e4..1c0bfc8f9 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/ConfigConstants.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/ConfigConstants.groovy @@ -2,166 +2,166 @@ 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_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_EXAMPLES_DESCRIPTION = 'Deploy example content: source repos, GitOps repos, Jenkins Job, Argo CD apps/project' - String CONTENT_MULTI_TENANCY_EXAMPLES_DESCRIPTION = "Deploy multi tenancy example content: source repos, GitOps repos, Jenkins Job, Argo CD apps/project" - - 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' - - // 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, vault and mailhog 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' - - String MAIL_DESCRIPTION = 'Config parameters for mail servers' - String MAIL_URL_DESCRIPTION = 'Sets url for the mail server frontend' - String MAILSERVER_ENABLE_DESCRIPTION = 'Installs a dedicated mail server.' - - // group external Mailserver - 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 example apps - - // 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_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_EXAMPLES_DESCRIPTION = 'Deploy example content: source repos, GitOps repos, Jenkins Job, Argo CD apps/project' + String CONTENT_MULTI_TENANCY_EXAMPLES_DESCRIPTION = "Deploy multi tenancy example content: source repos, GitOps repos, Jenkins Job, Argo CD apps/project" + + 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' + + // 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, vault and mailhog 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' + + String MAIL_DESCRIPTION = 'Config parameters for mail servers' + String MAIL_URL_DESCRIPTION = 'Sets url for the mail server frontend' + String MAILSERVER_ENABLE_DESCRIPTION = 'Installs a dedicated mail server.' + + // group external Mailserver + 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 example apps + + // 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/config/Credentials.groovy b/src/main/groovy/com/cloudogu/gitops/config/Credentials.groovy index 0281f78d5..a7933e5e8 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/Credentials.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/Credentials.groovy @@ -1,43 +1,44 @@ package com.cloudogu.gitops.config -import com.fasterxml.jackson.annotation.JsonIgnore -import com.fasterxml.jackson.annotation.JsonPropertyDescription +import static com.cloudogu.gitops.config.ConfigConstants.CONTENT_REPO_CREDENTIALS_DESCRIPTION + import groovy.transform.ToString -import static com.cloudogu.gitops.config.ConfigConstants.CONTENT_REPO_CREDENTIALS_DESCRIPTION +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonPropertyDescription @ToString class Credentials { - @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) - String username - @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) - @JsonIgnore - String password - @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) - String secretNamespace - @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) - String secretName - @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) - String usernameKey = 'username' - @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) - String passwordKey = 'password' - - Credentials() {} - - Credentials(String username, String password, String secretName = '', String secretNamespace = '', String usernameKey = "username", String passwordKey = 'password') { - this.username = username - this.password = password - this.secretNamespace = secretNamespace - this.secretName = secretName - this.usernameKey = usernameKey - this.passwordKey = passwordKey - } - - Credentials(Credentials unsafeCredentials) { - this.secretNamespace = unsafeCredentials.secretNamespace - this.secretName = unsafeCredentials.secretName - this.usernameKey = unsafeCredentials.usernameKey - this.passwordKey = unsafeCredentials.passwordKey - } + @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) + String username + @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) + @JsonIgnore + String password + @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) + String secretNamespace + @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) + String secretName + @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) + String usernameKey = 'username' + @JsonPropertyDescription(CONTENT_REPO_CREDENTIALS_DESCRIPTION) + String passwordKey = 'password' + + Credentials() {} + + Credentials(String username, String password, String secretName = '', String secretNamespace = '', String usernameKey = "username", String passwordKey = 'password') { + this.username = username + this.password = password + this.secretNamespace = secretNamespace + this.secretName = secretName + this.usernameKey = usernameKey + this.passwordKey = passwordKey + } + + Credentials(Credentials unsafeCredentials) { + this.secretNamespace = unsafeCredentials.secretNamespace + this.secretName = unsafeCredentials.secretName + this.usernameKey = unsafeCredentials.usernameKey + this.passwordKey = unsafeCredentials.passwordKey + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/config/MultiTenantSchema.groovy b/src/main/groovy/com/cloudogu/gitops/config/MultiTenantSchema.groovy index d2a7646b3..5de4f3cad 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/MultiTenantSchema.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/MultiTenantSchema.groovy @@ -3,40 +3,39 @@ package com.cloudogu.gitops.config import com.cloudogu.gitops.features.git.config.ScmCentralSchema.GitlabCentralConfig import com.cloudogu.gitops.features.git.config.ScmCentralSchema.ScmManagerCentralConfig import com.cloudogu.gitops.features.git.config.util.ScmProviderType + import com.fasterxml.jackson.annotation.JsonPropertyDescription import picocli.CommandLine.Mixin import picocli.CommandLine.Option class MultiTenantSchema { - static final String SCM_PROVIDER_TYPE_DESCRIPTION = 'The SCM provider type. Possible values: SCM_MANAGER, GITLAB' - static final String GITLAB_CONFIG_DESCRIPTION = 'Config for GITLAB' - static final String SCMM_CONFIG_DESCRIPTION = 'Config for GITLAB' - static final String CENTRAL_ARGOCD_NAMESPACE_DESCRIPTION = 'Namespace for the centralized Argocd' - static final String CENTRAL_USEDEDICATED_DESCRIPTION = 'Toggles the Dedicated Instances Mode. See docs for more info' - - @Option( - names = ['--central-scm-provider'], - description = SCM_PROVIDER_TYPE_DESCRIPTION, - defaultValue = "SCM_MANAGER" - ) - @JsonPropertyDescription(SCM_PROVIDER_TYPE_DESCRIPTION) - ScmProviderType scmProviderType = ScmProviderType.SCM_MANAGER - - @JsonPropertyDescription(GITLAB_CONFIG_DESCRIPTION) - @Mixin - GitlabCentralConfig gitlab - - @JsonPropertyDescription(SCMM_CONFIG_DESCRIPTION) - @Mixin - ScmManagerCentralConfig scmManager - - @Option(names = ['--central-argocd-namespace'], description = CENTRAL_ARGOCD_NAMESPACE_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_ARGOCD_NAMESPACE_DESCRIPTION) - String centralArgocdNamespace = 'argocd' - - @Option(names = ['--dedicated-instance'], description = CENTRAL_USEDEDICATED_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_USEDEDICATED_DESCRIPTION) - Boolean useDedicatedInstance = false + static final String SCM_PROVIDER_TYPE_DESCRIPTION = 'The SCM provider type. Possible values: SCM_MANAGER, GITLAB' + static final String GITLAB_CONFIG_DESCRIPTION = 'Config for GITLAB' + static final String SCMM_CONFIG_DESCRIPTION = 'Config for GITLAB' + static final String CENTRAL_ARGOCD_NAMESPACE_DESCRIPTION = 'Namespace for the centralized Argocd' + static final String CENTRAL_USEDEDICATED_DESCRIPTION = 'Toggles the Dedicated Instances Mode. See docs for more info' + + @Option(names = ['--central-scm-provider'], + description = SCM_PROVIDER_TYPE_DESCRIPTION, + defaultValue = "SCM_MANAGER") + @JsonPropertyDescription(SCM_PROVIDER_TYPE_DESCRIPTION) + ScmProviderType scmProviderType = ScmProviderType.SCM_MANAGER + + @JsonPropertyDescription(GITLAB_CONFIG_DESCRIPTION) + @Mixin + GitlabCentralConfig gitlab + + @JsonPropertyDescription(SCMM_CONFIG_DESCRIPTION) + @Mixin + ScmManagerCentralConfig scmManager + + @Option(names = ['--central-argocd-namespace'], description = CENTRAL_ARGOCD_NAMESPACE_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_ARGOCD_NAMESPACE_DESCRIPTION) + String centralArgocdNamespace = 'argocd' + + @Option(names = ['--dedicated-instance'], description = CENTRAL_USEDEDICATED_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_USEDEDICATED_DESCRIPTION) + Boolean useDedicatedInstance = false } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/config/schema/JsonSchemaGenerator.groovy b/src/main/groovy/com/cloudogu/gitops/config/schema/JsonSchemaGenerator.groovy index 6699cfcd0..27014b745 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/schema/JsonSchemaGenerator.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/schema/JsonSchemaGenerator.groovy @@ -1,34 +1,36 @@ package com.cloudogu.gitops.config.schema import com.cloudogu.gitops.config.Config + +import jakarta.inject.Singleton + import com.fasterxml.jackson.annotation.JsonPropertyDescription import com.fasterxml.jackson.databind.node.ObjectNode import com.github.victools.jsonschema.generator.* import com.github.victools.jsonschema.module.jackson.JacksonModule -import jakarta.inject.Singleton @Singleton class JsonSchemaGenerator { - static ObjectNode createSchema() { - SchemaGeneratorConfigBuilder configBuilder = - new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) - // Make the schema strict: Only allow our fields, warn when additional fields are passed - .with(Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT) - // Exception to the above: For Maps allow additional fields. - // We use this to allow inline helm values without having to validate them - .with(Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES) - // All fields can be set to null to use the default - .with(Option.NULLABLE_FIELDS_BY_DEFAULT) - .with(new JacksonModule( /* no options for now */)) - // Apply the rule to include only fields with @JsonProperty annotation - configBuilder.forFields() - .withIgnoreCheck((FieldScope field) -> { - // Only include fields that are annotated with @JsonProperty - return field.getAnnotation(JsonPropertyDescription) == null - }) + static ObjectNode createSchema() { + SchemaGeneratorConfigBuilder configBuilder = + new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) + // Make the schema strict: Only allow our fields, warn when additional fields are passed + .with(Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT) + // Exception to the above: For Maps allow additional fields. + // We use this to allow inline helm values without having to validate them + .with(Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES) + // All fields can be set to null to use the default + .with(Option.NULLABLE_FIELDS_BY_DEFAULT) + .with(new JacksonModule(/* no options for now */)) + // Apply the rule to include only fields with @JsonProperty annotation + configBuilder.forFields() + .withIgnoreCheck((FieldScope field) -> { + // Only include fields that are annotated with @JsonProperty + return field.getAnnotation(JsonPropertyDescription) == null + }) - SchemaGenerator generator = new SchemaGenerator(configBuilder.build()) + SchemaGenerator generator = new SchemaGenerator(configBuilder.build()) - return generator.generateSchema(Config) - } -} + return generator.generateSchema(Config) + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/config/schema/JsonSchemaValidator.groovy b/src/main/groovy/com/cloudogu/gitops/config/schema/JsonSchemaValidator.groovy index d04664b9e..c8f505060 100644 --- a/src/main/groovy/com/cloudogu/gitops/config/schema/JsonSchemaValidator.groovy +++ b/src/main/groovy/com/cloudogu/gitops/config/schema/JsonSchemaValidator.groovy @@ -1,27 +1,28 @@ package com.cloudogu.gitops.config.schema +import groovy.util.logging.Slf4j + import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.networknt.schema.JsonSchemaFactory import com.networknt.schema.SpecVersionDetector -import groovy.util.logging.Slf4j @Slf4j class JsonSchemaValidator { - private static ObjectMapper objectMapper = new ObjectMapper() - - static void validate(Map yaml) { - def json = objectMapper.convertValue(yaml, JsonNode) - def schemaNode = JsonSchemaGenerator.createSchema() - def schema = JsonSchemaFactory.getInstance(SpecVersionDetector.detect(schemaNode)).getSchema(schemaNode) + private static ObjectMapper objectMapper = new ObjectMapper() + + static void validate(Map yaml) { + def json = objectMapper.convertValue(yaml, JsonNode) + def schemaNode = JsonSchemaGenerator.createSchema() + def schema = JsonSchemaFactory.getInstance(SpecVersionDetector.detect(schemaNode)).getSchema(schemaNode) - log.debug("yaml configuration converted to json for validate {}", json) + log.debug("yaml configuration converted to json for validate {}", json) - def validationMessages = schema.validate(json) + def validationMessages = schema.validate(json) - if (!validationMessages.isEmpty()) { - throw new RuntimeException("Config file invalid: " + validationMessages.join("\n")) - } - } -} + if (!validationMessages.isEmpty()) { + throw new RuntimeException("Config file invalid: " + validationMessages.join("\n")) + } + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/dependencyinjection/HttpClientFactory.groovy b/src/main/groovy/com/cloudogu/gitops/dependencyinjection/HttpClientFactory.groovy index 97db911da..174e9423e 100644 --- a/src/main/groovy/com/cloudogu/gitops/dependencyinjection/HttpClientFactory.groovy +++ b/src/main/groovy/com/cloudogu/gitops/dependencyinjection/HttpClientFactory.groovy @@ -4,15 +4,8 @@ import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.Credentials import com.cloudogu.gitops.git.providers.scmmanager.api.AuthorizationInterceptor import com.cloudogu.gitops.okhttp.RetryInterceptor -import groovy.transform.TupleConstructor + import io.micronaut.context.annotation.Factory -import jakarta.inject.Named -import jakarta.inject.Singleton -import okhttp3.JavaNetCookieJar -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import org.jetbrains.annotations.NotNull -import org.slf4j.LoggerFactory import javax.net.ssl.HostnameVerifier import javax.net.ssl.SSLContext @@ -21,82 +14,89 @@ import javax.net.ssl.X509TrustManager import java.security.SecureRandom import java.security.cert.CertificateException import java.security.cert.X509Certificate +import jakarta.inject.Named +import jakarta.inject.Singleton +import groovy.transform.TupleConstructor + +import okhttp3.JavaNetCookieJar +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.jetbrains.annotations.NotNull +import org.slf4j.LoggerFactory @Factory class HttpClientFactory { - static OkHttpClient buildOkHttpClient(Credentials credentials, Boolean isInsecure) { - def builder = new OkHttpClient.Builder() - .addInterceptor(new AuthorizationInterceptor(credentials.username, credentials.password)) - .addInterceptor(createLoggingInterceptor()) - .addInterceptor(new RetryInterceptor()) - - if (isInsecure) { - def context = insecureSslContext() - builder.sslSocketFactory(context.socketFactory, context.trustManager) - } - - builder.hostnameVerifier({ hostname, session -> true } as HostnameVerifier) - - return builder.build() - } - - @Singleton - @Named("jenkins") - OkHttpClient okHttpClientJenkins(Config config) { - def builder = new OkHttpClient.Builder() - .cookieJar(new JavaNetCookieJar(new CookieManager())) - .addInterceptor(createLoggingInterceptor()) - .addInterceptor(new RetryInterceptor()) - - if (config.application.insecure) { - def context = insecureSslContext() - builder.sslSocketFactory(context.socketFactory, context.trustManager) - } - - return builder.build() - } - - static HttpLoggingInterceptor createLoggingInterceptor() { - def logger = LoggerFactory.getLogger("com.cloudogu.gitops.HttpClient") - - def ret = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() { - @Override - void log(@NotNull String msg) { - logger.trace(msg) - } - }) - - ret.setLevel(HttpLoggingInterceptor.Level.HEADERS) - ret.redactHeader("Authorization") - - return ret - } - - static InsecureSslContext insecureSslContext() { - def noCheckTrustManager = new X509TrustManager() { - @Override - void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - } - - @Override - void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { - } - - @Override - X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0] - } - } - def sslCtxt = SSLContext.getInstance('SSL') - sslCtxt.init(null, [noCheckTrustManager] as X509TrustManager[], new SecureRandom()) - - return new InsecureSslContext(sslCtxt.socketFactory, noCheckTrustManager) - } - - @TupleConstructor(defaults = false) - static class InsecureSslContext { - final SSLSocketFactory socketFactory - final X509TrustManager trustManager - } + static OkHttpClient buildOkHttpClient(Credentials credentials, Boolean isInsecure) { + def builder = new OkHttpClient.Builder() + .addInterceptor(new AuthorizationInterceptor(credentials.username, credentials.password)) + .addInterceptor(createLoggingInterceptor()) + .addInterceptor(new RetryInterceptor()) + + if (isInsecure) { + def context = insecureSslContext() + builder.sslSocketFactory(context.socketFactory, context.trustManager) + } + + builder.hostnameVerifier({ hostname, session -> true } as HostnameVerifier) + + return builder.build() + } + + @Singleton + @Named("jenkins") + OkHttpClient okHttpClientJenkins(Config config) { + def builder = new OkHttpClient.Builder() + .cookieJar(new JavaNetCookieJar(new CookieManager())) + .addInterceptor(createLoggingInterceptor()) + .addInterceptor(new RetryInterceptor()) + + if (config.application.insecure) { + def context = insecureSslContext() + builder.sslSocketFactory(context.socketFactory, context.trustManager) + } + + return builder.build() + } + + static HttpLoggingInterceptor createLoggingInterceptor() { + def logger = LoggerFactory.getLogger("com.cloudogu.gitops.HttpClient") + + def ret = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() { + @Override + void log(@NotNull String msg) { + logger.trace(msg) + } + }) + + ret.setLevel(HttpLoggingInterceptor.Level.HEADERS) + ret.redactHeader("Authorization") + + return ret + } + + static InsecureSslContext insecureSslContext() { + def noCheckTrustManager = new X509TrustManager() { + @Override + void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {} + + @Override + void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {} + + @Override + X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0] + } + } + def sslCtxt = SSLContext.getInstance('SSL') + sslCtxt.init(null, [noCheckTrustManager] as X509TrustManager[], new SecureRandom()) + + return new InsecureSslContext(sslCtxt.socketFactory, noCheckTrustManager) + } + + @TupleConstructor(defaults = false) + static class InsecureSslContext { + final SSLSocketFactory socketFactory + final X509TrustManager trustManager + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/destroy/ArgoCDDestructionHandler.groovy b/src/main/groovy/com/cloudogu/gitops/destroy/ArgoCDDestructionHandler.groovy index a47a2d712..88d69f4b7 100644 --- a/src/main/groovy/com/cloudogu/gitops/destroy/ArgoCDDestructionHandler.groovy +++ b/src/main/groovy/com/cloudogu/gitops/destroy/ArgoCDDestructionHandler.groovy @@ -4,101 +4,91 @@ import com.cloudogu.gitops.config.Config 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.cloudogu.gitops.kubernetes.api.HelmClient import com.cloudogu.gitops.kubernetes.api.K8sClient +import com.cloudogu.gitops.utils.FileSystemUtils + import io.micronaut.core.annotation.Order -import jakarta.inject.Singleton import java.nio.file.Path +import jakarta.inject.Singleton @Singleton @Order(100) class ArgoCDDestructionHandler implements DestructionHandler { - private K8sClient k8sClient - private GitRepoFactory repoProvider - private HelmClient helmClient - private Config config - private FileSystemUtils fileSystemUtils - private GitHandler gitHandler - ArgoCDDestructionHandler( - Config config, - K8sClient k8sClient, - GitRepoFactory repoProvider, - HelmClient helmClient, - FileSystemUtils fileSystemUtils, - GitHandler gitHandler - ) { - this.k8sClient = k8sClient - this.repoProvider = repoProvider - this.helmClient = helmClient - this.config = config - this.fileSystemUtils = fileSystemUtils - this.gitHandler = gitHandler - } - - @Override - void destroy() { - - def repo = repoProvider.getRepo("argocd/cloud-resources", gitHandler.resourcesScm) - repo.cloneRepo() - - for (def app in k8sClient.getCustomResource("app")) { - if (app.name == 'bootstrap' || app.name == 'argocd' || app.name == 'projects') { - // we don't want bootstrap to kill everything - // argocd and projects are needed for argocd to function and run finalizers - continue - } - - k8sClient.patch( - "app", - app.name, - app.namespace, - 'merge', - [ - metadata: [ - finalizers: [ - "resources-finalizer.argocd.argoproj.io" - ] - ] - ] - ) - } - - List> appsToBeDeleted = [ - new Tuple2("argocd", "bootstrap"), // first to prevent recreation - new Tuple2("argocd", "cluster-resources"), - new Tuple2("argocd", "example-apps"), - ] - - for (def app in appsToBeDeleted) { - k8sClient.delete("app", app.v1, app.v2) - } - - installArgoCDViaHelm(repo) - helmClient.uninstall('argocd', 'argocd') - for (def project in k8sClient.getCustomResource('appprojects')) { - k8sClient.delete("appproject", project.namespace, project.name) - } - - k8sClient.delete("app", 'argocd', "projects") - k8sClient.delete("app", 'argocd', "argocd") - - k8sClient.delete('secret', 'default', 'jenkins-credentials') - k8sClient.delete('secret', 'default', 'argocd-repo-creds-scm') - } - - void installArgoCDViaHelm(GitRepo repo) { - // this is a hack to be able to uninstall using helm - def namePrefix = config.application.namePrefix - - // Install umbrella chart from folder - String umbrellaChartPath = Path.of(repo.getAbsoluteLocalRepoTmpDir(), 'argocd/') - // 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(umbrellaChartPath, 'Chart.yaml'))['dependencies'] - helmClient.addRepo('argo', helmDependencies[0]['repository'] as String) - helmClient.dependencyBuild(umbrellaChartPath) - helmClient.upgrade('argocd', umbrellaChartPath, [namespace: "${namePrefix}argocd"]) - } + private K8sClient k8sClient + private GitRepoFactory repoProvider + private HelmClient helmClient + private Config config + private FileSystemUtils fileSystemUtils + private GitHandler gitHandler + + ArgoCDDestructionHandler(Config config, + K8sClient k8sClient, + GitRepoFactory repoProvider, + HelmClient helmClient, + FileSystemUtils fileSystemUtils, + GitHandler gitHandler) { + this.k8sClient = k8sClient + this.repoProvider = repoProvider + this.helmClient = helmClient + this.config = config + this.fileSystemUtils = fileSystemUtils + this.gitHandler = gitHandler + } + + @Override + void destroy() { + + def repo = repoProvider.getRepo("argocd/cloud-resources", gitHandler.resourcesScm) + repo.cloneRepo() + + for (def app in k8sClient.getCustomResource("app")) { + if (app.name == 'bootstrap' || app.name == 'argocd' || app.name == 'projects') { + // we don't want bootstrap to kill everything + // argocd and projects are needed for argocd to function and run finalizers + continue + } + + k8sClient.patch("app", + app.name, + app.namespace, + 'merge', + [metadata: [finalizers: ["resources-finalizer.argocd.argoproj.io"]]]) + } + + List> appsToBeDeleted = [new Tuple2("argocd", "bootstrap"), // first to prevent recreation + new Tuple2("argocd", "cluster-resources"), + new Tuple2("argocd", "example-apps"),] + + for (def app in appsToBeDeleted) { + k8sClient.delete("app", app.v1, app.v2) + } + + installArgoCDViaHelm(repo) + helmClient.uninstall('argocd', 'argocd') + for (def project in k8sClient.getCustomResource('appprojects')) { + k8sClient.delete("appproject", project.namespace, project.name) + } + + k8sClient.delete("app", 'argocd', "projects") + k8sClient.delete("app", 'argocd', "argocd") + + k8sClient.delete('secret', 'default', 'jenkins-credentials') + k8sClient.delete('secret', 'default', 'argocd-repo-creds-scm') + } + + void installArgoCDViaHelm(GitRepo repo) { + // this is a hack to be able to uninstall using helm + def namePrefix = config.application.namePrefix + + // Install umbrella chart from folder + String umbrellaChartPath = Path.of(repo.getAbsoluteLocalRepoTmpDir(), 'argocd/') + // 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(umbrellaChartPath, 'Chart.yaml'))['dependencies'] + helmClient.addRepo('argo', helmDependencies[0]['repository'] as String) + helmClient.dependencyBuild(umbrellaChartPath) + helmClient.upgrade('argocd', umbrellaChartPath, [namespace: "${namePrefix}argocd"]) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/destroy/Destroyer.groovy b/src/main/groovy/com/cloudogu/gitops/destroy/Destroyer.groovy index b44089ca6..edb8f0e96 100644 --- a/src/main/groovy/com/cloudogu/gitops/destroy/Destroyer.groovy +++ b/src/main/groovy/com/cloudogu/gitops/destroy/Destroyer.groovy @@ -1,28 +1,28 @@ package com.cloudogu.gitops.destroy -import groovy.util.logging.Slf4j import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Singleton @Slf4j class Destroyer { - final List destructionHandlers + final List destructionHandlers - Destroyer(List destructionHandlers) { - this.destructionHandlers = destructionHandlers - } + Destroyer(List destructionHandlers) { + this.destructionHandlers = destructionHandlers + } - void destroy() { - log.info("Start destroying") - for (def handler in destructionHandlers) { - log.info("Running handler $handler.class.simpleName") - handler.destroy() - } - log.info("Finished destroying") - } + void destroy() { + log.info("Start destroying") + for (def handler in destructionHandlers) { + log.info("Running handler $handler.class.simpleName") + handler.destroy() + } + log.info("Finished destroying") + } - List getDestructionHandlers() { - return destructionHandlers - } -} + List getDestructionHandlers() { + return destructionHandlers + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/destroy/DestructionHandler.groovy b/src/main/groovy/com/cloudogu/gitops/destroy/DestructionHandler.groovy index 837eeb5ee..d049bc31b 100644 --- a/src/main/groovy/com/cloudogu/gitops/destroy/DestructionHandler.groovy +++ b/src/main/groovy/com/cloudogu/gitops/destroy/DestructionHandler.groovy @@ -1,5 +1,5 @@ package com.cloudogu.gitops.destroy interface DestructionHandler { - void destroy() -} + void destroy() +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/destroy/JenkinsDestructionHandler.groovy b/src/main/groovy/com/cloudogu/gitops/destroy/JenkinsDestructionHandler.groovy index 6ea7f5202..1e8290b5c 100644 --- a/src/main/groovy/com/cloudogu/gitops/destroy/JenkinsDestructionHandler.groovy +++ b/src/main/groovy/com/cloudogu/gitops/destroy/JenkinsDestructionHandler.groovy @@ -3,29 +3,31 @@ package com.cloudogu.gitops.destroy import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.jenkins.GlobalPropertyManager import com.cloudogu.gitops.jenkins.JobManager + import io.micronaut.core.annotation.Order + import jakarta.inject.Singleton @Singleton @Order(300) class JenkinsDestructionHandler implements DestructionHandler { - private JobManager jobManager - private GlobalPropertyManager globalPropertyManager - private Config configuration + private JobManager jobManager + private GlobalPropertyManager globalPropertyManager + private Config configuration - JenkinsDestructionHandler(JobManager jobManager, Config configuration, GlobalPropertyManager globalPropertyManager) { - this.jobManager = jobManager - this.configuration = configuration - this.globalPropertyManager = globalPropertyManager - } + JenkinsDestructionHandler(JobManager jobManager, Config configuration, GlobalPropertyManager globalPropertyManager) { + this.jobManager = jobManager + this.configuration = configuration + this.globalPropertyManager = globalPropertyManager + } - @Override - void destroy() { - jobManager.deleteJob("${configuration.application.namePrefix}example-apps") - globalPropertyManager.deleteGlobalProperty("SCMM_URL") - globalPropertyManager.deleteGlobalProperty("${configuration.application.namePrefixForEnvVars}REGISTRY_URL") - globalPropertyManager.deleteGlobalProperty("${configuration.application.namePrefixForEnvVars}REGISTRY_PATH") - globalPropertyManager.deleteGlobalProperty("${configuration.application.namePrefixForEnvVars}REGISTRY_PROXY_URL") - globalPropertyManager.deleteGlobalProperty("${configuration.application.namePrefixForEnvVars}K8S_VERSION") - } -} + @Override + void destroy() { + jobManager.deleteJob("${configuration.application.namePrefix}example-apps") + globalPropertyManager.deleteGlobalProperty("SCMM_URL") + globalPropertyManager.deleteGlobalProperty("${configuration.application.namePrefixForEnvVars}REGISTRY_URL") + globalPropertyManager.deleteGlobalProperty("${configuration.application.namePrefixForEnvVars}REGISTRY_PATH") + globalPropertyManager.deleteGlobalProperty("${configuration.application.namePrefixForEnvVars}REGISTRY_PROXY_URL") + globalPropertyManager.deleteGlobalProperty("${configuration.application.namePrefixForEnvVars}K8S_VERSION") + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/destroy/ScmmDestructionHandler.groovy b/src/main/groovy/com/cloudogu/gitops/destroy/ScmmDestructionHandler.groovy index edf91375e..6c0ee2747 100644 --- a/src/main/groovy/com/cloudogu/gitops/destroy/ScmmDestructionHandler.groovy +++ b/src/main/groovy/com/cloudogu/gitops/destroy/ScmmDestructionHandler.groovy @@ -2,48 +2,48 @@ package com.cloudogu.gitops.destroy import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.providers.scmmanager.api.ScmManagerApiClient + import io.micronaut.core.annotation.Order + import jakarta.inject.Singleton @Singleton @Order(200) class ScmmDestructionHandler implements DestructionHandler { - private ScmManagerApiClient scmmApiClient - private Config config - - ScmmDestructionHandler( - Config config - ) { - this.config = config - this.scmmApiClient = scmmApiClient - } - - @Override - void destroy() { - deleteUser("gitops") - deleteRepository("argocd", "argocd") - deleteRepository("argocd", "cluster-resources") - deleteRepository("argocd", "example-apps") - deleteRepository("3rd-party-dependencies", "ces-build-lib", false) - deleteRepository("3rd-party-dependencies", "gitops-build-lib", false) - deleteRepository("3rd-party-dependencies", "spring-boot-helm-chart", false) - deleteRepository("3rd-party-dependencies", "spring-boot-helm-chart-with-dependency", false) - } - - private void deleteRepository(String namespace, String repository, boolean prefixNamespace = true) { - def namePrefix = prefixNamespace ? config.application.namePrefix : '' - def response = scmmApiClient.repositoryApi().delete("${namePrefix}$namespace", repository).execute() - - if (response.code() != 204) { - throw new RuntimeException("Could not delete user $namespace/$repository (${response.code()} ${response.message()}): ${response.errorBody().string()}") - } - } - - private void deleteUser(String name) { - def response = scmmApiClient.usersApi().delete("${config.application.namePrefix}$name").execute() - - if (response.code() != 204) { - throw new RuntimeException("Could not delete user $name (${response.code()} ${response.message()}): ${response.errorBody().string()}") - } - } + private ScmManagerApiClient scmmApiClient + private Config config + + ScmmDestructionHandler(Config config) { + this.config = config + this.scmmApiClient = scmmApiClient + } + + @Override + void destroy() { + deleteUser("gitops") + deleteRepository("argocd", "argocd") + deleteRepository("argocd", "cluster-resources") + deleteRepository("argocd", "example-apps") + deleteRepository("3rd-party-dependencies", "ces-build-lib", false) + deleteRepository("3rd-party-dependencies", "gitops-build-lib", false) + deleteRepository("3rd-party-dependencies", "spring-boot-helm-chart", false) + deleteRepository("3rd-party-dependencies", "spring-boot-helm-chart-with-dependency", false) + } + + private void deleteRepository(String namespace, String repository, boolean prefixNamespace = true) { + def namePrefix = prefixNamespace ? config.application.namePrefix : '' + def response = scmmApiClient.repositoryApi().delete("${namePrefix}$namespace", repository).execute() + + if (response.code() != 204) { + throw new RuntimeException("Could not delete user $namespace/$repository (${response.code()} ${response.message()}): ${response.errorBody().string()}") + } + } + + private void deleteUser(String name) { + def response = scmmApiClient.usersApi().delete("${config.application.namePrefix}$name").execute() + + if (response.code() != 204) { + throw new RuntimeException("Could not delete user $name (${response.code()} ${response.message()}): ${response.errorBody().string()}") + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy b/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy index 854a7edaa..b43f30afd 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/CertManager.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/CertManager.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 groovy.util.logging.Slf4j @Slf4j @Singleton @Order(160) 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/ContentLoader.groovy b/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy index 81fd19f8a..5a222dfac 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/ContentLoader.groovy @@ -1,5 +1,8 @@ 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 @@ -7,16 +10,19 @@ import com.cloudogu.gitops.config.Credentials 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.AllowListFreemarkerObjectWrapper import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.TemplatingEngine + +import io.micronaut.core.annotation.Order + +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 @@ -24,553 +30,524 @@ import org.eclipse.jgit.lib.Ref import org.eclipse.jgit.lib.Repository import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider -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 - ) { - this.config = config - this.k8sClient = k8sClient - this.repoProvider = repoProvider - this.jenkins = jenkins - this.gitHandler = gitHandler - } - - @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() - } - - @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 - } - } - } - - 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) { + this.config = config + this.k8sClient = k8sClient + this.repoProvider = repoProvider + this.jenkins = jenkins + this.gitHandler = gitHandler + } + + @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() + } + + @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 + } + } + } + + 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 2aedc1eac..5c856718d 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) 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..8ecaa2361 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Ingress.groovy @@ -5,48 +5,48 @@ 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) 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 ffc6c1daf..3637ded73 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Jenkins.groovy @@ -10,269 +10,243 @@ 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) - } - - 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, + 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) + } + + 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/Mail.groovy b/src/main/groovy/com/cloudogu/gitops/features/Mail.groovy index 859e050c1..cb15a9370 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Mail.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Mail.groovy @@ -5,13 +5,16 @@ 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 @@ -20,45 +23,41 @@ import org.springframework.security.crypto.bcrypt.BCrypt @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 - - 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 - } - - @Override - boolean isEnabled() { - return config.features.mail.mailServer - } - - @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 : '', - ]) - - deployHelmChart('mailhog', 'mailhog', namespace, config.features.mail.helm, HELM_VALUES_PATH, config) - } -} + 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 + + 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 + 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 : '',]) + + 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..e83b5087a 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Monitoring.groovy @@ -8,12 +8,16 @@ 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 @@ -21,216 +25,195 @@ import java.nio.file.Path @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 61e66ed96..9610e58ed 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Registry.groovy @@ -3,80 +3,77 @@ 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.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) 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. + HelmStrategy 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) - 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/Vault.groovy b/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy index 4616144ed..7baf3dd3c 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/Vault.groovy @@ -6,75 +6,74 @@ 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) 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 f42edfc02..0369a4a31 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCD.groovy @@ -4,346 +4,322 @@ import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.Config 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 - } + 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 + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCDRepoSetup.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCDRepoSetup.groovy index 5d013a467..d4f5a92fb 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCDRepoSetup.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/ArgoCDRepoSetup.groovy @@ -5,160 +5,153 @@ import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.utils.FileSystemUtils -import groovy.util.logging.Slf4j import java.nio.file.Path +import groovy.util.logging.Slf4j /** * Holds ArgoCD-related repo initialization actions (cluster-resources + optional tenant bootstrap) - * and encapsulates the initialization logic (single-instance vs. dedicated instance). - */ + * and encapsulates the initialization logic (single-instance vs. dedicated instance).*/ @Slf4j class ArgoCDRepoSetup { - final RepoInitializationAction clusterResources - final RepoInitializationAction tenantBootstrap // may be null - final List allRepos - - private final Config config - private final FileSystemUtils fileSystemUtils - - private ArgoCDRepoSetup(Config config, - FileSystemUtils fileSystemUtils, - RepoInitializationAction clusterResources, - RepoInitializationAction tenantBootstrap, - List allRepos) { - this.config = config - this.fileSystemUtils = fileSystemUtils - this.clusterResources = clusterResources - this.tenantBootstrap = tenantBootstrap - this.allRepos = allRepos - } - - static ArgoCDRepoSetup create(Config config, FileSystemUtils fileSystemUtils, GitRepoFactory repoFactory, GitHandler gitHandler) { - RepoInitializationAction cluster - RepoInitializationAction tenant - List all = [] - - if (config.multiTenant.useDedicatedInstance) { - // Dedicated instance: tenant bootstrap (tenant provider) + cluster-resources (central provider) - tenant = createRepoInitializationAction(config, repoFactory, gitHandler, - 'argocd/cluster-resources/apps/argocd/multiTenant/tenant', - 'argocd/cluster-resources', - gitHandler.tenant - ) - all.add(tenant) - - cluster = createRepoInitializationAction(config, repoFactory, gitHandler, - 'argocd/cluster-resources', - 'argocd/cluster-resources', - gitHandler.central - ) - all.add(cluster) - - } else { - // Single instance: only cluster-resources (tenant provider) - cluster = createRepoInitializationAction(config, repoFactory, gitHandler, - 'argocd/cluster-resources', - 'argocd/cluster-resources', - gitHandler.tenant - ) - all.add(cluster) - } - - // Configure which subdirectories should be copied into the cluster-resources repo - cluster.subDirsToCopy = determineClusterResourceSubDirs(config) - - return new ArgoCDRepoSetup(config, fileSystemUtils, cluster, tenant, all) - } - - RepoLayout clusterRepoLayout() { - new RepoLayout(clusterResources.repo.getAbsoluteLocalRepoTmpDir()) - } - - RepoLayout tenantRepoLayout() { - if (tenantBootstrap == null) { - throw new IllegalStateException("tenantBootstrap repo is not initialized (single-instance mode).") - } - new RepoLayout(tenantBootstrap.repo.getAbsoluteLocalRepoTmpDir()) - } - - void initLocalRepos() { - allRepos.each { it.initLocalRepo() } - } - - void prepareClusterResourcesRepo() { - RepoLayout layout = clusterRepoLayout() - - if (config.features.argocd.operator) { - fileSystemUtils.deleteDir(layout.helmDir()) - } else { - fileSystemUtils.deleteDir(layout.operatorDir()) - } - - if (config.multiTenant.useDedicatedInstance) { - log.debug("Deleting unnecessary non dedicated instances folders from argocd repo: applications=${clusterRepoLayout().applicationsDir()}, projects=${clusterRepoLayout().projectsDir()}, tenant=${clusterRepoLayout().multiTenantDir()}/tenant") - FileSystemUtils.deleteDir clusterRepoLayout().applicationsDir() - FileSystemUtils.deleteDir clusterRepoLayout().projectsDir() - fileSystemUtils.moveDirectoryMergeOverwrite(Path.of(clusterRepoLayout().multiTenantDir() + "/central"), Path.of(clusterRepoLayout().argocdRoot())) - FileSystemUtils.deleteDir clusterRepoLayout().multiTenantDir() - } else { - fileSystemUtils.deleteDir(layout.multiTenantDir()) - } - - if (!config.application.netpols) { - fileSystemUtils.deleteFile(layout.netpolFile()) - } - } - - void commitAndPushAll(String message) { - allRepos.each { it.repo.commitAndPush(message) } - } - - private static Set determineClusterResourceSubDirs(Config config) { - Set clusterResourceSubDirs = new LinkedHashSet<>() - - clusterResourceSubDirs.add(RepoLayout.argocdSubdirRel()) - - if (config.features.certManager.active) { - clusterResourceSubDirs.add(RepoLayout.certManagerSubdirRel()) - } - if (config.features.ingress.active) { - clusterResourceSubDirs.add(RepoLayout.ingressSubdirRel()) - } - if (config.jenkins.active) { - clusterResourceSubDirs.add(RepoLayout.jenkinsSubdirRel()) - } - if (config.features.mail.active) { - clusterResourceSubDirs.add(RepoLayout.mailhogSubdirRel()) - } - if (config.features.monitoring.active) { - clusterResourceSubDirs.add(RepoLayout.monitoringSubdirRel()) - } - if (config.scm.scmManager?.url) { - clusterResourceSubDirs.add(RepoLayout.scmManagerSubdirRel()) - } - if (config.features.secrets.active) { - clusterResourceSubDirs.add(RepoLayout.secretsSubdirRel()) - clusterResourceSubDirs.add(RepoLayout.vaultSubdirRel()) - } - - return clusterResourceSubDirs - } - - private static RepoInitializationAction createRepoInitializationAction( - Config config, - GitRepoFactory repoFactory, - GitHandler gitHandler, - String localSrcDir, - String scmRepoTarget, - GitProvider gitProvider - ) { - new RepoInitializationAction( - config, - repoFactory.getRepo(scmRepoTarget, gitProvider), - gitHandler, - localSrcDir - ) - } -} + final RepoInitializationAction clusterResources + final RepoInitializationAction tenantBootstrap + // may be null + final List allRepos + + private final Config config + private final FileSystemUtils fileSystemUtils + + private ArgoCDRepoSetup(Config config, + FileSystemUtils fileSystemUtils, + RepoInitializationAction clusterResources, + RepoInitializationAction tenantBootstrap, + List allRepos) { + this.config = config + this.fileSystemUtils = fileSystemUtils + this.clusterResources = clusterResources + this.tenantBootstrap = tenantBootstrap + this.allRepos = allRepos + } + + static ArgoCDRepoSetup create(Config config, FileSystemUtils fileSystemUtils, GitRepoFactory repoFactory, GitHandler gitHandler) { + RepoInitializationAction cluster + RepoInitializationAction tenant + List all = [] + + if (config.multiTenant.useDedicatedInstance) { + // Dedicated instance: tenant bootstrap (tenant provider) + cluster-resources (central provider) + tenant = createRepoInitializationAction(config, repoFactory, gitHandler, + 'argocd/cluster-resources/apps/argocd/multiTenant/tenant', + 'argocd/cluster-resources', + gitHandler.tenant) + all.add(tenant) + + cluster = createRepoInitializationAction(config, repoFactory, gitHandler, + 'argocd/cluster-resources', + 'argocd/cluster-resources', + gitHandler.central) + all.add(cluster) + + } else { + // Single instance: only cluster-resources (tenant provider) + cluster = createRepoInitializationAction(config, repoFactory, gitHandler, + 'argocd/cluster-resources', + 'argocd/cluster-resources', + gitHandler.tenant) + all.add(cluster) + } + + // Configure which subdirectories should be copied into the cluster-resources repo + cluster.subDirsToCopy = determineClusterResourceSubDirs(config) + + return new ArgoCDRepoSetup(config, fileSystemUtils, cluster, tenant, all) + } + + RepoLayout clusterRepoLayout() { + new RepoLayout(clusterResources.repo.getAbsoluteLocalRepoTmpDir()) + } + + RepoLayout tenantRepoLayout() { + if (tenantBootstrap == null) { + throw new IllegalStateException("tenantBootstrap repo is not initialized (single-instance mode).") + } + new RepoLayout(tenantBootstrap.repo.getAbsoluteLocalRepoTmpDir()) + } + + void initLocalRepos() { + allRepos.each { it.initLocalRepo() } + } + + void prepareClusterResourcesRepo() { + RepoLayout layout = clusterRepoLayout() + + if (config.features.argocd.operator) { + fileSystemUtils.deleteDir(layout.helmDir()) + } else { + fileSystemUtils.deleteDir(layout.operatorDir()) + } + + if (config.multiTenant.useDedicatedInstance) { + log.debug("Deleting unnecessary non dedicated instances folders from argocd repo: applications=${clusterRepoLayout().applicationsDir()}, projects=${clusterRepoLayout().projectsDir()}, tenant=${clusterRepoLayout().multiTenantDir()}/tenant") + FileSystemUtils.deleteDir clusterRepoLayout().applicationsDir() + FileSystemUtils.deleteDir clusterRepoLayout().projectsDir() + fileSystemUtils.moveDirectoryMergeOverwrite(Path.of(clusterRepoLayout().multiTenantDir() + "/central"), Path.of(clusterRepoLayout().argocdRoot())) + FileSystemUtils.deleteDir clusterRepoLayout().multiTenantDir() + } else { + fileSystemUtils.deleteDir(layout.multiTenantDir()) + } + + if (!config.application.netpols) { + fileSystemUtils.deleteFile(layout.netpolFile()) + } + } + + void commitAndPushAll(String message) { + allRepos.each { it.repo.commitAndPush(message) } + } + + private static Set determineClusterResourceSubDirs(Config config) { + Set clusterResourceSubDirs = new LinkedHashSet<>() + + clusterResourceSubDirs.add(RepoLayout.argocdSubdirRel()) + + if (config.features.certManager.active) { + clusterResourceSubDirs.add(RepoLayout.certManagerSubdirRel()) + } + if (config.features.ingress.active) { + clusterResourceSubDirs.add(RepoLayout.ingressSubdirRel()) + } + if (config.jenkins.active) { + clusterResourceSubDirs.add(RepoLayout.jenkinsSubdirRel()) + } + if (config.features.mail.active) { + clusterResourceSubDirs.add(RepoLayout.mailhogSubdirRel()) + } + if (config.features.monitoring.active) { + clusterResourceSubDirs.add(RepoLayout.monitoringSubdirRel()) + } + if (config.scm.scmManager?.url) { + clusterResourceSubDirs.add(RepoLayout.scmManagerSubdirRel()) + } + if (config.features.secrets.active) { + clusterResourceSubDirs.add(RepoLayout.secretsSubdirRel()) + clusterResourceSubDirs.add(RepoLayout.vaultSubdirRel()) + } + + return clusterResourceSubDirs + } + + private static RepoInitializationAction createRepoInitializationAction(Config config, + GitRepoFactory repoFactory, + GitHandler gitHandler, + String localSrcDir, + String scmRepoTarget, + GitProvider gitProvider) { + new RepoInitializationAction(config, + repoFactory.getRepo(scmRepoTarget, gitProvider), + gitHandler, + localSrcDir) + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/RepoInitializationAction.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/RepoInitializationAction.groovy index 39c4cf4ef..a61971f67 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/RepoInitializationAction.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/RepoInitializationAction.groovy @@ -3,135 +3,125 @@ package com.cloudogu.gitops.features.argocd import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.git.GitRepo -import freemarker.template.DefaultObjectWrapperBuilder + import groovy.util.logging.Slf4j +import freemarker.template.DefaultObjectWrapperBuilder + @Slf4j class RepoInitializationAction { - private GitRepo repo - private String copyFromDirectory - Set subDirsToCopy = [] as Set - private Config config - private GitHandler gitHandler - - RepoInitializationAction(Config config, GitRepo repo,GitHandler gitHandler, String copyFromDirectory) { - this.config = config - this.repo = repo - this.copyFromDirectory = copyFromDirectory - this.gitHandler = gitHandler - } - - /** - * Clone repo from SCM and initialize it by copying only the configured subdirectories. - * Afterwards we can edit these files. - */ - void initLocalRepo() { - repo.cloneRepo() - - log.debug("Initializing repo ${repo.repoTarget} from ${copyFromDirectory} with subdirs: ${subDirsToCopy}") - repo.copyDirectoryContents(copyFromDirectory, createSubdirFilter()) - replaceTemplates() - } - - void replaceTemplates() { - Map templateModel = buildTemplateValues(config) - repo.replaceTemplates(templateModel) - } - - GitRepo getRepo() { - return repo - } - - private Map buildTemplateValues(Config config) { - def model = [ - tenantName: config.application.tenantName, - argocd : [host: config.features.argocd.url ? new URL(config.features.argocd.url).host : ""], - scm : [ - baseUrl : this.repo.gitProvider.url, - host : this.repo.gitProvider.host, - protocol: this.repo.gitProvider.protocol, - repoUrl : this.repo.gitProvider.repoPrefix(), - centralScmUrl: this.gitHandler.central?.repoPrefix() ?: '' - ], - config : config, - // Allow for using static classes inside the templates - statics : new DefaultObjectWrapperBuilder(freemarker.template.Configuration.VERSION_2_3_32).build().getStaticModels() - ] as Map - - return model - } - - private FileFilter createSubdirFilter() { - if (!subDirsToCopy || subDirsToCopy.isEmpty()) { - return { File f -> true } as FileFilter - } - - File srcRoot = new File(copyFromDirectory).canonicalFile - - // Normalize entries like "argocd", "apps/monitoring" to "argocd/" or "apps/monitoring/" - Set prefixes = subDirsToCopy.collect { String s -> - def norm = s.replace('\\', '/') - norm = norm.replaceAll('^/+', '').replaceAll('/+$', '') - return norm + '/' - } as Set - - boolean hasPrefixes = !prefixes.isEmpty() - - // Templates that MUST be copied (chart templates), even though they match the global templates-exclude - Set templateIncludePrefixes = [ - 'apps/argocd/argocd/templates/' - ] as Set - - return { File f -> - File canon = f.canonicalFile - String rel = srcRoot.toURI().relativize(canon.toURI()).toString() - rel = rel.replace('\\', '/') - - // Always copy the root (copyFromDirectory itself), otherwise we can't build up the directory structure - if (rel == '' || rel == '.') { - return true - } - - boolean isDir = f.isDirectory() - // For directories, always compare using a trailing slash - String relDir = rel.endsWith('/') ? rel : rel + '/' - - // --- Exception: keep required chart templates (e.g., ArgoCD chart templates) --- - // If the current path is inside an explicitly allowed templates subtree, always allow it. - if (templateIncludePrefixes.any { String p -> - (isDir ? relDir : rel).startsWith(p) - }) { - return true - } - - // --- Global excludes for feature templates --- - // do NOT copy anything under apps/**/templates/** into the SCM repo - if (rel.startsWith('apps/') && relDir.contains('/templates/')) { - return false - } - - // If no prefixes are configured, copy everything (except templates) - if (!hasPrefixes) { - return true - } - - if (isDir) { - // Allow a directory if it is: - // - exactly one of the requested subdirs, or - // - inside one of them, or - // - a parent of one of them (needed to keep the tree structure). - return prefixes.any { String p -> - relDir == p || relDir.startsWith(p) || p.startsWith(relDir) - } - } else { - // Only copy files that are directly under one of the allowed subtrees - return prefixes.any { String p -> - rel.startsWith(p) - } - } - } as FileFilter - } - - + private GitRepo repo + private String copyFromDirectory + Set subDirsToCopy = [] as Set + private Config config + private GitHandler gitHandler + + RepoInitializationAction(Config config, GitRepo repo, GitHandler gitHandler, String copyFromDirectory) { + this.config = config + this.repo = repo + this.copyFromDirectory = copyFromDirectory + this.gitHandler = gitHandler + } + + /** + * Clone repo from SCM and initialize it by copying only the configured subdirectories. + * Afterwards we can edit these files.*/ + void initLocalRepo() { + repo.cloneRepo() + + log.debug("Initializing repo ${repo.repoTarget} from ${copyFromDirectory} with subdirs: ${subDirsToCopy}") + repo.copyDirectoryContents(copyFromDirectory, createSubdirFilter()) + replaceTemplates() + } + + void replaceTemplates() { + Map templateModel = buildTemplateValues(config) + repo.replaceTemplates(templateModel) + } + + GitRepo getRepo() { + return repo + } + + private Map buildTemplateValues(Config config) { + def model = [tenantName: config.application.tenantName, + argocd : [host: config.features.argocd.url ? new URL(config.features.argocd.url).host : ""], + scm : [baseUrl : this.repo.gitProvider.url, + host : this.repo.gitProvider.host, + protocol : this.repo.gitProvider.protocol, + repoUrl : this.repo.gitProvider.repoPrefix(), + centralScmUrl: this.gitHandler.central?.repoPrefix() ?: ''], + config : config, + // Allow for using static classes inside the templates + statics : new DefaultObjectWrapperBuilder(freemarker.template.Configuration.VERSION_2_3_32).build().getStaticModels()] as Map + + return model + } + + private FileFilter createSubdirFilter() { + if (!subDirsToCopy || subDirsToCopy.isEmpty()) { + return { File f -> true } as FileFilter + } + + File srcRoot = new File(copyFromDirectory).canonicalFile + + // Normalize entries like "argocd", "apps/monitoring" to "argocd/" or "apps/monitoring/" + Set prefixes = subDirsToCopy.collect { String s -> + def norm = s.replace('\\', '/') + norm = norm.replaceAll('^/+', '').replaceAll('/+$', '') + return norm + '/' + } as Set + + boolean hasPrefixes = !prefixes.isEmpty() + + // Templates that MUST be copied (chart templates), even though they match the global templates-exclude + Set templateIncludePrefixes = ['apps/argocd/argocd/templates/'] as Set + + return { File f -> + File canon = f.canonicalFile + String rel = srcRoot.toURI().relativize(canon.toURI()).toString() + rel = rel.replace('\\', '/') + + // Always copy the root (copyFromDirectory itself), otherwise we can't build up the directory structure + if (rel == '' || rel == '.') { + return true + } + + boolean isDir = f.isDirectory() + // For directories, always compare using a trailing slash + String relDir = rel.endsWith('/') ? rel : rel + '/' + + // --- Exception: keep required chart templates (e.g., ArgoCD chart templates) --- + // If the current path is inside an explicitly allowed templates subtree, always allow it. + if (templateIncludePrefixes.any { String p -> (isDir ? relDir : rel).startsWith(p) + }) { + return true + } + + // --- Global excludes for feature templates --- + // do NOT copy anything under apps/**/templates/** into the SCM repo + if (rel.startsWith('apps/') && relDir.contains('/templates/')) { + return false + } + + // If no prefixes are configured, copy everything (except templates) + if (!hasPrefixes) { + return true + } + + if (isDir) { + // Allow a directory if it is: + // - exactly one of the requested subdirs, or + // - inside one of them, or + // - a parent of one of them (needed to keep the tree structure). + return prefixes.any { String p -> relDir == p || relDir.startsWith(p) || p.startsWith(relDir) + } + } else { + // Only copy files that are directly under one of the allowed subtrees + return prefixes.any { String p -> rel.startsWith(p) + } + } + } as FileFilter + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/argocd/RepoLayout.groovy b/src/main/groovy/com/cloudogu/gitops/features/argocd/RepoLayout.groovy index f258c89d0..edf1253d6 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/argocd/RepoLayout.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/argocd/RepoLayout.groovy @@ -3,130 +3,135 @@ package com.cloudogu.gitops.features.argocd import java.nio.file.Path class RepoLayout { - private static final String APPS_MONITORING_DIR = 'apps/monitoring' - private static final String APPS_SECRETS_DIR = 'apps/external-secrets' - private static final String APPS_VAULT_DIR = 'apps/vault' - private static final String APPS_CERTMANAGER_DIR = 'apps/cert-manager' - private static final String APPS_JENKINS_DIR = 'apps/jenkins' - private static final String APPS_INGRESS_DIR = 'apps/ingress' - private static final String APPS_MAILHOG_DIR = 'apps/mail' - private static final String APPS_SCMMANAGER_DIR = 'apps/scm-manager' - private static final String APPS_ARGOCD_DIR = 'apps/argocd' - - private static final String OPERATOR_DIR = 'operator' - private static final String MULTITENANT_DIR = 'multiTenant' - private static final String APPLICATIONS_DIR = 'applications' - private static final String PROJECTS_DIR = 'projects' - private static final String HELM_DIR = 'argocd' // argocd/argocd - private static final String NETPOL_YAML = 'templates/allow-namespaces.yaml' - - private final String repoRootDir - - RepoLayout(String repoRootDir) { - this.repoRootDir = repoRootDir - } - - String rootDir() { - repoRootDir - } - - String argocdRoot() { - Path.of(repoRootDir, APPS_ARGOCD_DIR).toString() - } - - // --- folder --- - - String operatorDir() { - Path.of(argocdRoot(), OPERATOR_DIR).toString() - } - - String operatorRbacDir() { - // "cluster-resources/apps/argocd/operator/rbac" - Path.of(operatorDir(), "rbac").toString() - } - - String operatorConfigFile() { - // "cluster-resources/apps/argocd/operator/argocd.yaml" - Path.of(operatorDir(), "argocd.yaml").toString() - } - - String multiTenantDir() { - Path.of(argocdRoot(), MULTITENANT_DIR).toString() - } - - String applicationsDir() { - Path.of(argocdRoot(), APPLICATIONS_DIR).toString() - } - - String projectsDir() { - Path.of(argocdRoot(), PROJECTS_DIR).toString() - } - - String helmDir() { - Path.of(argocdRoot(), HELM_DIR).toString() - } - - String helmValuesFile() { - // "cluster-resources/apps/argocd/argocd/values.yaml" - Path.of(helmDir(), "values.yaml").toString() - } - - String chartYaml() { - Path.of(helmDir(), "Chart.yaml").toString() - } - - String netpolFile() { - Path.of(helmDir(), NETPOL_YAML).toString() - } - - String monitoringDir() { - Path.of(repoRootDir, APPS_MONITORING_DIR).toString() - } - - String vaultDir() { - Path.of(repoRootDir, APPS_VAULT_DIR).toString() - } - - static String monitoringSubdirRel() { - APPS_MONITORING_DIR - } - - static String secretsSubdirRel() { - APPS_SECRETS_DIR - } - static String vaultSubdirRel() { - APPS_VAULT_DIR - } - - static String certManagerSubdirRel() { - APPS_CERTMANAGER_DIR - } - - static String jenkinsSubdirRel() { - APPS_JENKINS_DIR - } - - static String ingressSubdirRel() { - APPS_INGRESS_DIR - } - static String mailhogSubdirRel() { - APPS_MAILHOG_DIR - } - static String scmManagerSubdirRel() { - APPS_SCMMANAGER_DIR - } - static String argocdSubdirRel() { - APPS_ARGOCD_DIR - } - - // --- relative subfolders for RBAC (passed to RbacDefinition.withSubfolder) --- - static String operatorRbacSubfolder() { - // "argocd/operator/rbac" - "${APPS_ARGOCD_DIR}/${OPERATOR_DIR}/rbac" - } - - static String operatorRbacTenantSubfolder() { - // "argocd/operator/rbac/tenant" - "${operatorRbacSubfolder()}/tenant" - } -} + private static final String APPS_MONITORING_DIR = 'apps/monitoring' + private static final String APPS_SECRETS_DIR = 'apps/external-secrets' + private static final String APPS_VAULT_DIR = 'apps/vault' + private static final String APPS_CERTMANAGER_DIR = 'apps/cert-manager' + private static final String APPS_JENKINS_DIR = 'apps/jenkins' + private static final String APPS_INGRESS_DIR = 'apps/ingress' + private static final String APPS_MAILHOG_DIR = 'apps/mail' + private static final String APPS_SCMMANAGER_DIR = 'apps/scm-manager' + private static final String APPS_ARGOCD_DIR = 'apps/argocd' + + private static final String OPERATOR_DIR = 'operator' + private static final String MULTITENANT_DIR = 'multiTenant' + private static final String APPLICATIONS_DIR = 'applications' + private static final String PROJECTS_DIR = 'projects' + private static final String HELM_DIR = 'argocd' + // argocd/argocd + private static final String NETPOL_YAML = 'templates/allow-namespaces.yaml' + + private final String repoRootDir + + RepoLayout(String repoRootDir) { + this.repoRootDir = repoRootDir + } + + String rootDir() { + repoRootDir + } + + String argocdRoot() { + Path.of(repoRootDir, APPS_ARGOCD_DIR).toString() + } + + // --- folder --- + + String operatorDir() { + Path.of(argocdRoot(), OPERATOR_DIR).toString() + } + + String operatorRbacDir() { + // "cluster-resources/apps/argocd/operator/rbac" + Path.of(operatorDir(), "rbac").toString() + } + + String operatorConfigFile() { + // "cluster-resources/apps/argocd/operator/argocd.yaml" + Path.of(operatorDir(), "argocd.yaml").toString() + } + + String multiTenantDir() { + Path.of(argocdRoot(), MULTITENANT_DIR).toString() + } + + String applicationsDir() { + Path.of(argocdRoot(), APPLICATIONS_DIR).toString() + } + + String projectsDir() { + Path.of(argocdRoot(), PROJECTS_DIR).toString() + } + + String helmDir() { + Path.of(argocdRoot(), HELM_DIR).toString() + } + + String helmValuesFile() { + // "cluster-resources/apps/argocd/argocd/values.yaml" + Path.of(helmDir(), "values.yaml").toString() + } + + String chartYaml() { + Path.of(helmDir(), "Chart.yaml").toString() + } + + String netpolFile() { + Path.of(helmDir(), NETPOL_YAML).toString() + } + + String monitoringDir() { + Path.of(repoRootDir, APPS_MONITORING_DIR).toString() + } + + String vaultDir() { + Path.of(repoRootDir, APPS_VAULT_DIR).toString() + } + + static String monitoringSubdirRel() { + APPS_MONITORING_DIR + } + + static String secretsSubdirRel() { + APPS_SECRETS_DIR + } + + static String vaultSubdirRel() { + APPS_VAULT_DIR + } + + static String certManagerSubdirRel() { + APPS_CERTMANAGER_DIR + } + + static String jenkinsSubdirRel() { + APPS_JENKINS_DIR + } + + static String ingressSubdirRel() { + APPS_INGRESS_DIR + } + + static String mailhogSubdirRel() { + APPS_MAILHOG_DIR + } + + static String scmManagerSubdirRel() { + APPS_SCMMANAGER_DIR + } + + static String argocdSubdirRel() { + APPS_ARGOCD_DIR + } + + // --- relative subfolders for RBAC (passed to RbacDefinition.withSubfolder) --- + static String operatorRbacSubfolder() { + // "argocd/operator/rbac" + "${APPS_ARGOCD_DIR}/${OPERATOR_DIR}/rbac" + } + + static String operatorRbacTenantSubfolder() { + // "argocd/operator/rbac/tenant" + "${operatorRbacSubfolder()}/tenant" + } +} \ No newline at end of file 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..06c354256 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,125 @@ 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 + @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}") + } + } } \ No newline at end of file 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..e5ccba8c5 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,30 @@ 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 + 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 - } + 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) - } - } -} + @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) + } + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/deployment/DeploymentStrategy.groovy b/src/main/groovy/com/cloudogu/gitops/features/deployment/DeploymentStrategy.groovy index d2cf3a6e4..15348b424 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/deployment/DeploymentStrategy.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/deployment/DeploymentStrategy.groovy @@ -3,13 +3,15 @@ package com.cloudogu.gitops.features.deployment import java.nio.file.Path interface DeploymentStrategy { - void deployFeature(String repoURL, String repoName, String chartOrPath, String version, String namespace, - String releaseName, Path helmValuesPath, RepoType repoType) - - default void deployFeature(String repoURL, String repoName, String chart, String version, String namespace, - String releaseName, Path helmValuesPath) { - deployFeature(repoURL, repoName, chart, version, namespace, releaseName, helmValuesPath, RepoType.HELM) - } - - enum RepoType { HELM, GIT } -} + void deployFeature(String repoURL, String repoName, String chartOrPath, String version, String namespace, + String releaseName, Path helmValuesPath, RepoType repoType) + + default void deployFeature(String repoURL, String repoName, String chart, String version, String namespace, + String releaseName, Path helmValuesPath) { + deployFeature(repoURL, repoName, chart, version, namespace, releaseName, helmValuesPath, RepoType.HELM) + } + + enum RepoType { + HELM, GIT + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/deployment/HelmStrategy.groovy b/src/main/groovy/com/cloudogu/gitops/features/deployment/HelmStrategy.groovy index 384be0052..18ec6270f 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/deployment/HelmStrategy.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/deployment/HelmStrategy.groovy @@ -1,41 +1,38 @@ package com.cloudogu.gitops.features.deployment import com.cloudogu.gitops.config.Config - import com.cloudogu.gitops.kubernetes.api.HelmClient -import groovy.util.logging.Slf4j -import jakarta.inject.Singleton import java.nio.file.Path +import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton class HelmStrategy implements DeploymentStrategy { - private HelmClient helmClient - private Config config - - HelmStrategy(Config config, HelmClient helmClient) { - this.config = config - this.helmClient = helmClient - } - - @Override - void deployFeature(String repoURL, String repoName, String chartOrPath, String version, String namespace, - String releaseName, Path helmValuesPath, RepoType repoType) { - - if (repoType == RepoType.GIT) { - // This would be possible with plugins or by pulling the repo first, but for now, we don't need it - throw new RuntimeException("Unable to deploy helm chart via Helm CLI from Git URL, because helm does not support this out of the box.\n" + - "Repo URL: ${repoURL}") - } - - log.debug("Imperatively deploying helm release ${releaseName} basing on chart ${chartOrPath} from ${repoURL}, " + - "version ${version}, into namespace ${namespace}. Using values:\n${helmValuesPath.toFile().text}") - - helmClient.addRepo(repoName, repoURL) - helmClient.upgrade(releaseName, "$repoName/$chartOrPath", - [namespace: namespace, - version : version, - values : helmValuesPath.toString()]) - } + private HelmClient helmClient + private Config config + + HelmStrategy(Config config, HelmClient helmClient) { + this.config = config + this.helmClient = helmClient + } + + @Override + void deployFeature(String repoURL, String repoName, String chartOrPath, String version, String namespace, + String releaseName, Path helmValuesPath, RepoType repoType) { + + if (repoType == RepoType.GIT) { + // This would be possible with plugins or by pulling the repo first, but for now, we don't need it + throw new RuntimeException("Unable to deploy helm chart via Helm CLI from Git URL, because helm does not support this out of the box.\n" + "Repo URL: ${repoURL}") + } + + log.debug("Imperatively deploying helm release ${releaseName} basing on chart ${chartOrPath} from ${repoURL}, " + "version ${version}, into namespace ${namespace}. Using values:\n${helmValuesPath.toFile().text}") + + helmClient.addRepo(repoName, repoURL) + helmClient.upgrade(releaseName, "$repoName/$chartOrPath", + [namespace: namespace, + version : version, + values : helmValuesPath.toString()]) + } } \ 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..d55e42818 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) 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 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 + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/config/ScmCentralSchema.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/config/ScmCentralSchema.groovy index b8cb97f56..c2aa66ba8 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/config/ScmCentralSchema.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/config/ScmCentralSchema.groovy @@ -4,91 +4,92 @@ import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.Credentials import com.cloudogu.gitops.features.git.config.util.GitlabConfig import com.cloudogu.gitops.features.git.config.util.ScmManagerConfig + import com.fasterxml.jackson.annotation.JsonPropertyDescription import picocli.CommandLine.Option class ScmCentralSchema { - static class GitlabCentralConfig implements GitlabConfig { + static class GitlabCentralConfig implements GitlabConfig { - public static final String CENTRAL_GITLAB_URL_DESCRIPTION = "URL for external Gitlab" - public static final String CENTRAL_GITLAB_USERNAME_DESCRIPTION = "GitLab username for API access. Must be 'oauth2' when using Personal Access Token (PAT) authentication" - public static final String CENTRAL_GITLAB_PASSWORD_DESCRIPTION = "Password for SCM Manager authentication" - public static final String CENTRAL_GITLAB_PARENTGROUP_ID_DESCRIPTION = "Main Group for Gitlab where the GOP creates it's groups/repos" + public static final String CENTRAL_GITLAB_URL_DESCRIPTION = "URL for external Gitlab" + public static final String CENTRAL_GITLAB_USERNAME_DESCRIPTION = "GitLab username for API access. Must be 'oauth2' when using Personal Access Token (PAT) authentication" + public static final String CENTRAL_GITLAB_PASSWORD_DESCRIPTION = "Password for SCM Manager authentication" + public static final String CENTRAL_GITLAB_PARENTGROUP_ID_DESCRIPTION = "Main Group for Gitlab where the GOP creates it's groups/repos" - // Only supports external Gitlab for now - @Option(names = ['--central-gitlab-url'], description = CENTRAL_GITLAB_URL_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_GITLAB_URL_DESCRIPTION) - String url = 'https://gitlab.com/' + // Only supports external Gitlab for now + @Option(names = ['--central-gitlab-url'], description = CENTRAL_GITLAB_URL_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_GITLAB_URL_DESCRIPTION) + String url = 'https://gitlab.com/' - @Option(names = ['--central-gitlab-username'], description = CENTRAL_GITLAB_USERNAME_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_GITLAB_USERNAME_DESCRIPTION) - String username = 'oauth2.0' + @Option(names = ['--central-gitlab-username'], description = CENTRAL_GITLAB_USERNAME_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_GITLAB_USERNAME_DESCRIPTION) + String username = 'oauth2.0' - @Option(names = ['--central-gitlab-token'], description = CENTRAL_GITLAB_PASSWORD_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_GITLAB_PASSWORD_DESCRIPTION) - String password = '' + @Option(names = ['--central-gitlab-token'], description = CENTRAL_GITLAB_PASSWORD_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_GITLAB_PASSWORD_DESCRIPTION) + String password = '' - @Option(names = ['--central-gitlab-group-id'], description = CENTRAL_GITLAB_PARENTGROUP_ID_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_GITLAB_PARENTGROUP_ID_DESCRIPTION) - String parentGroupId = '' + @Option(names = ['--central-gitlab-group-id'], description = CENTRAL_GITLAB_PARENTGROUP_ID_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_GITLAB_PARENTGROUP_ID_DESCRIPTION) + String parentGroupId = '' - Credentials getCredentials() { - return new Credentials(username, password) - } + Credentials getCredentials() { + return new Credentials(username, password) + } - String gitOpsUsername = '' - String defaultVisibility = '' - } + String gitOpsUsername = '' + String defaultVisibility = '' + } - static class ScmManagerCentralConfig implements ScmManagerConfig { + static class ScmManagerCentralConfig implements ScmManagerConfig { - public static final String CENTRAL_SCMM_INTERNAL_DESCRIPTION = 'SCM for Central Management is running on the same cluster, so k8s internal URLs can be used for access' - public static final String CENTRAL_SCMM_URL_DESCRIPTION = 'URL for the centralized Management Repo' - public static final String CENTRAL_SCMM_USERNAME_DESCRIPTION = 'CENTRAL SCMM username' - public static final String CENTRAL_SCMM_PASSWORD_DESCRIPTION = 'CENTRAL SCMM password' - public static final String CENTRAL_SCMM_PATH_DESCRIPTION = 'Root path for SCM Manager. In SCM-Manager it is always "repo"' - public static final String CENTRAL_SCMM_NAMESPACE_DESCRIPTION = 'Namespace where to find the Central SCMM' + public static final String CENTRAL_SCMM_INTERNAL_DESCRIPTION = 'SCM for Central Management is running on the same cluster, so k8s internal URLs can be used for access' + public static final String CENTRAL_SCMM_URL_DESCRIPTION = 'URL for the centralized Management Repo' + public static final String CENTRAL_SCMM_USERNAME_DESCRIPTION = 'CENTRAL SCMM username' + public static final String CENTRAL_SCMM_PASSWORD_DESCRIPTION = 'CENTRAL SCMM password' + public static final String CENTRAL_SCMM_PATH_DESCRIPTION = 'Root path for SCM Manager. In SCM-Manager it is always "repo"' + public static final String CENTRAL_SCMM_NAMESPACE_DESCRIPTION = 'Namespace where to find the Central SCMM' - @Option(names = ['--central-scmm-internal'], description = CENTRAL_SCMM_INTERNAL_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_SCMM_INTERNAL_DESCRIPTION) - Boolean internal = false + @Option(names = ['--central-scmm-internal'], description = CENTRAL_SCMM_INTERNAL_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_SCMM_INTERNAL_DESCRIPTION) + Boolean internal = false - @Option(names = ['--central-scmm-url'], description = CENTRAL_SCMM_URL_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_SCMM_URL_DESCRIPTION) - String url = '' + @Option(names = ['--central-scmm-url'], description = CENTRAL_SCMM_URL_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_SCMM_URL_DESCRIPTION) + String url = '' - @Option(names = ['--central-scmm-username'], description = CENTRAL_SCMM_USERNAME_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_SCMM_USERNAME_DESCRIPTION) - String username = '' + @Option(names = ['--central-scmm-username'], description = CENTRAL_SCMM_USERNAME_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_SCMM_USERNAME_DESCRIPTION) + String username = '' - @Option(names = ['--central-scmm-password'], description = CENTRAL_SCMM_PASSWORD_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_SCMM_PASSWORD_DESCRIPTION) - String password = '' + @Option(names = ['--central-scmm-password'], description = CENTRAL_SCMM_PASSWORD_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_SCMM_PASSWORD_DESCRIPTION) + String password = '' - @Option(names = ['--central-scmm-root-path'], description = CENTRAL_SCMM_PATH_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_SCMM_PATH_DESCRIPTION) - String rootPath = 'repo' + @Option(names = ['--central-scmm-root-path'], description = CENTRAL_SCMM_PATH_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_SCMM_PATH_DESCRIPTION) + String rootPath = 'repo' - @Option(names = ['--central-scmm-namespace'], description = CENTRAL_SCMM_NAMESPACE_DESCRIPTION) - @JsonPropertyDescription(CENTRAL_SCMM_NAMESPACE_DESCRIPTION) - String namespace = 'scm-manager' + @Option(names = ['--central-scmm-namespace'], description = CENTRAL_SCMM_NAMESPACE_DESCRIPTION) + @JsonPropertyDescription(CENTRAL_SCMM_NAMESPACE_DESCRIPTION) + String namespace = 'scm-manager' - @Override - String getIngress() { - return null //Needed for setup - } + @Override + String getIngress() { + return null //Needed for setup + } - @Override - Config.HelmConfigWithValues getHelm() { - return null //Needed for setup - } + @Override + Config.HelmConfigWithValues getHelm() { + return null //Needed for setup + } - Credentials getCredentials() { - return new Credentials(username, password) - } + Credentials getCredentials() { + return new Credentials(username, password) + } - String gitOpsUsername = '' + String gitOpsUsername = '' - } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/config/ScmTenantSchema.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/config/ScmTenantSchema.groovy index 33b7f72fb..3d04ef1b9 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/config/ScmTenantSchema.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/config/ScmTenantSchema.groovy @@ -1,162 +1,157 @@ package com.cloudogu.gitops.features.git.config +import static com.cloudogu.gitops.config.ConfigConstants.HELM_CONFIG_DESCRIPTION + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.Credentials import com.cloudogu.gitops.features.git.config.util.GitlabConfig import com.cloudogu.gitops.features.git.config.util.ScmManagerConfig import com.cloudogu.gitops.features.git.config.util.ScmProviderType import com.cloudogu.gitops.utils.NetworkingUtils + import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonMerge import com.fasterxml.jackson.annotation.JsonPropertyDescription import picocli.CommandLine.Mixin import picocli.CommandLine.Option -import static com.cloudogu.gitops.config.ConfigConstants.HELM_CONFIG_DESCRIPTION - class ScmTenantSchema { - static final String GITLAB_CONFIG_DESCRIPTION = 'Config for GITLAB' - static final String SCMM_CONFIG_DESCRIPTION = 'Config for GITLAB' - static final String SCM_PROVIDER_TYPE_DESCRIPTION = 'The SCM provider type. Possible values: SCM_MANAGER, GITLAB' - static final String GITOPSUSERNAME_DESCRIPTION = 'Username for the Gitops User' - - @Option( - names = ['--scm-provider'], - description = SCM_PROVIDER_TYPE_DESCRIPTION, - defaultValue = "SCM_MANAGER" - ) - @JsonPropertyDescription(SCM_PROVIDER_TYPE_DESCRIPTION) - ScmProviderType scmProviderType = ScmProviderType.SCM_MANAGER - - @JsonPropertyDescription(GITLAB_CONFIG_DESCRIPTION) - @Mixin - GitlabTenantConfig gitlab + static final String GITLAB_CONFIG_DESCRIPTION = 'Config for GITLAB' + static final String SCMM_CONFIG_DESCRIPTION = 'Config for GITLAB' + static final String SCM_PROVIDER_TYPE_DESCRIPTION = 'The SCM provider type. Possible values: SCM_MANAGER, GITLAB' + static final String GITOPSUSERNAME_DESCRIPTION = 'Username for the Gitops User' + + @Option(names = ['--scm-provider'], + description = SCM_PROVIDER_TYPE_DESCRIPTION, + defaultValue = "SCM_MANAGER") + @JsonPropertyDescription(SCM_PROVIDER_TYPE_DESCRIPTION) + ScmProviderType scmProviderType = ScmProviderType.SCM_MANAGER - @JsonPropertyDescription(SCMM_CONFIG_DESCRIPTION) - @Mixin - ScmManagerTenantConfig scmManager + @JsonPropertyDescription(GITLAB_CONFIG_DESCRIPTION) + @Mixin + GitlabTenantConfig gitlab - @JsonIgnore - Boolean internal = { -> - return (gitlab.internal || scmManager.internal) - } + @JsonPropertyDescription(SCMM_CONFIG_DESCRIPTION) + @Mixin + ScmManagerTenantConfig scmManager + @JsonIgnore + Boolean internal = { -> return (gitlab.internal || scmManager.internal) + } - static class GitlabTenantConfig implements GitlabConfig { + static class GitlabTenantConfig implements GitlabConfig { - static final String GITLAB_INTERNAL_DESCRIPTION = 'True if Gitlab is running in the same K8s cluster. For now we only support access by external URL' - static final String GITLAB_URL_DESCRIPTION = "Base URL for the Gitlab instance" - static final String GITLAB_USERNAME_DESCRIPTION = 'Defaults to: oauth2.0 when PAT token is given.' - static final String GITLAB_TOKEN_DESCRIPTION = 'PAT Token for the account. Needs read/write repo permissions. See docs for mor information' - static final String GITLAB_PARENT_GROUP_ID = 'Number for the Gitlab Group where the repos and subgroups should be created' + static final String GITLAB_INTERNAL_DESCRIPTION = 'True if Gitlab is running in the same K8s cluster. For now we only support access by external URL' + static final String GITLAB_URL_DESCRIPTION = "Base URL for the Gitlab instance" + static final String GITLAB_USERNAME_DESCRIPTION = 'Defaults to: oauth2.0 when PAT token is given.' + static final String GITLAB_TOKEN_DESCRIPTION = 'PAT Token for the account. Needs read/write repo permissions. See docs for mor information' + static final String GITLAB_PARENT_GROUP_ID = 'Number for the Gitlab Group where the repos and subgroups should be created' - @JsonPropertyDescription(GITLAB_INTERNAL_DESCRIPTION) - Boolean internal = false + @JsonPropertyDescription(GITLAB_INTERNAL_DESCRIPTION) + Boolean internal = false - @Option(names = ['--gitlab-url'], description = GITLAB_URL_DESCRIPTION) - @JsonPropertyDescription(GITLAB_URL_DESCRIPTION) - String url + @Option(names = ['--gitlab-url'], description = GITLAB_URL_DESCRIPTION) + @JsonPropertyDescription(GITLAB_URL_DESCRIPTION) + String url - @Option(names = ['--gitlab-username'], description = GITLAB_USERNAME_DESCRIPTION) - @JsonPropertyDescription(GITLAB_USERNAME_DESCRIPTION) - String username = 'oauth2.0' + @Option(names = ['--gitlab-username'], description = GITLAB_USERNAME_DESCRIPTION) + @JsonPropertyDescription(GITLAB_USERNAME_DESCRIPTION) + String username = 'oauth2.0' - @Option(names = ['--gitlab-token'], description = GITLAB_TOKEN_DESCRIPTION) - @JsonPropertyDescription(GITLAB_TOKEN_DESCRIPTION) - String password + @Option(names = ['--gitlab-token'], description = GITLAB_TOKEN_DESCRIPTION) + @JsonPropertyDescription(GITLAB_TOKEN_DESCRIPTION) + String password - @Option(names = ['--gitlab-group-id'], description = GITLAB_PARENT_GROUP_ID) - @JsonPropertyDescription(GITLAB_PARENT_GROUP_ID) - String parentGroupId = '' + @Option(names = ['--gitlab-group-id'], description = GITLAB_PARENT_GROUP_ID) + @JsonPropertyDescription(GITLAB_PARENT_GROUP_ID) + String parentGroupId = '' - @JsonIgnore - Credentials getCredentials() { - return new Credentials(username, password) - } + @JsonIgnore + Credentials getCredentials() { + return new Credentials(username, password) + } - @JsonPropertyDescription(GITOPSUSERNAME_DESCRIPTION) - String gitOpsUsername = '' - String defaultVisibility = '' + @JsonPropertyDescription(GITOPSUSERNAME_DESCRIPTION) + String gitOpsUsername = '' + String defaultVisibility = '' - } + } - static class ScmManagerTenantConfig implements ScmManagerConfig { + static class ScmManagerTenantConfig implements ScmManagerConfig { - static final String SCMM_SKIP_RESTART_DESCRIPTION = 'Skips restarting SCM-Manager 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.\'' - static final String SCMM_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.' - static final String SCMM_URL_DESCRIPTION = 'The host of your external scm-manager' - static final String SCMM_USERNAME_DESCRIPTION = 'Mandatory when scmm-url is set' - static final String SCMM_PASSWORD_DESCRIPTION = 'Mandatory when scmm-url is set' - static final String SCMM_ROOT_PATH_DESCRIPTION = 'Sets the root path for the Git Repositories. In SCM-Manager it is always "repo"' - static final String SCMM_NAMESPACE_DESCRIPTION = 'Namespace where SCM-Manager should run' + static final String SCMM_SKIP_RESTART_DESCRIPTION = 'Skips restarting SCM-Manager 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.\'' + static final String SCMM_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.' + static final String SCMM_URL_DESCRIPTION = 'The host of your external scm-manager' + static final String SCMM_USERNAME_DESCRIPTION = 'Mandatory when scmm-url is set' + static final String SCMM_PASSWORD_DESCRIPTION = 'Mandatory when scmm-url is set' + static final String SCMM_ROOT_PATH_DESCRIPTION = 'Sets the root path for the Git Repositories. In SCM-Manager it is always "repo"' + static final String SCMM_NAMESPACE_DESCRIPTION = 'Namespace where SCM-Manager should run' - Boolean internal = true + Boolean internal = true - @Option(names = ['--scmm-url'], description = SCMM_URL_DESCRIPTION) - @JsonPropertyDescription(SCMM_URL_DESCRIPTION) - String url = '' + @Option(names = ['--scmm-url'], description = SCMM_URL_DESCRIPTION) + @JsonPropertyDescription(SCMM_URL_DESCRIPTION) + String url = '' - @Option(names = ['--scmm-namespace'], description = SCMM_NAMESPACE_DESCRIPTION) - @JsonPropertyDescription(SCMM_NAMESPACE_DESCRIPTION) - String namespace = 'scm-manager' + @Option(names = ['--scmm-namespace'], description = SCMM_NAMESPACE_DESCRIPTION) + @JsonPropertyDescription(SCMM_NAMESPACE_DESCRIPTION) + String namespace = 'scm-manager' - @Option(names = ['--scmm-username'], description = SCMM_USERNAME_DESCRIPTION) - @JsonPropertyDescription(SCMM_USERNAME_DESCRIPTION) - String username = Config.DEFAULT_ADMIN_USER + @Option(names = ['--scmm-username'], description = SCMM_USERNAME_DESCRIPTION) + @JsonPropertyDescription(SCMM_USERNAME_DESCRIPTION) + String username = Config.DEFAULT_ADMIN_USER - @Option(names = ['--scmm-password'], description = SCMM_PASSWORD_DESCRIPTION) - @JsonPropertyDescription(SCMM_PASSWORD_DESCRIPTION) - String password = Config.DEFAULT_ADMIN_PW + @Option(names = ['--scmm-password'], description = SCMM_PASSWORD_DESCRIPTION) + @JsonPropertyDescription(SCMM_PASSWORD_DESCRIPTION) + String password = Config.DEFAULT_ADMIN_PW - @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) - @JsonMerge - Config.HelmConfigWithValues helm = new Config.HelmConfigWithValues( - chart: 'scm-manager', - repoURL: 'https://packages.scm-manager.org/repository/helm-v2-releases/', - version: '3.11.4', - values: [:] - ) + @JsonPropertyDescription(HELM_CONFIG_DESCRIPTION) + @JsonMerge + Config.HelmConfigWithValues helm = new Config.HelmConfigWithValues(chart: 'scm-manager', + repoURL: 'https://packages.scm-manager.org/repository/helm-v2-releases/', + version: '3.11.4', + values: [:]) - @Option(names = ['--scmm-root-path'], description = SCMM_ROOT_PATH_DESCRIPTION) - @JsonPropertyDescription(SCMM_ROOT_PATH_DESCRIPTION) - String rootPath = 'repo' + @Option(names = ['--scmm-root-path'], description = SCMM_ROOT_PATH_DESCRIPTION) + @JsonPropertyDescription(SCMM_ROOT_PATH_DESCRIPTION) + String rootPath = 'repo' - /* When installing from via Docker we have to distinguish scmm.url (which is a local IP address) from - the SCMM URL used by jenkins. + /* When installing from via Docker we have to distinguish scmm.url (which is a local IP address) from + the SCMM URL used by jenkins. - This is necessary to make the build on push feature (webhooks from SCMM to Jenkins that trigger builds) work - in k3d. - The webhook contains repository URLs that start with the "Base URL" Setting of SCMM. - Jenkins checks these repo URLs and triggers all builds that match repo URLs. + This is necessary to make the build on push feature (webhooks from SCMM to Jenkins that trigger builds) work + in k3d. + The webhook contains repository URLs that start with the "Base URL" Setting of SCMM. + Jenkins checks these repo URLs and triggers all builds that match repo URLs. - This value is set as "Base URL" in SCMM Settings and in Jenkins Job. + This value is set as "Base URL" in SCMM Settings and in Jenkins Job. - See ApplicationConfigurator.addScmmConfig() and the comment at jenkins.urlForScmm */ + See ApplicationConfigurator.addScmmConfig() and the comment at jenkins.urlForScmm */ - String urlForJenkins = '' + String urlForJenkins = '' - @JsonIgnore - String getHost() { return NetworkingUtils.getHost(url) } + @JsonIgnore + String getHost() { return NetworkingUtils.getHost(url) } - @JsonIgnore - String getProtocol() { return NetworkingUtils.getProtocol(url) } - String ingress = '' + @JsonIgnore + String getProtocol() { return NetworkingUtils.getProtocol(url) } + String ingress = '' - @Option(names = ['--scmm-skip-restart'], description = SCMM_SKIP_RESTART_DESCRIPTION) - @JsonPropertyDescription(SCMM_SKIP_RESTART_DESCRIPTION) - Boolean skipRestart = false + @Option(names = ['--scmm-skip-restart'], description = SCMM_SKIP_RESTART_DESCRIPTION) + @JsonPropertyDescription(SCMM_SKIP_RESTART_DESCRIPTION) + Boolean skipRestart = false - @Option(names = ['--scmm-skip-plugins'], description = SCMM_SKIP_PLUGINS_DESCRIPTION) - @JsonPropertyDescription(SCMM_SKIP_PLUGINS_DESCRIPTION) - Boolean skipPlugins = false + @Option(names = ['--scmm-skip-plugins'], description = SCMM_SKIP_PLUGINS_DESCRIPTION) + @JsonPropertyDescription(SCMM_SKIP_PLUGINS_DESCRIPTION) + Boolean skipPlugins = false - @JsonPropertyDescription(GITOPSUSERNAME_DESCRIPTION) - String gitOpsUsername = '' + @JsonPropertyDescription(GITOPSUSERNAME_DESCRIPTION) + String gitOpsUsername = '' - @JsonIgnore - Credentials getCredentials() { - return new Credentials(username, password) - } - } + @JsonIgnore + Credentials getCredentials() { + return new Credentials(username, password) + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/config/util/GitlabConfig.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/config/util/GitlabConfig.groovy index 304ef0264..70da8ef7d 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/config/util/GitlabConfig.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/config/util/GitlabConfig.groovy @@ -3,9 +3,13 @@ package com.cloudogu.gitops.features.git.config.util import com.cloudogu.gitops.config.Credentials interface GitlabConfig { - String getUrl() - String getParentGroupId() - String getDefaultVisibility() - String getGitOpsUsername() - Credentials getCredentials() + String getUrl() + + String getParentGroupId() + + String getDefaultVisibility() + + String getGitOpsUsername() + + Credentials getCredentials() } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/config/util/ScmManagerConfig.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/config/util/ScmManagerConfig.groovy index 5e92bc6c7..e99d9c7d4 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/config/util/ScmManagerConfig.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/config/util/ScmManagerConfig.groovy @@ -3,17 +3,24 @@ package com.cloudogu.gitops.features.git.config.util import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.Credentials - interface ScmManagerConfig { - Boolean getInternal() - String getUrl() - String getUsername() - String getPassword() - String getNamespace() - String getIngress() - Config.HelmConfigWithValues getHelm() - String getRootPath() - String getGitOpsUsername() - - Credentials getCredentials() + Boolean getInternal() + + String getUrl() + + String getUsername() + + String getPassword() + + String getNamespace() + + String getIngress() + + Config.HelmConfigWithValues getHelm() + + String getRootPath() + + String getGitOpsUsername() + + Credentials getCredentials() } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/features/git/config/util/ScmProviderType.groovy b/src/main/groovy/com/cloudogu/gitops/features/git/config/util/ScmProviderType.groovy index 5685e2e71..062f5fd8e 100644 --- a/src/main/groovy/com/cloudogu/gitops/features/git/config/util/ScmProviderType.groovy +++ b/src/main/groovy/com/cloudogu/gitops/features/git/config/util/ScmProviderType.groovy @@ -1,6 +1,6 @@ package com.cloudogu.gitops.features.git.config.util enum ScmProviderType { - GITLAB, - SCM_MANAGER + GITLAB, + SCM_MANAGER } \ 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..81137b880 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,260 @@ 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) { + 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 + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/GitRepoFactory.groovy b/src/main/groovy/com/cloudogu/gitops/git/GitRepoFactory.groovy index 31e0b3221..9cbc63d04 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/GitRepoFactory.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/GitRepoFactory.groovy @@ -3,20 +3,21 @@ package com.cloudogu.gitops.git import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.utils.FileSystemUtils + import jakarta.inject.Singleton @Singleton class GitRepoFactory { - protected final Config config - protected final FileSystemUtils fileSystemUtils + protected final Config config + protected final FileSystemUtils fileSystemUtils - GitRepoFactory(Config config, FileSystemUtils fileSystemUtils) { - this.fileSystemUtils = fileSystemUtils - this.config = config - } + GitRepoFactory(Config config, FileSystemUtils fileSystemUtils) { + this.fileSystemUtils = fileSystemUtils + this.config = config + } - GitRepo getRepo(String repoTarget, GitProvider gitProvider) { - return new GitRepo(config, gitProvider, repoTarget, fileSystemUtils) - } + GitRepo getRepo(String repoTarget, GitProvider gitProvider) { + return new GitRepo(config, gitProvider, repoTarget, fileSystemUtils) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/jgit/helpers/InsecureCredentialProvider.groovy b/src/main/groovy/com/cloudogu/gitops/git/jgit/helpers/InsecureCredentialProvider.groovy index 6dfc9c866..346b42c44 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/jgit/helpers/InsecureCredentialProvider.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/jgit/helpers/InsecureCredentialProvider.groovy @@ -18,33 +18,32 @@ import org.eclipse.jgit.transport.URIish * @link https://archive.eclipse.org/jgit/site/4.10.0.201712302008-r/apidocs/org/eclipse/jgit/transport/CredentialsProvider.html */ class InsecureCredentialProvider extends CredentialsProvider { - @Override - boolean isInteractive() { - return false - } + @Override + boolean isInteractive() { + return false + } - @Override - boolean supports(CredentialItem... items) { - def message = items.find { it instanceof CredentialItem.InformationalMessage } - if (message == null) { - return false - } + @Override + boolean supports(CredentialItem... items) { + def message = items.find { it instanceof CredentialItem.InformationalMessage } + if (message == null) { + return false + } - return message.promptText =~ /^A secure connection to .* could not be established/ - } + return message.promptText =~ /^A secure connection to .* could not be established/ + } - @Override - boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem { - items.findAll { it instanceof CredentialItem.YesNoType }.each { - if (it.promptText == "Skip SSL verification for this single git operation" || - it.promptText =~ /^Skip SSL verification for git operations for repository/) { - (it as CredentialItem.YesNoType).setValue(true) - } else if (it.promptText == "Always skip SSL verification for this server from now on") { - // otherwise we would persistently overwrite our $HOME/.gitconfig - (it as CredentialItem.YesNoType).setValue(false) - } - } + @Override + boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem { + items.findAll { it instanceof CredentialItem.YesNoType }.each { + if (it.promptText == "Skip SSL verification for this single git operation" || it.promptText =~ /^Skip SSL verification for git operations for repository/) { + (it as CredentialItem.YesNoType).setValue(true) + } else if (it.promptText == "Always skip SSL verification for this server from now on") { + // otherwise we would persistently overwrite our $HOME/.gitconfig + (it as CredentialItem.YesNoType).setValue(false) + } + } - return true - } + return true + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/GitProvider.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/GitProvider.groovy index a8cdb9702..90800971e 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/GitProvider.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/GitProvider.groovy @@ -4,59 +4,59 @@ import com.cloudogu.gitops.config.Credentials interface GitProvider { - default boolean createRepository(String repoTarget, String description) { - return createRepository(repoTarget, description, true); - } + default boolean createRepository(String repoTarget, String description) { + return createRepository(repoTarget, description, true); + } - boolean createRepository(String repoTarget, String description, boolean initialize) + boolean createRepository(String repoTarget, String description, boolean initialize) - void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) + void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) - default String repoUrl(String repoTarget) { - return repoUrl(repoTarget, RepoUrlScope.IN_CLUSTER); - } + default String repoUrl(String repoTarget) { + return repoUrl(repoTarget, RepoUrlScope.IN_CLUSTER); + } - String repoUrl(String repoTarget, RepoUrlScope scope); + String repoUrl(String repoTarget, RepoUrlScope scope); - String repoPrefix() + String repoPrefix() - Credentials getCredentials() + Credentials getCredentials() - URI prometheusMetricsEndpoint() + URI prometheusMetricsEndpoint() - /** - * Deletes the given repository on the provider, if supported. - * Note: This capability is not used by the current destruction flow, - * which talks directly to provider-specific clients (e.g. ScmManagerApiClient).*/ - void deleteRepository(String namespace, String repository, boolean prefixNamespace) + /** + * Deletes the given repository on the provider, if supported. + * Note: This capability is not used by the current destruction flow, + * which talks directly to provider-specific clients (e.g. ScmManagerApiClient).*/ + void deleteRepository(String namespace, String repository, boolean prefixNamespace) - /** - * Deletes a user account on the provider, if supported. - * Note: Not used by the current destruction flow; kept as an optional capability - * on the GitProvider abstraction */ - void deleteUser(String name) + /** + * Deletes a user account on the provider, if supported. + * Note: Not used by the current destruction flow; kept as an optional capability + * on the GitProvider abstraction */ + void deleteUser(String name) - /** - * Sets the default branch of a repository, if supported by the provider; - * kept as an optional capability on the GitProvider abstraction */ - void setDefaultBranch(String repoTarget, String branch) + /** + * Sets the default branch of a repository, if supported by the provider; + * kept as an optional capability on the GitProvider abstraction */ + void setDefaultBranch(String repoTarget, String branch) - String getUrl() + String getUrl() - String getProtocol() + String getProtocol() - String getHost() + String getHost() - String getGitOpsUsername() + String getGitOpsUsername() } enum AccessRole { - READ, WRITE, MAINTAIN, ADMIN, OWNER + READ, WRITE, MAINTAIN, ADMIN, OWNER } enum Scope { - USER, GROUP + USER, GROUP } /** @@ -67,9 +67,8 @@ enum Scope { * regardless of their location. * If the application itself runs inside Kubernetes, the Service DNS is used; * otherwise, NodePort (for internal installations) or externalBase (for external ones) - * is selected automatically. - */ + * is selected automatically.*/ enum RepoUrlScope { - IN_CLUSTER, - CLIENT + IN_CLUSTER, + CLIENT } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/gitlab/Gitlab.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/gitlab/Gitlab.groovy index b73f34354..37c8dbceb 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/gitlab/Gitlab.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/gitlab/Gitlab.groovy @@ -7,7 +7,10 @@ import com.cloudogu.gitops.git.providers.AccessRole import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.git.providers.RepoUrlScope import com.cloudogu.gitops.git.providers.Scope + +import java.util.logging.Level import groovy.util.logging.Slf4j + import org.gitlab4j.api.GitLabApi import org.gitlab4j.api.GitLabApiException import org.gitlab4j.api.models.AccessLevel @@ -15,395 +18,377 @@ import org.gitlab4j.api.models.Group import org.gitlab4j.api.models.Project import org.gitlab4j.api.models.Visibility -import java.util.logging.Level - @Slf4j class Gitlab implements GitProvider { - private final Config config - private final GitLabApi api - private GitlabConfig gitlabConfig - - Gitlab(Config config, GitlabConfig gitlabConfig) { - this.config = config - this.gitlabConfig = gitlabConfig - - String url = Objects.requireNonNull(gitlabConfig.getUrl(), "Missing gitlab url in config.scm.gitlab.url").trim() - String pat = Objects.requireNonNull(gitlabConfig.getCredentials()?.password, "Missing gitlab token").trim() - this.api = new GitLabApi(url, pat) - this.api.enableRequestResponseLogging(Level.ALL) - } - - @Override - boolean createRepository(String repoTarget, String description, boolean initialize) { - def repoNamespace = repoTarget.split('/', 2)[0] - def repoName = repoTarget.split('/', 2)[1] - -// def repoNamespacePrefixed = config.application.namePrefix + repoNamespace - // 1) Resolve parent by numeric ID (do NOT treat the ID as a path!) - Group parent = parentGroup() - String repoNamespacePath = repoNamespace.toLowerCase() - String projectPath = repoName.toLowerCase() - - long subgroupId = ensureSubgroupUnderParentId(parent, repoNamespacePath) - String fullProjectPath = "${parentFullPath()}/${repoNamespacePath}/${projectPath}" - - - if (findProject(fullProjectPath).present) { - log.info("GitLab project already exists: ${fullProjectPath}") - return false - } - - def project = new Project() - .withName(repoName) - .withPath(projectPath) - .withDescription(description ?: "") - .withIssuesEnabled(false) - .withMergeRequestsEnabled(false) - .withWikiEnabled(false) - .withSnippetsEnabled(false) - .withNamespaceId(subgroupId) - .withInitializeWithReadme(initialize) - project.visibility = toVisibility(gitlabConfig.defaultVisibility) - - def created = api.projectApi.createProject(project) - log.info("Created GitLab project ${created.getPathWithNamespace()} (id=${created.id})") - return true - } - - @Override - void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) { - String fullPath = resolveFullPath(repoTarget) - Project project = findProjectOrThrow(fullPath) - AccessLevel level = toAccessLevel(role, scope) - if (scope == Scope.GROUP) { - def group = api.groupApi.getGroups(principal) - .find { it.fullPath == principal || it.path == principal || it.name == principal } - if (!group) throw new IllegalArgumentException("Group '${principal}' not found") - api.projectApi.shareProject(project.id, group.id, level, null) - } else { - def user = api.userApi.findUsers(principal) - .find { it.username == principal || it.email == principal } - if (!user) throw new IllegalArgumentException("User '${principal}' not found") - api.projectApi.addMember(project.id, user.id, level) - } - } - - @Override - String repoUrl(String repoTarget, RepoUrlScope scope) { - String base = gitlabConfig.url.strip() - return "${base}/${parentFullPath()}/${repoTarget}.git" - } - - @Override - String repoPrefix() { - String base = gitlabConfig.url.strip() - def prefix = (config.application.namePrefix ?: "").strip() - return "${base}/${parentFullPath()}/${prefix}" - - } - - - @Override - Credentials getCredentials() { - return this.gitlabConfig.credentials - } - - @Override - String getProtocol() { - return gitlabConfig.url - } - - String getHost() { - return gitlabConfig.url - } - - @Override - String getGitOpsUsername() { - return gitlabConfig.gitOpsUsername - } - - @Override - String getUrl() { - return this.gitlabConfig.url - } - - - /** - * Prometheus integration is only required for SCM-Manager. - * GitLab provides its own built-in Prometheus metrics, so we don't expose an endpoint here. - */ - @Override - URI prometheusMetricsEndpoint() { - return null - } - - /** - * No-op by design. GitLab repository deletion is not managed through this abstraction. - * Kept for interface compatibility only. - */ - @Override - void deleteRepository(String namespace, String repository, boolean prefixNamespace) { - // intentionally left blank - } - - /** - * No-op by design. User deletion is not supported or handled through this provider. - * 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 - } - - private Group parentGroup() { - String raw = gitlabConfig?.parentGroupId?.trim() - if (!raw) throw new IllegalArgumentException("--gitlab-group-id is required") - - boolean isNumeric = raw ==~ /\d+/ - - def groupApi = api.getGroupApi() - if (isNumeric) { - return groupApi.getGroup(Long.parseLong(raw)) - } else { - return groupApi.getGroup(raw.replaceAll('^/+', '')) - } - } - - private String parentFullPath() { - parentGroup().fullPath - } - - /** Ensure a single-level subgroup exists under 'parent'; return its namespace (group) ID. */ - private long ensureSubgroupUnderParentId(Group parent, String segPath) { - // 1) Already there? - Group existing = findDirectSubgroupByPath(parent.id as Long, segPath) - if (existing != null) return existing.id as Long - - - // 2) Guard against project/subgroup name collision in the same parent - Project collision = findDirectProjectByPath(parent.id as Long, segPath) - if (collision != null) { - throw new IllegalStateException( - "Cannot create subgroup '${segPath}' under '${parent.fullPath}': " + - "a project with that path already exists at '${parent.fullPath}/${segPath}'. " + - "Rename/transfer the project first or choose a different subgroup name." - ) - } - - // 3) Create subgroup - Group toCreate = new Group() - .withName(segPath) // display name - .withPath(segPath) // (lowercase etc.) - .withParentId(parent.id) - - - try { - Group created = api.groupApi.addGroup(toCreate) - log.info("Created group {}", created.fullPath) - return created.id as Long - } catch (GitLabApiException e) { - // If someone created it in parallel, treat 400/409 as "exists" and re-fetch - if (e.httpStatus in [400, 409]) { - Group retry = findDirectSubgroupByPath(parent.id as Long, segPath) - if (retry != null) return retry.id as Long - } - def ve = e.hasValidationErrors() ? e.getValidationErrors() : null - log.error("addGroup failed (parent={}, segPath={}, status={}, message={}, validationErrors={})", - parent.fullPath, segPath, e.httpStatus, e.getMessage(), ve) - throw e - } - } - - - /** Find a direct subgroup of 'parentId' with the exact path . */ - private Group findDirectSubgroupByPath(Long parentId, String segPath) { - // uses the overload: getSubGroups(Object idOrPath) - List subGroups = api.groupApi.getSubGroups(parentId) - return subGroups?.find { Group subGroup -> subGroup.path == segPath } - } - - - /** Find a direct project of 'parentId' with the exact path . */ - private Project findDirectProjectByPath(Long parentId, String path) { - // uses the overload: getProjects(Object idOrPath) - List projects = api.groupApi.getProjects(parentId) - return projects?.find { Project project -> project.path == path } - } - - - // ---- Helpers ---- - private Optional findProject(String fullPath) { - try { - return Optional.ofNullable(api.projectApi.getProject(fullPath)) - } catch (Exception ignore) { - return Optional.empty() - } - } - - private Project findProjectOrThrow(String fullPath) { - return findProject(fullPath).orElseThrow { - new IllegalStateException("GitLab project '${fullPath}' not found") - } - } - - private String resolveFullPath(String repoTarget) { - if (!gitlabConfig.parentGroupId) { - throw new IllegalStateException("gitlab.parentGroup is not set") - } - return "${gitlabConfig.parentGroupId}/${repoTarget}" - } - - - private static Visibility toVisibility(String s) { - switch ((s ?: "private").toLowerCase()) { - case "public": return Visibility.PUBLIC - case "internal": return Visibility.INTERNAL - default: return Visibility.PRIVATE - } - } - -// provider-agnostic AccessRole → GitLab AccessLevel - private static AccessLevel toAccessLevel(AccessRole role, Scope scope) { - switch (role) { - case AccessRole.READ: - // GitLab: Guests usually can't read private repo code; Reporter can. - return AccessLevel.REPORTER - case AccessRole.WRITE: - // Typical push/merge permissions - return AccessLevel.DEVELOPER - case AccessRole.MAINTAIN: - return AccessLevel.MAINTAINER - case AccessRole.ADMIN: - // No separate project-level "admin" → cap at Maintainer - return AccessLevel.MAINTAINER - case AccessRole.OWNER: - // OWNER is meaningful for groups/namespaces; for users on a project we cap to MAINTAINER - return (scope == Scope.GROUP) ? AccessLevel.OWNER : AccessLevel.MAINTAINER - default: - throw new IllegalArgumentException("Unknown role: ${role}") - } - } - - - //TODO when git abctraction feature is ready, we will create before merge to main a branch, that - // contain this code as preservation for oop - /* ================================= SETUP CODE ==================================== - void setup() { - log.info("Creating Gitlab Groups") - def mainGroupName = "${config.application.namePrefix}scm".toString() - Group mainSCMGroup = this.gitlabApi.groupApi.getGroup(mainGroupName) - if (!mainSCMGroup) { - def tempGroup = new Group() - .withName(mainGroupName) - .withPath(mainGroupName.toLowerCase()) - .withParentId(null) - - mainSCMGroup = this.gitlabApi.groupApi.addGroup(tempGroup) - } - - String argoCDGroupName = 'argocd' - Optional argoCDGroup = getGroup("${mainGroupName}/${argoCDGroupName}") - if (argoCDGroup.isEmpty()) { - def tempGroup = new Group() - .withName(argoCDGroupName) - .withPath(argoCDGroupName.toLowerCase()) - .withParentId(mainSCMGroup.id) - - argoCDGroup = addGroup(tempGroup) - } - - argoCDGroup.ifPresent(this.&createArgoCDRepos) - - String dependencysGroupName = '3rd-party-dependencies' - Optional dependencysGroup = getGroup("${mainGroupName}/${dependencysGroupName}") - if (dependencysGroup.isEmpty()) { - def tempGroup = new Group() - .withName(dependencysGroupName) - .withPath(dependencysGroupName.toLowerCase()) - .withParentId(mainSCMGroup.id) - - addGroup(tempGroup) - } - - String exercisesGroupName = 'exercises' - Optional exercisesGroup = getGroup("${mainGroupName}/${exercisesGroupName}") - if (exercisesGroup.isEmpty()) { - def tempGroup = new Group() - .withName(exercisesGroupName) - .withPath(exercisesGroupName.toLowerCase()) - .withParentId(mainSCMGroup.id) - - exercisesGroup = addGroup(tempGroup) - } - - exercisesGroup.ifPresent(this.&createExercisesRepos) - } - - void createRepo(String name, String description) { - Optional project = getProject("${parentGroup.getFullPath()}/${name}".toString()) - if (project.isEmpty()) { - Project projectSpec = new Project() - .withName(name) - .withDescription(description) - .withIssuesEnabled(true) - .withMergeRequestsEnabled(true) - .withWikiEnabled(true) - .withSnippetsEnabled(true) - .withPublic(false) - .withNamespaceId(this.gitlabConfig.parentGroup.toLong()) - .withInitializeWithReadme(true) - - project = Optional.ofNullable(this.gitlabApi.projectApi.createProject(projectSpec)) - log.info("Project ${projectSpec} created in Gitlab!") - } - removeBranchProtection(project.get()) - } - - void removeBranchProtection(Project project) { - try { - this.gitlabApi.getProtectedBranchesApi().unprotectBranch(project.getId(), project.getDefaultBranch()) - log.debug("Unprotected default branch: " + project.getDefaultBranch()) - } catch (Exception ex) { - log.error("Failed to unprotect default branch '${project.getDefaultBranch()}' for project '${project.getName()}' (ID: ${project.getId()})", ex) - } - } - - - private Optional getGroup(String groupName) { - try { - return Optional.ofNullable(this.gitlabApi.groupApi.getGroup(groupName)) - } catch (Exception e) { - return Optional.empty() - } - } - - private Optional addGroup(Group group) { - try { - return Optional.ofNullable(this.gitlabApi.groupApi.addGroup(group)) - } catch (Exception e) { - return Optional.empty() - } - } - - private Optional getProject(String projectPath) { - try { - return Optional.ofNullable(this.gitlabApi.projectApi.getProject(projectPath)) - } catch (Exception e) { - return Optional.empty() - - - } - } + private final Config config + private final GitLabApi api + private GitlabConfig gitlabConfig + + Gitlab(Config config, GitlabConfig gitlabConfig) { + this.config = config + this.gitlabConfig = gitlabConfig + + String url = Objects.requireNonNull(gitlabConfig.getUrl(), "Missing gitlab url in config.scm.gitlab.url").trim() + String pat = Objects.requireNonNull(gitlabConfig.getCredentials()?.password, "Missing gitlab token").trim() + this.api = new GitLabApi(url, pat) + this.api.enableRequestResponseLogging(Level.ALL) + } + + @Override + boolean createRepository(String repoTarget, String description, boolean initialize) { + def repoNamespace = repoTarget.split('/', 2)[0] + def repoName = repoTarget.split('/', 2)[1] + + // def repoNamespacePrefixed = config.application.namePrefix + repoNamespace + // 1) Resolve parent by numeric ID (do NOT treat the ID as a path!) + Group parent = parentGroup() + String repoNamespacePath = repoNamespace.toLowerCase() + String projectPath = repoName.toLowerCase() + + long subgroupId = ensureSubgroupUnderParentId(parent, repoNamespacePath) + String fullProjectPath = "${parentFullPath()}/${repoNamespacePath}/${projectPath}" + + if (findProject(fullProjectPath).present) { + log.info("GitLab project already exists: ${fullProjectPath}") + return false + } + + def project = new Project() + .withName(repoName) + .withPath(projectPath) + .withDescription(description ?: "") + .withIssuesEnabled(false) + .withMergeRequestsEnabled(false) + .withWikiEnabled(false) + .withSnippetsEnabled(false) + .withNamespaceId(subgroupId) + .withInitializeWithReadme(initialize) + project.visibility = toVisibility(gitlabConfig.defaultVisibility) + + def created = api.projectApi.createProject(project) + log.info("Created GitLab project ${created.getPathWithNamespace()} (id=${created.id})") + return true + } + + @Override + void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) { + String fullPath = resolveFullPath(repoTarget) + Project project = findProjectOrThrow(fullPath) + AccessLevel level = toAccessLevel(role, scope) + if (scope == Scope.GROUP) { + def group = api.groupApi.getGroups(principal) + .find { it.fullPath == principal || it.path == principal || it.name == principal } + if (!group) throw new IllegalArgumentException("Group '${principal}' not found") + api.projectApi.shareProject(project.id, group.id, level, null) + } else { + def user = api.userApi.findUsers(principal) + .find { it.username == principal || it.email == principal } + if (!user) throw new IllegalArgumentException("User '${principal}' not found") + api.projectApi.addMember(project.id, user.id, level) + } + } + + @Override + String repoUrl(String repoTarget, RepoUrlScope scope) { + String base = gitlabConfig.url.strip() + return "${base}/${parentFullPath()}/${repoTarget}.git" + } + + @Override + String repoPrefix() { + String base = gitlabConfig.url.strip() + def prefix = (config.application.namePrefix ?: "").strip() + return "${base}/${parentFullPath()}/${prefix}" + + } + + @Override + Credentials getCredentials() { + return this.gitlabConfig.credentials + } + + @Override + String getProtocol() { + return gitlabConfig.url + } + + String getHost() { + return gitlabConfig.url + } + + @Override + String getGitOpsUsername() { + return gitlabConfig.gitOpsUsername + } + + @Override + String getUrl() { + return this.gitlabConfig.url + } + + /** + * Prometheus integration is only required for SCM-Manager. + * GitLab provides its own built-in Prometheus metrics, so we don't expose an endpoint here.*/ + @Override + URI prometheusMetricsEndpoint() { + return null + } + + /** + * No-op by design. GitLab repository deletion is not managed through this abstraction. + * Kept for interface compatibility only.*/ + @Override + void deleteRepository(String namespace, String repository, boolean prefixNamespace) { + // intentionally left blank + } + + /** + * No-op by design. User deletion is not supported or handled through this provider. + * 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 + } + + private Group parentGroup() { + String raw = gitlabConfig?.parentGroupId?.trim() + if (!raw) throw new IllegalArgumentException("--gitlab-group-id is required") + + boolean isNumeric = raw ==~ /\d+/ + + def groupApi = api.getGroupApi() + if (isNumeric) { + return groupApi.getGroup(Long.parseLong(raw)) + } else { + return groupApi.getGroup(raw.replaceAll('^/+', '')) + } + } + + private String parentFullPath() { + parentGroup().fullPath + } + + /** Ensure a single-level subgroup exists under 'parent'; return its namespace (group) ID. */ + private long ensureSubgroupUnderParentId(Group parent, String segPath) { + // 1) Already there? + Group existing = findDirectSubgroupByPath(parent.id as Long, segPath) + if (existing != null) return existing.id as Long + + + // 2) Guard against project/subgroup name collision in the same parent + Project collision = findDirectProjectByPath(parent.id as Long, segPath) + if (collision != null) { + throw new IllegalStateException("Cannot create subgroup '${segPath}' under '${parent.fullPath}': " + "a project with that path already exists at '${parent.fullPath}/${segPath}'. " + + "Rename/transfer the project first or choose a different subgroup name.") + } + + // 3) Create subgroup + Group toCreate = new Group() + .withName(segPath) // display name + .withPath(segPath) // (lowercase etc.) + .withParentId(parent.id) + + try { + Group created = api.groupApi.addGroup(toCreate) + log.info("Created group {}", created.fullPath) + return created.id as Long + } catch (GitLabApiException e) { + // If someone created it in parallel, treat 400/409 as "exists" and re-fetch + if (e.httpStatus in [400, 409]) { + Group retry = findDirectSubgroupByPath(parent.id as Long, segPath) + if (retry != null) return retry.id as Long + } + def ve = e.hasValidationErrors() ? e.getValidationErrors() : null + log.error("addGroup failed (parent={}, segPath={}, status={}, message={}, validationErrors={})", + parent.fullPath, segPath, e.httpStatus, e.getMessage(), ve) + throw e + } + } + + /** Find a direct subgroup of 'parentId' with the exact path . */ + private Group findDirectSubgroupByPath(Long parentId, String segPath) { + // uses the overload: getSubGroups(Object idOrPath) + List subGroups = api.groupApi.getSubGroups(parentId) + return subGroups?.find { Group subGroup -> subGroup.path == segPath } + } + + /** Find a direct project of 'parentId' with the exact path . */ + private Project findDirectProjectByPath(Long parentId, String path) { + // uses the overload: getProjects(Object idOrPath) + List projects = api.groupApi.getProjects(parentId) + return projects?.find { Project project -> project.path == path } + } + + // ---- Helpers ---- + private Optional findProject(String fullPath) { + try { + return Optional.ofNullable(api.projectApi.getProject(fullPath)) + } catch (Exception ignore) { + return Optional.empty() + } + } + + private Project findProjectOrThrow(String fullPath) { + return findProject(fullPath).orElseThrow { + new IllegalStateException("GitLab project '${fullPath}' not found") + } + } + + private String resolveFullPath(String repoTarget) { + if (!gitlabConfig.parentGroupId) { + throw new IllegalStateException("gitlab.parentGroup is not set") + } + return "${gitlabConfig.parentGroupId}/${repoTarget}" + } + + private static Visibility toVisibility(String s) { + switch ((s ?: "private").toLowerCase()) { + case "public": return Visibility.PUBLIC + case "internal": return Visibility.INTERNAL + default: return Visibility.PRIVATE + } + } + + // provider-agnostic AccessRole → GitLab AccessLevel + private static AccessLevel toAccessLevel(AccessRole role, Scope scope) { + switch (role) { + case AccessRole.READ: + // GitLab: Guests usually can't read private repo code; Reporter can. + return AccessLevel.REPORTER + case AccessRole.WRITE: + // Typical push/merge permissions + return AccessLevel.DEVELOPER + case AccessRole.MAINTAIN: + return AccessLevel.MAINTAINER + case AccessRole.ADMIN: + // No separate project-level "admin" → cap at Maintainer + return AccessLevel.MAINTAINER + case AccessRole.OWNER: + // OWNER is meaningful for groups/namespaces; for users on a project we cap to MAINTAINER + return (scope == Scope.GROUP) ? AccessLevel.OWNER : AccessLevel.MAINTAINER + default: + throw new IllegalArgumentException("Unknown role: ${role}") + } + } + + //TODO when git abctraction feature is ready, we will create before merge to main a branch, that + // contain this code as preservation for oop + /* ================================= SETUP CODE ==================================== + void setup() { + log.info("Creating Gitlab Groups") + def mainGroupName = "${config.application.namePrefix}scm".toString() + Group mainSCMGroup = this.gitlabApi.groupApi.getGroup(mainGroupName) + if (!mainSCMGroup) { + def tempGroup = new Group() + .withName(mainGroupName) + .withPath(mainGroupName.toLowerCase()) + .withParentId(null) + + mainSCMGroup = this.gitlabApi.groupApi.addGroup(tempGroup) + } + + String argoCDGroupName = 'argocd' + Optional argoCDGroup = getGroup("${mainGroupName}/${argoCDGroupName}") + if (argoCDGroup.isEmpty()) { + def tempGroup = new Group() + .withName(argoCDGroupName) + .withPath(argoCDGroupName.toLowerCase()) + .withParentId(mainSCMGroup.id) + + argoCDGroup = addGroup(tempGroup) + } + + argoCDGroup.ifPresent(this.&createArgoCDRepos) + + String dependencysGroupName = '3rd-party-dependencies' + Optional dependencysGroup = getGroup("${mainGroupName}/${dependencysGroupName}") + if (dependencysGroup.isEmpty()) { + def tempGroup = new Group() + .withName(dependencysGroupName) + .withPath(dependencysGroupName.toLowerCase()) + .withParentId(mainSCMGroup.id) + + addGroup(tempGroup) + } + + String exercisesGroupName = 'exercises' + Optional exercisesGroup = getGroup("${mainGroupName}/${exercisesGroupName}") + if (exercisesGroup.isEmpty()) { + def tempGroup = new Group() + .withName(exercisesGroupName) + .withPath(exercisesGroupName.toLowerCase()) + .withParentId(mainSCMGroup.id) + + exercisesGroup = addGroup(tempGroup) + } + + exercisesGroup.ifPresent(this.&createExercisesRepos) + } + + void createRepo(String name, String description) { + Optional project = getProject("${parentGroup.getFullPath()}/${name}".toString()) + if (project.isEmpty()) { + Project projectSpec = new Project() + .withName(name) + .withDescription(description) + .withIssuesEnabled(true) + .withMergeRequestsEnabled(true) + .withWikiEnabled(true) + .withSnippetsEnabled(true) + .withPublic(false) + .withNamespaceId(this.gitlabConfig.parentGroup.toLong()) + .withInitializeWithReadme(true) + + project = Optional.ofNullable(this.gitlabApi.projectApi.createProject(projectSpec)) + log.info("Project ${projectSpec} created in Gitlab!") + } + removeBranchProtection(project.get()) + } + + void removeBranchProtection(Project project) { + try { + this.gitlabApi.getProtectedBranchesApi().unprotectBranch(project.getId(), project.getDefaultBranch()) + log.debug("Unprotected default branch: " + project.getDefaultBranch()) + } catch (Exception ex) { + log.error("Failed to unprotect default branch '${project.getDefaultBranch()}' for project '${project.getName()}' (ID: ${project.getId()})", ex) + } + } + + + private Optional getGroup(String groupName) { + try { + return Optional.ofNullable(this.gitlabApi.groupApi.getGroup(groupName)) + } catch (Exception e) { + return Optional.empty() + } + } + + private Optional addGroup(Group group) { + try { + return Optional.ofNullable(this.gitlabApi.groupApi.addGroup(group)) + } catch (Exception e) { + return Optional.empty() + } + } + + private Optional getProject(String projectPath) { + try { + return Optional.ofNullable(this.gitlabApi.projectApi.getProject(projectPath)) + } catch (Exception e) { + return Optional.empty() + + + } + } */ } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/Permission.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/Permission.groovy index ffe623bcd..78178ed70 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/Permission.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/Permission.groovy @@ -1,24 +1,24 @@ package com.cloudogu.gitops.git.providers.scmmanager class Permission { - final String name - final Role role - final List verbs - final boolean groupPermission + final String name + final Role role + final List verbs + final boolean groupPermission - Permission(String name, Role role, boolean groupPermission = false, List verbs = []) { - this.name = name - this.role = role - this.verbs = verbs - this.groupPermission = groupPermission - } + Permission(String name, Role role, boolean groupPermission = false, List verbs = []) { + this.name = name + this.role = role + this.verbs = verbs + this.groupPermission = groupPermission + } - @Override - String toString() { - "Permission{name='$name', role=$role, verbs=$verbs, groupPermission=$groupPermission}" - } + @Override + String toString() { + "Permission{name='$name', role=$role, verbs=$verbs, groupPermission=$groupPermission}" + } - enum Role { - READ, WRITE,OWNER - } + enum Role { + READ, WRITE, OWNER + } } \ 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..3ef2b6a58 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,180 @@ 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 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..9511a3b9b 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 @@ -5,184 +5,170 @@ 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) + 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}.") + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerUrlResolver.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerUrlResolver.groovy index 5b9cab08a..f0d470873 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerUrlResolver.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerUrlResolver.groovy @@ -4,122 +4,121 @@ import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.git.config.util.ScmManagerConfig import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.NetworkingUtils + import groovy.util.logging.Slf4j @Slf4j class ScmManagerUrlResolver { - private final Config config - private final ScmManagerConfig scmm - private final K8sClient k8s - private final NetworkingUtils net - - private URI cachedClusterBind + private final Config config + private final ScmManagerConfig scmm + private final K8sClient k8s + private final NetworkingUtils net - private final String releaseName = 'scmm' + private URI cachedClusterBind - ScmManagerUrlResolver(Config config, ScmManagerConfig scmm, K8sClient k8s, NetworkingUtils net) { - this.config = config - this.scmm = scmm - this.k8s = k8s - this.net = net - } + private final String releaseName = 'scmm' - // ---------- Public API used by ScmManager ---------- + ScmManagerUrlResolver(Config config, ScmManagerConfig scmm, K8sClient k8s, NetworkingUtils net) { + this.config = config + this.scmm = scmm + this.k8s = k8s + this.net = net + } - /** Client base …/scm (no trailing slash) */ - URI clientBase() { noTrailSlash(ensureScm(clientBaseRaw())) } + // ---------- Public API used by ScmManager ---------- - /** Client API base …/scm/api/ */ - URI clientApiBase() { withSlash(clientBase()).resolve("api/") } + /** Client base …/scm (no trailing slash) */ + URI clientBase() { noTrailSlash(ensureScm(clientBaseRaw())) } - /** Client repo base …/scm/repo (no trailing slash) */ - URI clientRepoBase() { noTrailSlash(withSlash(clientBase()).resolve("${root()}/")) } + /** Client API base …/scm/api/ */ + URI clientApiBase() { withSlash(clientBase()).resolve("api/") } + /** Client repo base …/scm/repo (no trailing slash) */ + URI clientRepoBase() { noTrailSlash(withSlash(clientBase()).resolve("${root()}/")) } - /** In-cluster base …/scm (no trailing slash) */ - URI inClusterBase() { noTrailSlash(ensureScm(inClusterBaseRaw())) } + /** In-cluster base …/scm (no trailing slash) */ + URI inClusterBase() { noTrailSlash(ensureScm(inClusterBaseRaw())) } - /** In-cluster repo prefix …/scm/repo/[] */ - String inClusterRepoPrefix() { - def prefix = (config.application.namePrefix ?: "").strip() - def base = withSlash(inClusterBase()) - def url = withSlash(base.resolve(root())) + /** In-cluster repo prefix …/scm/repo/[] */ + String inClusterRepoPrefix() { + def prefix = (config.application.namePrefix ?: "").strip() + def base = withSlash(inClusterBase()) + def url = withSlash(base.resolve(root())) - return URI.create(url.toString() + prefix).toString() - } + return URI.create(url.toString() + prefix).toString() + } - /** In-cluster repo URL …/scm/repo// */ - String inClusterRepoUrl(String repoTarget) { - def repo = repoTarget.strip() - noTrailSlash(withSlash(inClusterBase()).resolve("${root()}/${repo}/")).toString() - } + /** In-cluster repo URL …/scm/repo// */ + String inClusterRepoUrl(String repoTarget) { + def repo = repoTarget.strip() + noTrailSlash(withSlash(inClusterBase()).resolve("${root()}/${repo}/")).toString() + } - /** Client repo URL …/scm/repo// (no trailing slash) */ - String clientRepoUrl(String repoTarget) { - def repo = repoTarget.strip() - noTrailSlash(withSlash(clientRepoBase()).resolve("${repo}/")).toString() - } + /** Client repo URL …/scm/repo// (no trailing slash) */ + String clientRepoUrl(String repoTarget) { + def repo = repoTarget.strip() + noTrailSlash(withSlash(clientRepoBase()).resolve("${repo}/")).toString() + } - /** …/scm/api/v2/metrics/prometheus */ - URI prometheusEndpoint() { withSlash(clientBase()).resolve("api/v2/metrics/prometheus") } + /** …/scm/api/v2/metrics/prometheus */ + URI prometheusEndpoint() { withSlash(clientBase()).resolve("api/v2/metrics/prometheus") } - // ---------- Base resolution ---------- + // ---------- Base resolution ---------- - private URI clientBaseRaw() { - if (Boolean.TRUE == scmm.internal) - return config.application.runningInsideK8s ? serviceDnsBase() : nodePortBase() - return externalBase() - } + private URI clientBaseRaw() { + if (Boolean.TRUE == scmm.internal) return config.application.runningInsideK8s ? serviceDnsBase() : nodePortBase() + return externalBase() + } - private URI inClusterBaseRaw() { - return scmm.internal ? serviceDnsBase() : externalBase() - } + private URI inClusterBaseRaw() { + return scmm.internal ? serviceDnsBase() : externalBase() + } - private URI serviceDnsBase() { - def namespace = (scmm.namespace ?: "scm-manager").strip() - URI.create("http://scmm.${namespace}.svc.cluster.local") - } + private URI serviceDnsBase() { + def namespace = (scmm.namespace ?: "scm-manager").strip() + URI.create("http://scmm.${namespace}.svc.cluster.local") + } - private URI externalBase() { - def url = (scmm.url ?: "").strip() - if (url) return URI.create(url) + private URI externalBase() { + def url = (scmm.url ?: "").strip() + if (url) return URI.create(url) - def ingress = (scmm.ingress ?: "").strip() - if (ingress) return URI.create("http://${ingress}") - throw new IllegalArgumentException("Either scmm.url or scmm.ingress must be set when internal=false") - } + def ingress = (scmm.ingress ?: "").strip() + if (ingress) return URI.create("http://${ingress}") + throw new IllegalArgumentException("Either scmm.url or scmm.ingress must be set when internal=false") + } - private URI nodePortBase() { - if (cachedClusterBind) return cachedClusterBind + private URI nodePortBase() { + if (cachedClusterBind) return cachedClusterBind - def namespace = (scmm.namespace ?: "scm-manager").strip() + def namespace = (scmm.namespace ?: "scm-manager").strip() - final def port = k8s.waitForNodePort(releaseName, namespace) - final def host = net.findClusterBindAddress() - cachedClusterBind = new URI("http://${host}:${port}") - return cachedClusterBind - } + final def port = k8s.waitForNodePort(releaseName, namespace) + final def host = net.findClusterBindAddress() + cachedClusterBind = new URI("http://${host}:${port}") + return cachedClusterBind + } - // ---------- Helpers ---------- + // ---------- Helpers ---------- - private String root() { - (scmm.rootPath ?: "repo").strip() - } + private String root() { + (scmm.rootPath ?: "repo").strip() + } - private static URI ensureScm(URI u) { - def us = withSlash(u) - def path = us.path ?: "" - path.endsWith("/scm/") ? us : us.resolve("scm/") - } + private static URI ensureScm(URI u) { + def us = withSlash(u) + def path = us.path ?: "" + path.endsWith("/scm/") ? us : us.resolve("scm/") + } - private static URI withSlash(URI u) { - def s = u.toString() - s.endsWith('/') ? u : URI.create(s + '/') - } + private static URI withSlash(URI u) { + def s = u.toString() + s.endsWith('/') ? u : URI.create(s + '/') + } - private static URI noTrailSlash(URI u) { - def s = u.toString() - s.endsWith('/') ? URI.create(s.substring(0, s.length() - 1)) : u - } + private static URI noTrailSlash(URI u) { + def s = u.toString() + s.endsWith('/') ? URI.create(s.substring(0, s.length() - 1)) : u + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/AuthorizationInterceptor.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/AuthorizationInterceptor.groovy index 28d799bf3..f4a50a924 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/AuthorizationInterceptor.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/AuthorizationInterceptor.groovy @@ -1,26 +1,25 @@ package com.cloudogu.gitops.git.providers.scmmanager.api - import okhttp3.Credentials import okhttp3.Interceptor import okhttp3.Response import org.jetbrains.annotations.NotNull class AuthorizationInterceptor implements Interceptor { - private String username - private String password + private String username + private String password - AuthorizationInterceptor(String username, String password) { - this.username = username - this.password = password - } + AuthorizationInterceptor(String username, String password) { + this.username = username + this.password = password + } - @Override - Response intercept(@NotNull Chain chain) throws IOException { - def newRequest = chain.request().newBuilder() - .header("Authorization", Credentials.basic(username, password)) - .build() + @Override + Response intercept(@NotNull Chain chain) throws IOException { + def newRequest = chain.request().newBuilder() + .header("Authorization", Credentials.basic(username, password)) + .build() - return chain.proceed(newRequest) - } + return chain.proceed(newRequest) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/PluginApi.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/PluginApi.groovy index 5e17d1a12..034cfff75 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/PluginApi.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/PluginApi.groovy @@ -1,18 +1,13 @@ package com.cloudogu.gitops.git.providers.scmmanager.api import retrofit2.Call -import retrofit2.http.Body -import retrofit2.http.Headers -import retrofit2.http.POST -import retrofit2.http.PUT -import retrofit2.http.Path -import retrofit2.http.Query +import retrofit2.http.* interface PluginApi { - @POST("v2/plugins/available/{name}/install") - Call install(@Path("name") String name, @Query("restart") Boolean restart) + @POST("v2/plugins/available/{name}/install") + Call install(@Path("name") String name, @Query("restart") Boolean restart) - @PUT("v2/config/jenkins/") - @Headers("Content-Type: application/json") - Call configureJenkinsPlugin(@Body Map config) + @PUT("v2/config/jenkins/") + @Headers("Content-Type: application/json") + Call configureJenkinsPlugin(@Body Map config) } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/Repository.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/Repository.groovy index 9dea6dd14..60774b6bf 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/Repository.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/Repository.groovy @@ -1,26 +1,26 @@ package com.cloudogu.gitops.git.providers.scmmanager.api class Repository { - final String name - final String namespace - final String type - final String contact - final String description + final String name + final String namespace + final String type + final String contact + final String description - Repository(String namespace, String name, String description = null, String contact = null, String type = 'git') { - this.namespace = namespace - this.name = name - this.type = type - this.contact = contact - this.description = description - } - - String getFullRepoName() { - return "${namespace}/${name}" - } - - @Override - String toString() { - "Repository{name='$name', namespace='$namespace', type='$type', contact='$contact', description='$description'}" - } + Repository(String namespace, String name, String description = null, String contact = null, String type = 'git') { + this.namespace = namespace + this.name = name + this.type = type + this.contact = contact + this.description = description + } + + String getFullRepoName() { + return "${namespace}/${name}" + } + + @Override + String toString() { + "Repository{name='$name', namespace='$namespace', type='$type', contact='$contact', description='$description'}" + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/RepositoryApi.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/RepositoryApi.groovy index 4893da921..896e88211 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/RepositoryApi.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/RepositoryApi.groovy @@ -1,19 +1,19 @@ package com.cloudogu.gitops.git.providers.scmmanager.api import com.cloudogu.gitops.git.providers.scmmanager.Permission -import okhttp3.ResponseBody + import retrofit2.Call import retrofit2.http.* interface RepositoryApi { - @DELETE("v2/repositories/{namespace}/{name}") - Call delete(@Path("namespace") String namespace, @Path("name") String name) + @DELETE("v2/repositories/{namespace}/{name}") + Call delete(@Path("namespace") String namespace, @Path("name") String name) - @POST("v2/repositories/") - @Headers("Content-Type: application/vnd.scmm-repository+json;v=2") - Call create(@Body Repository repository, @Query("initialize") boolean initialize) + @POST("v2/repositories/") + @Headers("Content-Type: application/vnd.scmm-repository+json;v=2") + Call create(@Body Repository repository, @Query("initialize") boolean initialize) - @POST("v2/repositories/{namespace}/{name}/permissions/") - @Headers("Content-Type: application/vnd.scmm-repositoryPermission+json") - Call createPermission(@Path("namespace") String namespace, @Path("name") String name, @Body Permission permission) + @POST("v2/repositories/{namespace}/{name}/permissions/") + @Headers("Content-Type: application/vnd.scmm-repositoryPermission+json") + Call createPermission(@Path("namespace") String namespace, @Path("name") String name, @Body Permission permission) } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerApi.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerApi.groovy index d06e832be..3ef1f0bf1 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerApi.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerApi.groovy @@ -8,10 +8,10 @@ import retrofit2.http.PUT interface ScmManagerApi { - @GET("v2") - Call checkScmmAvailable() + @GET("v2") + Call checkScmmAvailable() - @PUT("v2/config") - @Headers("Content-Type: application/vnd.scmm-config+json;v=2") - Call setConfig(@Body Map config) + @PUT("v2/config") + @Headers("Content-Type: application/vnd.scmm-config+json;v=2") + Call setConfig(@Body Map config) } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerApiClient.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerApiClient.groovy index a9a5f0ce7..3daa7833b 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerApiClient.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerApiClient.groovy @@ -1,9 +1,10 @@ package com.cloudogu.gitops.git.providers.scmmanager.api - import com.cloudogu.gitops.config.Credentials import com.cloudogu.gitops.dependencyinjection.HttpClientFactory + import groovy.util.logging.Slf4j + import okhttp3.OkHttpClient import retrofit2.Call import retrofit2.Response @@ -11,65 +12,62 @@ import retrofit2.Retrofit import retrofit2.converter.jackson.JacksonConverterFactory /** - * Parent class for all SCMM Apis that lazily creates the APIs - */ + * Parent class for all SCMM Apis that lazily creates the APIs*/ @Slf4j class ScmManagerApiClient { - Credentials credentials - OkHttpClient okHttpClient - String url + Credentials credentials + OkHttpClient okHttpClient + String url - ScmManagerApiClient(String url, Credentials credentials, Boolean isInsecure) { - this.url = url - this.credentials = credentials - this.okHttpClient = HttpClientFactory.buildOkHttpClient(credentials, isInsecure) - } + ScmManagerApiClient(String url, Credentials credentials, Boolean isInsecure) { + this.url = url + this.credentials = credentials + this.okHttpClient = HttpClientFactory.buildOkHttpClient(credentials, isInsecure) + } - UsersApi usersApi() { - return retrofit().create(UsersApi) - } + UsersApi usersApi() { + return retrofit().create(UsersApi) + } - RepositoryApi repositoryApi() { - return retrofit().create(RepositoryApi) - } + RepositoryApi repositoryApi() { + return retrofit().create(RepositoryApi) + } - ScmManagerApi generalApi() { - return retrofit().create(ScmManagerApi) - } + ScmManagerApi generalApi() { + return retrofit().create(ScmManagerApi) + } - PluginApi pluginApi() { - return retrofit().create(PluginApi) - } + PluginApi pluginApi() { + return retrofit().create(PluginApi) + } - static handleApiResponse(Call apiCall, String additionalMessage = "") { - try { - Response response = apiCall.execute() + static handleApiResponse(Call apiCall, String additionalMessage = "") { + try { + Response response = apiCall.execute() - if (!response.isSuccessful() && - response.code() != 409 && - response.code() != 201) { - def errorMessage = "API call failed!'. HTTP Status: ${response.code()} - ${response.message()}" - if (additionalMessage) { - errorMessage += " Additional Info: ${additionalMessage}" - } - log.error(errorMessage) - throw new RuntimeException(errorMessage) - } else { - log.debug("Successfully completed ${apiCall}") - } - } catch (Exception e) { - def errorMessage = "Error executing API: ${e.message}" - log.error(errorMessage, e) - throw new RuntimeException(errorMessage, e) - } - } + if (!response.isSuccessful() && response.code() != 409 && response.code() != 201) { + def errorMessage = "API call failed!'. HTTP Status: ${response.code()} - ${response.message()}" + if (additionalMessage) { + errorMessage += " Additional Info: ${additionalMessage}" + } + log.error(errorMessage) + throw new RuntimeException(errorMessage) + } else { + log.debug("Successfully completed ${apiCall}") + } + } catch (Exception e) { + def errorMessage = "Error executing API: ${e.message}" + log.error(errorMessage, e) + throw new RuntimeException(errorMessage, e) + } + } - protected Retrofit retrofit() { - return new Retrofit.Builder() - .baseUrl(this.url) - .client(okHttpClient) - // Converts HTTP body objects from groovy to JSON - .addConverterFactory(JacksonConverterFactory.create()) - .build() - } + protected Retrofit retrofit() { + return new Retrofit.Builder() + .baseUrl(this.url) + .client(okHttpClient) + // Converts HTTP body objects from groovy to JSON + .addConverterFactory(JacksonConverterFactory.create()) + .build() + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerUser.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerUser.groovy index c33af243a..38af2d492 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerUser.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/ScmManagerUser.groovy @@ -1,11 +1,11 @@ package com.cloudogu.gitops.git.providers.scmmanager.api class ScmManagerUser { - String name - String displayName - String mail - boolean external = false - String password - boolean active = true - Map _links = [:] + String name + String displayName + String mail + boolean external = false + String password + boolean active = true + Map _links = [:] } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApi.groovy b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApi.groovy index c59a27f44..a1d8bcaed 100644 --- a/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApi.groovy +++ b/src/main/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApi.groovy @@ -1,26 +1,18 @@ package com.cloudogu.gitops.git.providers.scmmanager.api -import okhttp3.ResponseBody import retrofit2.Call -import retrofit2.http.Body -import retrofit2.http.DELETE -import retrofit2.http.Headers -import retrofit2.http.POST -import retrofit2.http.PUT -import retrofit2.http.Path +import retrofit2.http.* interface UsersApi { - @DELETE("v2/users/{id}") - Call delete(@Path("id") String id) + @DELETE("v2/users/{id}") + Call delete(@Path("id") String id) - @Headers(["Content-Type: application/vnd.scmm-user+json;v=2"]) - @POST("v2/users") - Call addUser(@Body ScmManagerUser user) + @Headers(["Content-Type: application/vnd.scmm-user+json;v=2"]) + @POST("v2/users") + Call addUser(@Body ScmManagerUser user) - @Headers(["Content-Type: application/vnd.scmm-permissionCollection+json;v=2"]) - @PUT("v2/users/{username}/permissions") - Call setPermissionForUser( - @Path("username") String username, - @Body Map> permissions - ) + @Headers(["Content-Type: application/vnd.scmm-permissionCollection+json;v=2"]) + @PUT("v2/users/{username}/permissions") + Call setPermissionForUser(@Path("username") String username, + @Body Map> permissions) } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/jenkins/GlobalPropertyManager.groovy b/src/main/groovy/com/cloudogu/gitops/jenkins/GlobalPropertyManager.groovy index f58d5f338..030f474ca 100644 --- a/src/main/groovy/com/cloudogu/gitops/jenkins/GlobalPropertyManager.groovy +++ b/src/main/groovy/com/cloudogu/gitops/jenkins/GlobalPropertyManager.groovy @@ -1,19 +1,20 @@ package com.cloudogu.gitops.jenkins import jakarta.inject.Singleton + import org.intellij.lang.annotations.Language @Singleton class GlobalPropertyManager { - private JenkinsApiClient apiClient + private JenkinsApiClient apiClient - GlobalPropertyManager(JenkinsApiClient apiClient) { - this.apiClient = apiClient - } + GlobalPropertyManager(JenkinsApiClient apiClient) { + this.apiClient = apiClient + } - void setGlobalProperty(String key, String value) { - @Language("groovy") - def script = """ + void setGlobalProperty(String key, String value) { + @Language("groovy") + def script = """ instance = Jenkins.getInstance() globalNodeProperties = instance.getGlobalNodeProperties() envVarsNodePropertyList = globalNodeProperties.getAll(hudson.slaves.EnvironmentVariablesNodeProperty.class) @@ -36,15 +37,15 @@ class GlobalPropertyManager { print("Done") """ - def result = apiClient.runScript(script) - if (result != 'Done') { - throw new RuntimeException("Could not create global property: $result") - } - } + def result = apiClient.runScript(script) + if (result != 'Done') { + throw new RuntimeException("Could not create global property: $result") + } + } - void deleteGlobalProperty(String key) { - @Language("groovy") - def script = """ + void deleteGlobalProperty(String key) { + @Language("groovy") + def script = """ def instance = Jenkins.getInstance() def globalNodeProperties = instance.getGlobalNodeProperties() def envVarsNodePropertyList = globalNodeProperties.getAll(hudson.slaves.EnvironmentVariablesNodeProperty.class) @@ -59,9 +60,9 @@ class GlobalPropertyManager { print("Done") """ - def result = apiClient.runScript(script) - if (result != 'Nothing to do' && result != 'Done') { - throw new RuntimeException("Could not delete global property: $result") - } - } -} + def result = apiClient.runScript(script) + if (result != 'Nothing to do' && result != 'Done') { + throw new RuntimeException("Could not delete global property: $result") + } + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClient.groovy b/src/main/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClient.groovy index bc5ae8d94..cc41e4344 100644 --- a/src/main/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClient.groovy +++ b/src/main/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClient.groovy @@ -1,116 +1,116 @@ package com.cloudogu.gitops.jenkins import com.cloudogu.gitops.config.Config -import groovy.json.JsonSlurper -import groovy.util.logging.Slf4j + import jakarta.inject.Named import jakarta.inject.Singleton +import groovy.json.JsonSlurper +import groovy.util.logging.Slf4j + import okhttp3.* @Slf4j @Singleton class JenkinsApiClient { - private Config config - - private OkHttpClient client - - // Number of retries in uncommonly high, because we might have to outlive a unexpected Jenkins restart - private int maxRetries = 180 - private int waitPeriodInMs = 2000 - - JenkinsApiClient( - Config config, - @Named("jenkins") OkHttpClient client - ) { - - if (config.application.insecure) { - this.client = client.newBuilder() - .hostnameVerifier({ hostname, session -> true }) - .build() - } else { - this.client = client - } - this.config = config - } - - String runScript(String code) { - log.trace("Running groovy script in Jenkins: {}", code) - def response = postRequestWithCrumb("scriptText", new FormBody.Builder().add("script", code).build()) - if (response.code() != 200) { - throw new RuntimeException("Could not run script. Status code ${response.code()}") - } - - return response.body().string() - } - - Response postRequestWithCrumb(String url, RequestBody postData = null) { - return sendRequestWithRetries { - Request.Builder request = buildRequest(url) - .header("Jenkins-Crumb", getCrumb()) - - if (postData != null) { - request.method("POST", postData) - } else { - // Explicitly set empty body. Otherwise okhttp sends GET - RequestBody emptyBody = RequestBody.create("", null) - request.method("POST", emptyBody) - } - - request.build() - } - } - - private String getCrumb() { - log.trace("Getting Crumb for Jenkins") - def response = sendRequestWithRetries { buildRequest("crumbIssuer/api/json").build() } - - if (response.code() != 200) { - throw new RuntimeException("Could not create crumb. Status code ${response.code()}") - } - - def json = new JsonSlurper().parse(response.body().byteStream()) - - if (!json instanceof Map || !(json as Map).containsKey('crumb')) { - throw new RuntimeException("Could not create crumb. Invalid json.") - } - - return json['crumb'] - } - - private Request.Builder buildRequest(String url) { - return new Request.Builder() - .url("${config.jenkins.url}/$url") - .header("Authorization", Credentials.basic(config.jenkins.username, config.jenkins.password)) - } - - // We pass a closure, so that we actually refetch a new crumb for a failed request - // The Jenkins ApiClient has it's own retry logic on top of RetryInterceptor, because of crumb lifetime and restarts - private Response sendRequestWithRetries(Closure request) { - def retry = 0 - Response response = null - do { - response = client.newCall(request()).execute() - if (!shouldRetryRequest(response)) { - break - } - Thread.sleep(waitPeriodInMs) - } while (++retry < maxRetries) - - return response - } - - private boolean shouldRetryRequest(Response response) { - // We might run into a 403 due to an invalid crumb from a previous session before jenkins was restarted. - // Here in the ApiClient, we simply retry all 401 and 403 including fetching a new crumb - return response.code() in [401, 403] - } - - protected void setMaxRetries(int retries) { - this.maxRetries = retries - } - - protected setWaitPeriodInMs(int waitPeriodInMs) { - this.waitPeriodInMs = waitPeriodInMs - } - -} + private Config config + + private OkHttpClient client + + // Number of retries in uncommonly high, because we might have to outlive a unexpected Jenkins restart + private int maxRetries = 180 + private int waitPeriodInMs = 2000 + + JenkinsApiClient(Config config, + @Named("jenkins") OkHttpClient client) { + + if (config.application.insecure) { + this.client = client.newBuilder() + .hostnameVerifier({ hostname, session -> true }) + .build() + } else { + this.client = client + } + this.config = config + } + + String runScript(String code) { + log.trace("Running groovy script in Jenkins: {}", code) + def response = postRequestWithCrumb("scriptText", new FormBody.Builder().add("script", code).build()) + if (response.code() != 200) { + throw new RuntimeException("Could not run script. Status code ${response.code()}") + } + + return response.body().string() + } + + Response postRequestWithCrumb(String url, RequestBody postData = null) { + return sendRequestWithRetries { + Request.Builder request = buildRequest(url) + .header("Jenkins-Crumb", getCrumb()) + + if (postData != null) { + request.method("POST", postData) + } else { + // Explicitly set empty body. Otherwise okhttp sends GET + RequestBody emptyBody = RequestBody.create("", null) + request.method("POST", emptyBody) + } + + request.build() + } + } + + private String getCrumb() { + log.trace("Getting Crumb for Jenkins") + def response = sendRequestWithRetries { buildRequest("crumbIssuer/api/json").build() } + + if (response.code() != 200) { + throw new RuntimeException("Could not create crumb. Status code ${response.code()}") + } + + def json = new JsonSlurper().parse(response.body().byteStream()) + + if (!json instanceof Map || !(json as Map).containsKey('crumb')) { + throw new RuntimeException("Could not create crumb. Invalid json.") + } + + return json['crumb'] + } + + private Request.Builder buildRequest(String url) { + return new Request.Builder() + .url("${config.jenkins.url}/$url") + .header("Authorization", Credentials.basic(config.jenkins.username, config.jenkins.password)) + } + + // We pass a closure, so that we actually refetch a new crumb for a failed request + // The Jenkins ApiClient has it's own retry logic on top of RetryInterceptor, because of crumb lifetime and restarts + private Response sendRequestWithRetries(Closure request) { + def retry = 0 + Response response = null + do { + response = client.newCall(request()).execute() + if (!shouldRetryRequest(response)) { + break + } + Thread.sleep(waitPeriodInMs) + } while (++retry < maxRetries) + + return response + } + + private boolean shouldRetryRequest(Response response) { + // We might run into a 403 due to an invalid crumb from a previous session before jenkins was restarted. + // Here in the ApiClient, we simply retry all 401 and 403 including fetching a new crumb + return response.code() in [401, 403] + } + + protected void setMaxRetries(int retries) { + this.maxRetries = retries + } + + protected setWaitPeriodInMs(int waitPeriodInMs) { + this.waitPeriodInMs = waitPeriodInMs + } + +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/jenkins/JobManager.groovy b/src/main/groovy/com/cloudogu/gitops/jenkins/JobManager.groovy index c262211d8..eeab52c77 100644 --- a/src/main/groovy/com/cloudogu/gitops/jenkins/JobManager.groovy +++ b/src/main/groovy/com/cloudogu/gitops/jenkins/JobManager.groovy @@ -1,9 +1,11 @@ package com.cloudogu.gitops.jenkins import com.cloudogu.gitops.utils.TemplatingEngine + +import jakarta.inject.Singleton import groovy.json.JsonOutput import groovy.util.logging.Slf4j -import jakarta.inject.Singleton + import okhttp3.FormBody import okhttp3.MediaType import okhttp3.RequestBody @@ -12,88 +14,79 @@ import org.intellij.lang.annotations.Language @Singleton @Slf4j class JobManager { - private JenkinsApiClient apiClient - - JobManager(JenkinsApiClient apiClient) { - this.apiClient = apiClient - } - - void createCredential(String jobName, String id, String username, String password, String description) { - def response = apiClient.postRequestWithCrumb( - "job/$jobName/credentials/store/folder/domain/_/createCredentials", - new FormBody.Builder() - .add("json", JsonOutput.toJson([ - credentials: [ - scope : "GLOBAL", - id : id, - username : username, - password : password, - description: description, - $class : "com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl", - ] - ])) - .build() - ) - - if (response.code() != 200) { - throw new RuntimeException("Could not create credential id=$id,job=$jobName. StatusCode: ${response.code()}") - } - } - - /** - * @return true, if created; false if job already exists and nothing was changed. - */ - boolean createJob(String name, String serverUrl, String jobNamespace, String credentialsId) { - if (jobExists(name)) { - log.warn("Job '${name}' already exists, ignoring.") - return false - } else { - // Note for development: the XML representation of an existing job can be exporting by adding /config.xml to the URL - String payloadXml = new TemplatingEngine().template(new File('argocd/cluster-resources/apps/jenkins/templates/namespaceJobTemplate.xml.ftl'), - [ - SCMM_NAMESPACE_JOB_SERVER_URL : serverUrl, - SCMM_NAMESPACE_JOB_NAMESPACE : jobNamespace, - SCMM_NAMESPACE_JOB_CREDENTIALS_ID: credentialsId - ]) - - RequestBody body = RequestBody.create(payloadXml, MediaType.get("text/xml")) - - def response = apiClient.postRequestWithCrumb("createItem?name=$name", body) - - if (response.code() != 200) { - throw new RuntimeException("Could not create job '${name}'. StatusCode: ${response.code()}") - } - } - return true - } - - boolean jobExists(String name) { - def response= apiClient.postRequestWithCrumb("job/$name") - - return response.code() == 200 - } - - void deleteJob(String name) { - if (name.contains("'")) { - throw new RuntimeException('Job name cannot contain quotes.') - } - - @Language("groovy") - String script = "print(Jenkins.instance.getItem('$name')?.delete())" - def result = apiClient.runScript(script) - - if (result != 'null') { - throw new RuntimeException("Could not delete job $name") - } - } - - void startJob(String jobName) { - - def response= apiClient.postRequestWithCrumb( - "job/$jobName/build?delay=0sec") - - if (response.code() != 200) { - throw new RuntimeException("Could not trigger build of Jenkins job: $jobName. StatusCode: ${response.code()}") - } - } -} + private JenkinsApiClient apiClient + + JobManager(JenkinsApiClient apiClient) { + this.apiClient = apiClient + } + + void createCredential(String jobName, String id, String username, String password, String description) { + def response = apiClient.postRequestWithCrumb("job/$jobName/credentials/store/folder/domain/_/createCredentials", + new FormBody.Builder() + .add("json", JsonOutput.toJson([credentials: [scope : "GLOBAL", + id : id, + username : username, + password : password, + description: description, + $class : "com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl",]])) + .build()) + + if (response.code() != 200) { + throw new RuntimeException("Could not create credential id=$id,job=$jobName. StatusCode: ${response.code()}") + } + } + + /** + * @return true, if created; false if job already exists and nothing was changed. + */ + boolean createJob(String name, String serverUrl, String jobNamespace, String credentialsId) { + if (jobExists(name)) { + log.warn("Job '${name}' already exists, ignoring.") + return false + } else { + // Note for development: the XML representation of an existing job can be exporting by adding /config.xml to the URL + String payloadXml = new TemplatingEngine().template(new File('argocd/cluster-resources/apps/jenkins/templates/namespaceJobTemplate.xml.ftl'), + [SCMM_NAMESPACE_JOB_SERVER_URL : serverUrl, + SCMM_NAMESPACE_JOB_NAMESPACE : jobNamespace, + SCMM_NAMESPACE_JOB_CREDENTIALS_ID: credentialsId]) + + RequestBody body = RequestBody.create(payloadXml, MediaType.get("text/xml")) + + def response = apiClient.postRequestWithCrumb("createItem?name=$name", body) + + if (response.code() != 200) { + throw new RuntimeException("Could not create job '${name}'. StatusCode: ${response.code()}") + } + } + return true + } + + boolean jobExists(String name) { + def response = apiClient.postRequestWithCrumb("job/$name") + + return response.code() == 200 + } + + void deleteJob(String name) { + if (name.contains("'")) { + throw new RuntimeException('Job name cannot contain quotes.') + } + + @Language("groovy") + String script = "print(Jenkins.instance.getItem('$name')?.delete())" + def result = apiClient.runScript(script) + + if (result != 'null') { + throw new RuntimeException("Could not delete job $name") + } + } + + void startJob(String jobName) { + + def response = apiClient.postRequestWithCrumb("job/$jobName/build?delay=0sec") + + if (response.code() != 200) { + throw new RuntimeException("Could not trigger build of Jenkins job: $jobName. StatusCode: ${response.code()}") + } + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/jenkins/PrometheusConfigurator.groovy b/src/main/groovy/com/cloudogu/gitops/jenkins/PrometheusConfigurator.groovy index 0a8266837..d5edae78c 100644 --- a/src/main/groovy/com/cloudogu/gitops/jenkins/PrometheusConfigurator.groovy +++ b/src/main/groovy/com/cloudogu/gitops/jenkins/PrometheusConfigurator.groovy @@ -4,14 +4,14 @@ import jakarta.inject.Singleton @Singleton class PrometheusConfigurator { - private final JenkinsApiClient apiClient + private final JenkinsApiClient apiClient - PrometheusConfigurator(JenkinsApiClient apiClient) { - this.apiClient = apiClient - } + PrometheusConfigurator(JenkinsApiClient apiClient) { + this.apiClient = apiClient + } - void enableAuthentication() { - def result = apiClient.runScript(""" + void enableAuthentication() { + def result = apiClient.runScript(""" import org.jenkinsci.plugins.prometheus.config.* def config = Jenkins.instance.getDescriptor(PrometheusConfiguration) @@ -20,8 +20,8 @@ class PrometheusConfigurator { print(config.useAuthenticatedEndpoint) """) - if (result != "true") { - throw new RuntimeException("Cannot enable authentication for prometheus: $result") - } - } -} + if (result != "true") { + throw new RuntimeException("Cannot enable authentication for prometheus: $result") + } + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/jenkins/UserManager.groovy b/src/main/groovy/com/cloudogu/gitops/jenkins/UserManager.groovy index 1eab3ba5c..f42008431 100644 --- a/src/main/groovy/com/cloudogu/gitops/jenkins/UserManager.groovy +++ b/src/main/groovy/com/cloudogu/gitops/jenkins/UserManager.groovy @@ -1,46 +1,47 @@ package com.cloudogu.gitops.jenkins -import groovy.util.logging.Slf4j import jakarta.inject.Singleton +import groovy.util.logging.Slf4j + import org.intellij.lang.annotations.Language @Singleton @Slf4j class UserManager { - private JenkinsApiClient apiClient + private JenkinsApiClient apiClient - UserManager(JenkinsApiClient apiClient) { - this.apiClient = apiClient - } + UserManager(JenkinsApiClient apiClient) { + this.apiClient = apiClient + } - void createUser(String username, String password) { - log.debug("Add user $username to jenkins") - - @Language("Groovy") - def script = """ + void createUser(String username, String password) { + log.debug("Add user $username to jenkins") + + @Language("Groovy") + def script = """ def realm = Jenkins.getInstance().getSecurityRealm() def user = realm.createAccount('${escapeString(username)}', '${escapeString(password)}') print(user) """ - - def result = apiClient.runScript(script) - if (result != username) { - throw new RuntimeException("Error when creating user: $result") - } - } + def result = apiClient.runScript(script) + + if (result != username) { + throw new RuntimeException("Error when creating user: $result") + } + } - void grantPermission(String username, Permissions permission) { - if (!isUsingMatrixBasedPermissions()) { - log.debug("Is not using matrix based permission. Does not need to add permission.") - return - } + void grantPermission(String username, Permissions permission) { + if (!isUsingMatrixBasedPermissions()) { + log.debug("Is not using matrix based permission. Does not need to add permission.") + return + } - log.debug("Grant user $username permission $permission") + log.debug("Grant user $username permission $permission") - @Language("Groovy") - def script = """ + @Language("Groovy") + def script = """ import org.jenkinsci.plugins.matrixauth.PermissionEntry import org.jenkinsci.plugins.matrixauth.AuthorizationType @@ -50,53 +51,55 @@ class UserManager { } print(permissions[${permission.toJenkinsPermissionEnum()}].add(new PermissionEntry(AuthorizationType.USER, '${escapeString(username)}'))) """ - def result = apiClient.runScript(script) + def result = apiClient.runScript(script) + + if (result !in ["true", "false"]) { + // Both are valid return values for Set.add(). true == was already in set, false == was not already in set + throw new RuntimeException("Failed to add permission $permission to $username: $result") + } + } - if (result !in ["true", "false"]) { // Both are valid return values for Set.add(). true == was already in set, false == was not already in set - throw new RuntimeException("Failed to add permission $permission to $username: $result") - } - } + boolean isUsingMatrixBasedPermissions() { + def result = apiClient.runScript("print(Jenkins.getInstance().getAuthorizationStrategy().class)") - boolean isUsingMatrixBasedPermissions() { - def result = apiClient.runScript("print(Jenkins.getInstance().getAuthorizationStrategy().class)") + if (!result.startsWith("class ")) { + throw new RuntimeException("Error when trying to determine authorization strategy: $result") + } - if (!result.startsWith("class ")) { - throw new RuntimeException("Error when trying to determine authorization strategy: $result") - } + return result == "class hudson.security.GlobalMatrixAuthorizationStrategy" || result == "class hudson.security.ProjectMatrixAuthorizationStrategy" + } - return result == "class hudson.security.GlobalMatrixAuthorizationStrategy" || result == "class hudson.security.ProjectMatrixAuthorizationStrategy" - } + boolean isUsingCasSecurityRealm() { + def result = apiClient.runScript("print(Jenkins.getInstance().getSecurityRealm().class)") - boolean isUsingCasSecurityRealm() { - def result = apiClient.runScript("print(Jenkins.getInstance().getSecurityRealm().class)") + if (!result.startsWith("class ")) { + throw new RuntimeException("Error when trying to determine security realm: $result") + } - if (!result.startsWith("class ")) { - throw new RuntimeException("Error when trying to determine security realm: $result") - } + return result == "class org.jenkinsci.plugins.cas.CasSecurityRealm" + } - return result == "class org.jenkinsci.plugins.cas.CasSecurityRealm" - } + private String escapeString(String str) { + if (str.contains("\\")) { + // We don't want get in trouble with escaping, + // e.g. `foo\'foo` => `foo\\'foo`. Now we would have a backslash followed by an unescaped quote. + throw new IllegalArgumentException("Backslashes within the escaped variables are forbidden.") + } - private String escapeString(String str) { - if (str.contains("\\")) { - // We don't want get in trouble with escaping, - // e.g. `foo\'foo` => `foo\\'foo`. Now we would have a backslash followed by an unescaped quote. - throw new IllegalArgumentException("Backslashes within the escaped variables are forbidden.") - } + return str.replace("'", "\\'") + } - return str.replace("'", "\\'") - } + enum Permissions { + METRICS_VIEW("jenkins.metrics.api.Metrics.VIEW") - enum Permissions { - METRICS_VIEW("jenkins.metrics.api.Metrics.VIEW") + private final String value - private final String value - Permissions(String value){ - this.value = value - } + Permissions(String value) { + this.value = value + } - String toJenkinsPermissionEnum() { - return value - } - } -} + String toJenkinsPermissionEnum() { + return value + } + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/kubernetes/api/HelmClient.groovy b/src/main/groovy/com/cloudogu/gitops/kubernetes/api/HelmClient.groovy index 5a0d3599c..29b33abc0 100644 --- a/src/main/groovy/com/cloudogu/gitops/kubernetes/api/HelmClient.groovy +++ b/src/main/groovy/com/cloudogu/gitops/kubernetes/api/HelmClient.groovy @@ -1,51 +1,52 @@ package com.cloudogu.gitops.kubernetes.api import com.cloudogu.gitops.utils.CommandExecutor -import groovy.util.logging.Slf4j + import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton class HelmClient { - private CommandExecutor commandExecutor - - HelmClient(CommandExecutor commandExecutor) { - this.commandExecutor = commandExecutor - } - - String addRepo(String repoName, String url) { - helm(['repo', 'add', repoName, url ]) - } - - String dependencyBuild(String path) { - helm(['dependency', 'build', path ]) - } - - String upgrade(String release, String chartOrPath, Map args) { - helm(['upgrade', '-i', release, chartOrPath, '--create-namespace' ], args) - } - - String template(String release, String chartOrPath, Map args = [:]) { - helm(['template', release, chartOrPath ], args) - } - - private String helm(List verbAndParams, Map args = [:]) { - List command = ['helm'] + verbAndParams - - for (entry in args) { - String key = entry.key - String value = entry.value - command += "--${key}".toString() - command += value - } - - commandExecutor.execute(command as String[]).stdOut - } - - String uninstall(String release, String namespace) { - String[] command = ["helm", "uninstall", release, '--namespace', namespace] - - commandExecutor.execute(command).stdOut - } + private CommandExecutor commandExecutor + + HelmClient(CommandExecutor commandExecutor) { + this.commandExecutor = commandExecutor + } + + String addRepo(String repoName, String url) { + helm(['repo', 'add', repoName, url]) + } + + String dependencyBuild(String path) { + helm(['dependency', 'build', path]) + } + + String upgrade(String release, String chartOrPath, Map args) { + helm(['upgrade', '-i', release, chartOrPath, '--create-namespace'], args) + } + + String template(String release, String chartOrPath, Map args = [:]) { + helm(['template', release, chartOrPath], args) + } + + private String helm(List verbAndParams, Map args = [:]) { + List command = ['helm'] + verbAndParams + + for (entry in args) { + String key = entry.key + String value = entry.value + command += "--${key}".toString() + command += value + } + + commandExecutor.execute(command as String[]).stdOut + } + + String uninstall(String release, String namespace) { + String[] command = ["helm", "uninstall", release, '--namespace', namespace] + + commandExecutor.execute(command).stdOut + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/kubernetes/api/K8sClient.groovy b/src/main/groovy/com/cloudogu/gitops/kubernetes/api/K8sClient.groovy index cb2127522..a4ab82d56 100644 --- a/src/main/groovy/com/cloudogu/gitops/kubernetes/api/K8sClient.groovy +++ b/src/main/groovy/com/cloudogu/gitops/kubernetes/api/K8sClient.groovy @@ -3,577 +3,550 @@ package com.cloudogu.gitops.kubernetes.api import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.utils.CommandExecutor import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.config.Credentials + +import jakarta.inject.Provider +import jakarta.inject.Singleton import groovy.json.JsonBuilder import groovy.json.JsonSlurper import groovy.transform.Immutable import groovy.util.logging.Slf4j -import jakarta.inject.Provider -import jakarta.inject.Singleton @Slf4j @Singleton class K8sClient { - private static final String[] APPLY_FROM_STDIN = ['kubectl', 'apply', '-f-'] - - protected int SLEEPTIME = 1000 - protected int DEFAULT_RETRIES = 120 - - private CommandExecutor commandExecutor - private FileSystemUtils fileSystemUtils - private Provider configProvider - public K8sJavaApiClient k8sJavaApiClient - - K8sClient( - CommandExecutor commandExecutor, - FileSystemUtils fileSystemUtils, - Provider configProvider - ) { - this.fileSystemUtils = fileSystemUtils - this.commandExecutor = commandExecutor - this.configProvider = configProvider - this.k8sJavaApiClient = new K8sJavaApiClient() - } - - private String waitForOutput(String[] command, String[] additionalCommand, String logMessage, String failureMessage, int maxTries = DEFAULT_RETRIES) { - int tryCount = 0 - String output = "" - - log.debug(logMessage) - while (output.isEmpty() && tryCount < maxTries) { - if (!additionalCommand) { - output = commandExecutor.execute(command).stdOut - } else { - output = commandExecutor.execute(command, additionalCommand).stdOut - } - - if (output.isEmpty()) { - tryCount++ - log.debug("Still waiting... (try $tryCount/$maxTries)") - sleep(SLEEPTIME) - } - } - - if (output.isEmpty()) { - throw new RuntimeException(failureMessage) - } - - return output - } - - private String waitForOutput(String[] command, String logMessage, String failureMessage, int maxTries = DEFAULT_RETRIES) { - waitForOutput(command, null, logMessage, failureMessage, maxTries) - } - - String waitForInternalNodeIp() { - String node = waitForNode() - // For k3d this is either the host's IP or the IP address of the k3d API server's container IP (when --bind-localhost=false) - // Note that this might return multiple InternalIP (IPV4 and IPV6) - we assume the first one is IPV4 (break after first) - String[] command = ["kubectl", "get", "$node", - "--template='{{range .status.addresses}}{{ if eq .type \"InternalIP\" }}{{.address}}{{break}}{{end}}{{end}}'"] - String output = waitForOutput( - command, - "Waiting for internal IP of node $node", - "Failed to retrieve internal node IP" - ) - - log.debug("Internal IP of node $node: $output") - return output - } - - String waitForNodePort(String serviceName, String namespace) { - - String[] command = new Kubectl("get", "service", serviceName) - .namespace(namespace) - .mandatory("-o", "jsonpath={.spec.ports[0].nodePort}") - .build() - - String output = waitForOutput( - command, - "Getting node port for service $serviceName, ns=$namespace", - "Failed to get node port for service $serviceName, ns=$namespace" - ) - - log.debug("Node port for service $serviceName, ns=$namespace: $output") - return output - } - - /** - * @return A string containing "node/nodeName", e.g. "node/k3d-gitops-playground-server-0" - */ - String waitForNode() { - String[] command1 = ['kubectl', 'get', 'node', '-oname'] - String[] command2 = ['head', '-n1'] - - String output = waitForOutput( - command1, command2, - "Waiting for first node of the cluster to become ready", - "Failed waiting for node of the cluster to become ready" - ) - - log.debug("First node of the cluster is ready: $output") - return output - } - - String applyYaml(String yamlLocation) { - commandExecutor.execute("kubectl apply -f $yamlLocation").stdOut - } - - /** - * Creates a namespace with the specified name if it does not already exist. - * - * @param name the name of the namespace to create. Must not be {@code null} or empty. - * - * @throws IllegalArgumentException if the {@code name} is {@code null} or empty. - * @throws RuntimeException if an error occurs during the creation of the namespace, - * such as insufficient permissions. - */ - void createNamespace(String name) { - validateNamespace(name) - - if (!exists(name)) { - - log.debug("Namespace ${name} does not exist, proceeding to create.") - - // Create the namespace - String[] createNamespaceCommand = new Kubectl("create", "namespace", name).build() - try { - CommandExecutor.Output createNamespaceOutput = commandExecutor.execute(createNamespaceCommand) - log.debug("Namespace ${name} created successfully.") - } catch (Exception e) { - throw new RuntimeException("Failed to create namespace ${name} (possibly due to insufficient permissions)", e) - } - } - - - } - - private boolean exists(String namespace) { -// Check if the namespace already exists based on exitCode - String[] checkNamespaceCommand = new Kubectl("get", "namespace", namespace).build() - CommandExecutor.Output checkNamespaceOutput = commandExecutor.execute(checkNamespaceCommand, false) - - if (checkNamespaceOutput.exitCode == 0) { - log.debug("Namespace ${namespace} already exists.") - return true - } - return false - } - - private void validateNamespace(String name) { - if (name == null || name.trim().isEmpty()) { - throw new IllegalArgumentException("Namespace name must be provided and cannot be null or empty.") - } - } - - /** - * Creates multiple namespaces based on the given list of namespace names. - * - * @param names a list of strings representing the names of the namespaces to be created. - * Must not be {@code null}. - * - * @throws IllegalArgumentException if the {@code names} list is {@code null}. - */ - void createNamespaces(List names) { - if (names == null) { - throw new IllegalArgumentException("Namespaces must be provided and cannot be null.") - } - names.each { name -> - createNamespace(name) - } - } - - /** - * Idempotent create, i.e. overwrites if exists. - */ - void createSecret(String type, String name, String namespace = '', Tuple2... literals) { - def command1 = kubectl('create', 'secret', type, name) - .namespace(namespace) - .mandatory('--from-literal', literals) - .dryRunOutputYaml() - .build() - - commandExecutor.execute(command1, APPLY_FROM_STDIN) - } - - String getArgoCDNamespacesSecret(String name, String namespace = '') { - String[] command = ["kubectl", "get", 'secret', name, "-n", "${namespace}", '-ojsonpath={.data.namespaces}'] - String output = waitForOutput( - command, - "Getting Secret from Cluster", - "Failed getting Secret from Cluster" - ) - return output - } - - /** - * Idempotent create, i.e. overwrites if exists. - */ - void createImagePullSecret(String name, String namespace = '', String host, String user, String password) { - def command1 = kubectl('create', 'secret', 'docker-registry', name) - .namespace(namespace) - .mandatory('--docker-server', host) - .mandatory('--docker-username', user) - .mandatory('--docker-password', password) - .dryRunOutputYaml() - .build() - - commandExecutor.execute(command1, APPLY_FROM_STDIN) - } - - /** - * Idempotent create, i.e. overwrites if exists. - */ - void createConfigMapFromFile(String name, String namespace = '', String filePath) { - def command1 = kubectl('create', 'configmap', name) - .namespace(namespace) - .mandatory('--from-file', filePath) - .dryRunOutputYaml() - .build() - - commandExecutor.execute(command1, APPLY_FROM_STDIN) - } - - /** - * Idempotent create, i.e. overwrites if exists. - * - * @param tcp Port pairs can be specified as ':'. - */ - void createServiceNodePort(String name, String tcp, String nodePort = '', String namespace = '') { - def command1 = kubectl('create', 'service', 'nodeport', name) - .namespace(namespace) - .mandatory('--tcp', tcp) - .optional('--node-port', nodePort) - .dryRunOutputYaml() - .build() - - commandExecutor.execute(command1, APPLY_FROM_STDIN) - } - - void labelRemove(String resource, String name, String namespace = '', String... keys) { - Tuple2[] tuples = keys.collect { new Tuple2("${it}-", "") }.toArray(new Tuple2[0]) - label(resource, name, namespace, tuples) - } - - void label(String resource, String name, String namespace = '', Tuple2... keyValues) { - if (!keyValues) { - throw new RuntimeException("Missing key-value-pairs") - } - String command = - "kubectl label ${resource} ${name}${namespace ? " -n ${namespace}" : ''} " + - '--overwrite ' + // Make idempotent - keyValues.collect { "${it.v1}${it.v2 ? "=${it.v2}" : ''}" }.join(' ') - commandExecutor.execute(command) - } - - String run(String name, String image, String namespace = '', Map overrides = [:], String... params) { - - def command1 = kubectl('run', name) - .mandatory('--image', image) - .namespace(namespace) - .optional(params) - .optional('--overrides', mapToJson(overrides, 'kubectl run overrides')) - .build() - - commandExecutor.execute(command1).stdOut - } - - void patch(String resource, String name, String namespace = '', String type = '', Map yaml) { - // We're using a patch file here, instead of a patch JSON (--patch), because of quoting issues - // ERROR c.c.gitops.utils.CommandExecutor - Stderr: error: unable to parse "'{\"stringData\":": yaml: found unexpected end of stream - File patchYaml = File.createTempFile('gitops-playground-patch-yaml', '') - log.trace("Writing patch YAML: ${yaml}") - fileSystemUtils.writeYaml(yaml, patchYaml) - - // kubectl patch secret argocd-secret -p '{"stringData": { "admin.password": "'"${bcryptArgoCDPassword}"'"}}' || true - String command = - "kubectl patch ${resource} ${name}${namespace ? " -n ${namespace}" : ''}" + - (type ? " --type=$type" : '') + - " --patch-file=${patchYaml.absolutePath}" - commandExecutor.execute(command) - } - - void delete(String resource, String namespace = '', Tuple2... selectors) { - if (!selectors) { - throw new RuntimeException("Missing selectors") - } - // kubectl delete secret -n argocd -l owner=helm,name=argocd - String command = - "kubectl delete ${resource}${namespace ? " -n ${namespace}" : ''}" + - ' --ignore-not-found=true ' + // Make idempotent - selectors.collect { "--selector=${it.v1}=${it.v2}" }.join(' ') - - commandExecutor.execute(command) - } - - void delete(String resource, String namespace, String name) { - String command = - "kubectl delete ${resource}${namespace ? " -n ${namespace}" : ''}" + - " $name" + - ' --ignore-not-found=true ' // Make idempotent - - commandExecutor.execute(command) - } - - List getCustomResource(String resource) { - String[] command = ["kubectl", "get", resource, "-A", "-o", "jsonpath={range .items[*]}{.metadata.namespace}{','}{.metadata.name}{'\\n'}{end}"] - def result = commandExecutor.execute(command) - - if (!result.stdOut) { - return [] - } - - return result.stdOut.split('\n').collect { line -> - def parts = line.split(',') - new CustomResource(parts[0].trim(), parts[1].trim()) - } - } - - String getConfigMap(String mapName, String key) { - String[] command = ["kubectl", "get", "configmap", mapName, "-o", "jsonpath={.data['" + key.replace(".", "\\.") + "']}"] - def result = commandExecutor.execute(command, false) - if (result.exitCode != 0) { - throw new RuntimeException("Could not fetch configmap $mapName: ${result.stdErr}") - } - - if (result.stdOut == "") { - throw new RuntimeException("Could not fetch $key within config-map $mapName") - } - - return result.stdOut - } - - String getCurrentContext() { - // When running inside a pod this might fail - def output = commandExecutor.execute('kubectl config current-context', false) - if (!output.stdOut) { - output.stdOut = '(current context not set)' - } - return output.stdOut - } - - /** - * @param resource resource to get the annotation from - * @param name name of the resource, only one resource allowed! - * @param key key of the annotation - * @param namespace namespace of the resource (if not cluster wide) - * - * @return the value of the annotation - */ - String getAnnotation(String resource, String name, String key, String namespace = '') { - List commandAsList = [ - "kubectl", - "get", - resource, - name, - "-o", - // jsonpath expects a single resource object - // some requests with multiple resources may result in a listed response - // that does not match the jsonpath - "jsonpath={.metadata.annotations}" - ] - if (namespace) { - commandAsList.add("-n $namespace" as String) - } - String[] command = commandAsList.toArray(new String[0]) - def result = commandExecutor.execute(command, false) - if (!result.getStdErr().isEmpty()) { - throw new RuntimeException("Failed to fetch data from resource [$resource/$name] in namespace [$namespace]: ${result.stdErr}") - } - log.debug("getAnnotation returns = ${result.stdOut}") - def value = new JsonSlurper().parseText(result.stdOut) as Map - String myResult = value[key] - return myResult - } - - - private Kubectl kubectl(String... args) { - new Kubectl(args) - } - - /** - * Patches the nodePort of a specified port in a service. - * - * @param serviceName The name of the service to patch. - * @param namespace The namespace of the service. - * @param portName The name of the port to patch. - * @param newNodePort The new nodePort value to set. - * - * @throws IllegalArgumentException if name, namespace, portName, and nodePort are invalid. - * @throws RuntimeException if an error occurs while patching the service (i.e. portName not found). - */ - void patchServiceNodePort(String serviceName, String namespace, String portName, int newNodePort) { - validateInputForPatch(serviceName, namespace, portName, newNodePort) - - // Get the current service spec to find the index of the port to patch - String[] getServiceCommand = new Kubectl("get", "service", serviceName) - .namespace(namespace) - .mandatory("-o", "json") - .build() - CommandExecutor.Output getServiceOutput = commandExecutor.execute(getServiceCommand) - def serviceSpec = new JsonSlurper().parseText(getServiceOutput.stdOut) - def ports = serviceSpec['spec']['ports'] - - // Find the index of the port to patch - def portIndex = ports.findIndexOf { it['name'] == portName } - if (portIndex == -1) { - throw new RuntimeException("Port with name ${portName} not found in service ${serviceName}.") - } - - // Create the JSON patch for the specific port - def patch = [ - [ - op : "replace", - path : "/spec/ports/${portIndex}/nodePort", - value: newNodePort - ] - ] - String patchJson = new JsonBuilder(patch).toString() - - // Apply the patch - String[] patchCommand = new Kubectl("patch", "service", serviceName) - .namespace(namespace) - .mandatory("--type", "json") - .mandatory("-p", patchJson) - .build() - CommandExecutor.Output patchOutput = commandExecutor.execute(patchCommand) - log.debug("Service ${serviceName} in namespace ${namespace} successfully patched with nodePort ${newNodePort} for port ${portName}.") - } - - private static String mapToJson(Map kubectlJson, String debugPrefix) { - if (kubectlJson.isEmpty()) { - return '' - } - - JsonBuilder json = new JsonBuilder(kubectlJson) - log.debug("${debugPrefix} JSON pretty printed:\n${json.toPrettyString()}") - // Note that toPrettyString() will lead to empty results in some shell, e.g. plain sh 🧐 - return json.toString() - } - - private void validateInputForPatch(String serviceName, String namespace, String portName, int newNodePort) { - if (!serviceName || !namespace || !portName || newNodePort <= 0) { - throw new IllegalArgumentException("Service name, namespace, port name, and valid nodePort must be provided") - } - } - - /** - * Waits until the specified resource reaches the desired phase. - * - * @param resourceType The type of the Kubernetes resource (e.g., pod, deployment). - * @param resourceName The name of the specific resource. - * @param namespace The namespace of the resource. - * @param desiredPhase The desired phase to wait for (e.g., Running, Succeeded). - * @param timeoutSeconds The maximum time to wait for the desired phase in seconds. - * @param checkIntervalSeconds The interval between status checks in seconds. - * - * @throws IllegalArgumentException if Resource type, name, namespace, desired phase, Timeout and check interval are invalid. - * @throws RuntimeException if the desired phase is not reached within the timeout period. - */ - void waitForResourcePhase(String resourceType, String resourceName, String namespace, String desiredPhase, int timeoutSeconds, int checkIntervalSeconds) { - validateInputForWaitPhase(resourceType, resourceName, namespace, desiredPhase, timeoutSeconds, checkIntervalSeconds) - - long startTime = System.currentTimeMillis() - long endTime = startTime + (timeoutSeconds * 1000) - - while (System.currentTimeMillis() < endTime) { - String[] command = new Kubectl("get", resourceType, resourceName) - .namespace(namespace) - .mandatory("-o", "jsonpath={.status.phase}") - .build() - - def output = commandExecutor.execute(command) - String phase = output.stdOut.trim() - if (phase == desiredPhase) { - log.debug("Resource ${resourceType}/${resourceName} in namespace ${namespace} reached the desired phase: ${desiredPhase}") - return - } - - log.debug("Current phase: ${phase}. Waiting for phase: ${desiredPhase}...") - sleep(checkIntervalSeconds * 1000) - } - - // Never reached the desired Phase, so throw a RuntimeException and end the execution - throw new RuntimeException("Timeout reached. Resource ${resourceType}/${resourceName} in namespace ${namespace} did not reach the desired phase: ${desiredPhase} within ${timeoutSeconds} seconds.") - } - - private void validateInputForWaitPhase(String resourceType, String resourceName, String namespace, String desiredPhase, int timeoutSeconds, int checkIntervalSeconds) { - if (!resourceType || !resourceName || !namespace || !desiredPhase) { - throw new IllegalArgumentException("Resource type, name, namespace, and desired phase must be provided") - } - if (timeoutSeconds <= 0 || checkIntervalSeconds <= 0) { - throw new IllegalArgumentException("Timeout and check interval must be greater than zero") - } - } - - /** - * Waits for a specific resource to reach the desired phase with default timeout and interval. - * - * @param resourceType The type of the Kubernetes resource (e.g., pod, deployment). - * @param resourceName The name of the specific resource. - * @param namespace The namespace of the resource. - * @param desiredPhase The desired phase to wait for (e.g., Running, Succeeded). - * - * @see #waitForResourcePhase(String, String, String, String, int, int) - */ - void waitForResourcePhase(String resourceType, String resourceName, String namespace, String desiredPhase) { - waitForResourcePhase(resourceType, resourceName, namespace, desiredPhase, 60, 1) - } - - @Immutable - static class CustomResource { - String namespace - String name - } - - private class Kubectl { - private List command = ['kubectl'] - - Kubectl(String... args) { - command.addAll(args) - } - - Kubectl namespace(String namespace) { - if (namespace) { - this.command += ['-n', namespace] - } - return this - } - - Kubectl mandatory(String paramName, String value) { - // Here we could assert that value != null. For historical reasons we don't, for now. - this.command += [paramName, value] - return this - } - - Kubectl mandatory(String paramName, Tuple2... values) { - if (!values) { - throw new RuntimeException("Missing values for parameter '${paramName}' in command '${command.join(' ')}'") - } - values.each { command += [paramName, "${it.v1}=${it.v2 ? it.v2 : ''}".toString()] } - return this - } - - Kubectl optional(String paramName, String value) { - if (value) { - this.command += [paramName, value] - } - return this - } - - Kubectl optional(String... params) { - command.addAll(params) - return this - } - - Kubectl dryRunOutputYaml() { - this.command += ['--dry-run=client', '-oyaml'] - return this - } - - String[] build() { - this.command - } - } + private static final String[] APPLY_FROM_STDIN = ['kubectl', 'apply', '-f-'] + + protected int SLEEPTIME = 1000 + protected int DEFAULT_RETRIES = 120 + + private CommandExecutor commandExecutor + private FileSystemUtils fileSystemUtils + private Provider configProvider + public K8sJavaApiClient k8sJavaApiClient + + K8sClient(CommandExecutor commandExecutor, + FileSystemUtils fileSystemUtils, + Provider configProvider) { + this.fileSystemUtils = fileSystemUtils + this.commandExecutor = commandExecutor + this.configProvider = configProvider + this.k8sJavaApiClient = new K8sJavaApiClient() + } + + private String waitForOutput(String[] command, String[] additionalCommand, String logMessage, String failureMessage, int maxTries = DEFAULT_RETRIES) { + int tryCount = 0 + String output = "" + + log.debug(logMessage) + while (output.isEmpty() && tryCount < maxTries) { + if (!additionalCommand) { + output = commandExecutor.execute(command).stdOut + } else { + output = commandExecutor.execute(command, additionalCommand).stdOut + } + + if (output.isEmpty()) { + tryCount++ + log.debug("Still waiting... (try $tryCount/$maxTries)") + sleep(SLEEPTIME) + } + } + + if (output.isEmpty()) { + throw new RuntimeException(failureMessage) + } + + return output + } + + private String waitForOutput(String[] command, String logMessage, String failureMessage, int maxTries = DEFAULT_RETRIES) { + waitForOutput(command, null, logMessage, failureMessage, maxTries) + } + + String waitForInternalNodeIp() { + String node = waitForNode() + // For k3d this is either the host's IP or the IP address of the k3d API server's container IP (when --bind-localhost=false) + // Note that this might return multiple InternalIP (IPV4 and IPV6) - we assume the first one is IPV4 (break after first) + String[] command = ["kubectl", "get", "$node", + "--template='{{range .status.addresses}}{{ if eq .type \"InternalIP\" }}{{.address}}{{break}}{{end}}{{end}}'"] + String output = waitForOutput(command, + "Waiting for internal IP of node $node", + "Failed to retrieve internal node IP") + + log.debug("Internal IP of node $node: $output") + return output + } + + String waitForNodePort(String serviceName, String namespace) { + + String[] command = new Kubectl("get", "service", serviceName) + .namespace(namespace) + .mandatory("-o", "jsonpath={.spec.ports[0].nodePort}") + .build() + + String output = waitForOutput(command, + "Getting node port for service $serviceName, ns=$namespace", + "Failed to get node port for service $serviceName, ns=$namespace") + + log.debug("Node port for service $serviceName, ns=$namespace: $output") + return output + } + + /** + * @return A string containing "node/nodeName", e.g. "node/k3d-gitops-playground-server-0" + */ + String waitForNode() { + String[] command1 = ['kubectl', 'get', 'node', '-oname'] + String[] command2 = ['head', '-n1'] + + String output = waitForOutput(command1, command2, + "Waiting for first node of the cluster to become ready", + "Failed waiting for node of the cluster to become ready") + + log.debug("First node of the cluster is ready: $output") + return output + } + + String applyYaml(String yamlLocation) { + commandExecutor.execute("kubectl apply -f $yamlLocation").stdOut + } + + /** + * Creates a namespace with the specified name if it does not already exist. + * + * @param name the name of the namespace to create. Must not be {@code null} or empty. + * + * @throws IllegalArgumentException if the {@code name} is {@code null} or empty. + * @throws RuntimeException if an error occurs during the creation of the namespace, + * such as insufficient permissions. + */ + void createNamespace(String name) { + validateNamespace(name) + + if (!exists(name)) { + + log.debug("Namespace ${name} does not exist, proceeding to create.") + + // Create the namespace + String[] createNamespaceCommand = new Kubectl("create", "namespace", name).build() + try { + CommandExecutor.Output createNamespaceOutput = commandExecutor.execute(createNamespaceCommand) + log.debug("Namespace ${name} created successfully.") + } catch (Exception e) { + throw new RuntimeException("Failed to create namespace ${name} (possibly due to insufficient permissions)", e) + } + } + + } + + private boolean exists(String namespace) { + // Check if the namespace already exists based on exitCode + String[] checkNamespaceCommand = new Kubectl("get", "namespace", namespace).build() + CommandExecutor.Output checkNamespaceOutput = commandExecutor.execute(checkNamespaceCommand, false) + + if (checkNamespaceOutput.exitCode == 0) { + log.debug("Namespace ${namespace} already exists.") + return true + } + return false + } + + private void validateNamespace(String name) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Namespace name must be provided and cannot be null or empty.") + } + } + + /** + * Creates multiple namespaces based on the given list of namespace names. + * + * @param names a list of strings representing the names of the namespaces to be created. + * Must not be {@code null}. + * + * @throws IllegalArgumentException if the {@code names} list is {@code null}. + */ + void createNamespaces(List names) { + if (names == null) { + throw new IllegalArgumentException("Namespaces must be provided and cannot be null.") + } + names.each { name -> createNamespace(name) + } + } + + /** + * Idempotent create, i.e. overwrites if exists.*/ + void createSecret(String type, String name, String namespace = '', Tuple2... literals) { + def command1 = kubectl('create', 'secret', type, name) + .namespace(namespace) + .mandatory('--from-literal', literals) + .dryRunOutputYaml() + .build() + + commandExecutor.execute(command1, APPLY_FROM_STDIN) + } + + String getArgoCDNamespacesSecret(String name, String namespace = '') { + String[] command = ["kubectl", "get", 'secret', name, "-n", "${namespace}", '-ojsonpath={.data.namespaces}'] + String output = waitForOutput(command, + "Getting Secret from Cluster", + "Failed getting Secret from Cluster") + return output + } + + /** + * Idempotent create, i.e. overwrites if exists.*/ + void createImagePullSecret(String name, String namespace = '', String host, String user, String password) { + def command1 = kubectl('create', 'secret', 'docker-registry', name) + .namespace(namespace) + .mandatory('--docker-server', host) + .mandatory('--docker-username', user) + .mandatory('--docker-password', password) + .dryRunOutputYaml() + .build() + + commandExecutor.execute(command1, APPLY_FROM_STDIN) + } + + /** + * Idempotent create, i.e. overwrites if exists.*/ + void createConfigMapFromFile(String name, String namespace = '', String filePath) { + def command1 = kubectl('create', 'configmap', name) + .namespace(namespace) + .mandatory('--from-file', filePath) + .dryRunOutputYaml() + .build() + + commandExecutor.execute(command1, APPLY_FROM_STDIN) + } + + /** + * Idempotent create, i.e. overwrites if exists. + * + * @param tcp Port pairs can be specified as ':'. + */ + void createServiceNodePort(String name, String tcp, String nodePort = '', String namespace = '') { + def command1 = kubectl('create', 'service', 'nodeport', name) + .namespace(namespace) + .mandatory('--tcp', tcp) + .optional('--node-port', nodePort) + .dryRunOutputYaml() + .build() + + commandExecutor.execute(command1, APPLY_FROM_STDIN) + } + + void labelRemove(String resource, String name, String namespace = '', String... keys) { + Tuple2[] tuples = keys.collect { new Tuple2("${it}-", "") }.toArray(new Tuple2[0]) + label(resource, name, namespace, tuples) + } + + void label(String resource, String name, String namespace = '', Tuple2... keyValues) { + if (!keyValues) { + throw new RuntimeException("Missing key-value-pairs") + } + String command = + "kubectl label ${resource} ${name}${namespace ? " -n ${namespace}" : ''} " + '--overwrite ' + // Make idempotent + keyValues.collect { "${it.v1}${it.v2 ? "=${it.v2}" : ''}" }.join(' ') + commandExecutor.execute(command) + } + + String run(String name, String image, String namespace = '', Map overrides = [:], String... params) { + + def command1 = kubectl('run', name) + .mandatory('--image', image) + .namespace(namespace) + .optional(params) + .optional('--overrides', mapToJson(overrides, 'kubectl run overrides')) + .build() + + commandExecutor.execute(command1).stdOut + } + + void patch(String resource, String name, String namespace = '', String type = '', Map yaml) { + // We're using a patch file here, instead of a patch JSON (--patch), because of quoting issues + // ERROR c.c.gitops.utils.CommandExecutor - Stderr: error: unable to parse "'{\"stringData\":": yaml: found unexpected end of stream + File patchYaml = File.createTempFile('gitops-playground-patch-yaml', '') + log.trace("Writing patch YAML: ${yaml}") + fileSystemUtils.writeYaml(yaml, patchYaml) + + // kubectl patch secret argocd-secret -p '{"stringData": { "admin.password": "'"${bcryptArgoCDPassword}"'"}}' || true + String command = + "kubectl patch ${resource} ${name}${namespace ? " -n ${namespace}" : ''}" + (type ? " --type=$type" : '') + " --patch-file=${patchYaml.absolutePath}" + commandExecutor.execute(command) + } + + void delete(String resource, String namespace = '', Tuple2... selectors) { + if (!selectors) { + throw new RuntimeException("Missing selectors") + } + // kubectl delete secret -n argocd -l owner=helm,name=argocd + String command = + "kubectl delete ${resource}${namespace ? " -n ${namespace}" : ''}" + ' --ignore-not-found=true ' + // Make idempotent + selectors.collect { "--selector=${it.v1}=${it.v2}" }.join(' ') + + commandExecutor.execute(command) + } + + void delete(String resource, String namespace, String name) { + String command = + "kubectl delete ${resource}${namespace ? " -n ${namespace}" : ''}" + " $name" + ' --ignore-not-found=true ' + // Make idempotent + + commandExecutor.execute(command) + } + + List getCustomResource(String resource) { + String[] command = ["kubectl", "get", resource, "-A", "-o", "jsonpath={range .items[*]}{.metadata.namespace}{','}{.metadata.name}{'\\n'}{end}"] + def result = commandExecutor.execute(command) + + if (!result.stdOut) { + return [] + } + + return result.stdOut.split('\n').collect { line -> + def parts = line.split(',') + new CustomResource(parts[0].trim(), parts[1].trim()) + } + } + + String getConfigMap(String mapName, String key) { + String[] command = ["kubectl", "get", "configmap", mapName, "-o", "jsonpath={.data['" + key.replace(".", "\\.") + "']}"] + def result = commandExecutor.execute(command, false) + if (result.exitCode != 0) { + throw new RuntimeException("Could not fetch configmap $mapName: ${result.stdErr}") + } + + if (result.stdOut == "") { + throw new RuntimeException("Could not fetch $key within config-map $mapName") + } + + return result.stdOut + } + + String getCurrentContext() { + // When running inside a pod this might fail + def output = commandExecutor.execute('kubectl config current-context', false) + if (!output.stdOut) { + output.stdOut = '(current context not set)' + } + return output.stdOut + } + + /** + * @param resource resource to get the annotation from + * @param name name of the resource, only one resource allowed! + * @param key key of the annotation + * @param namespace namespace of the resource (if not cluster wide) + * + * @return the value of the annotation + */ + String getAnnotation(String resource, String name, String key, String namespace = '') { + List commandAsList = ["kubectl", + "get", + resource, + name, + "-o", + // jsonpath expects a single resource object + // some requests with multiple resources may result in a listed response + // that does not match the jsonpath + "jsonpath={.metadata.annotations}"] + if (namespace) { + commandAsList.add("-n $namespace" as String) + } + String[] command = commandAsList.toArray(new String[0]) + def result = commandExecutor.execute(command, false) + if (!result.getStdErr().isEmpty()) { + throw new RuntimeException("Failed to fetch data from resource [$resource/$name] in namespace [$namespace]: ${result.stdErr}") + } + log.debug("getAnnotation returns = ${result.stdOut}") + def value = new JsonSlurper().parseText(result.stdOut) as Map + String myResult = value[key] + return myResult + } + + private Kubectl kubectl(String... args) { + new Kubectl(args) + } + + /** + * Patches the nodePort of a specified port in a service. + * + * @param serviceName The name of the service to patch. + * @param namespace The namespace of the service. + * @param portName The name of the port to patch. + * @param newNodePort The new nodePort value to set. + * + * @throws IllegalArgumentException if name, namespace, portName, and nodePort are invalid. + * @throws RuntimeException if an error occurs while patching the service (i.e. portName not found). + */ + void patchServiceNodePort(String serviceName, String namespace, String portName, int newNodePort) { + validateInputForPatch(serviceName, namespace, portName, newNodePort) + + // Get the current service spec to find the index of the port to patch + String[] getServiceCommand = new Kubectl("get", "service", serviceName) + .namespace(namespace) + .mandatory("-o", "json") + .build() + CommandExecutor.Output getServiceOutput = commandExecutor.execute(getServiceCommand) + def serviceSpec = new JsonSlurper().parseText(getServiceOutput.stdOut) + def ports = serviceSpec['spec']['ports'] + + // Find the index of the port to patch + def portIndex = ports.findIndexOf { it['name'] == portName } + if (portIndex == -1) { + throw new RuntimeException("Port with name ${portName} not found in service ${serviceName}.") + } + + // Create the JSON patch for the specific port + def patch = [[op : "replace", + path : "/spec/ports/${portIndex}/nodePort", + value: newNodePort]] + String patchJson = new JsonBuilder(patch).toString() + + // Apply the patch + String[] patchCommand = new Kubectl("patch", "service", serviceName) + .namespace(namespace) + .mandatory("--type", "json") + .mandatory("-p", patchJson) + .build() + CommandExecutor.Output patchOutput = commandExecutor.execute(patchCommand) + log.debug("Service ${serviceName} in namespace ${namespace} successfully patched with nodePort ${newNodePort} for port ${portName}.") + } + + private static String mapToJson(Map kubectlJson, String debugPrefix) { + if (kubectlJson.isEmpty()) { + return '' + } + + JsonBuilder json = new JsonBuilder(kubectlJson) + log.debug("${debugPrefix} JSON pretty printed:\n${json.toPrettyString()}") + // Note that toPrettyString() will lead to empty results in some shell, e.g. plain sh 🧐 + return json.toString() + } + + private void validateInputForPatch(String serviceName, String namespace, String portName, int newNodePort) { + if (!serviceName || !namespace || !portName || newNodePort <= 0) { + throw new IllegalArgumentException("Service name, namespace, port name, and valid nodePort must be provided") + } + } + + /** + * Waits until the specified resource reaches the desired phase. + * + * @param resourceType The type of the Kubernetes resource (e.g., pod, deployment). + * @param resourceName The name of the specific resource. + * @param namespace The namespace of the resource. + * @param desiredPhase The desired phase to wait for (e.g., Running, Succeeded). + * @param timeoutSeconds The maximum time to wait for the desired phase in seconds. + * @param checkIntervalSeconds The interval between status checks in seconds. + * + * @throws IllegalArgumentException if Resource type, name, namespace, desired phase, Timeout and check interval are invalid. + * @throws RuntimeException if the desired phase is not reached within the timeout period. + */ + void waitForResourcePhase(String resourceType, String resourceName, String namespace, String desiredPhase, int timeoutSeconds, int checkIntervalSeconds) { + validateInputForWaitPhase(resourceType, resourceName, namespace, desiredPhase, timeoutSeconds, checkIntervalSeconds) + + long startTime = System.currentTimeMillis() + long endTime = startTime + (timeoutSeconds * 1000) + + while (System.currentTimeMillis() < endTime) { + String[] command = new Kubectl("get", resourceType, resourceName) + .namespace(namespace) + .mandatory("-o", "jsonpath={.status.phase}") + .build() + + def output = commandExecutor.execute(command) + String phase = output.stdOut.trim() + if (phase == desiredPhase) { + log.debug("Resource ${resourceType}/${resourceName} in namespace ${namespace} reached the desired phase: ${desiredPhase}") + return + } + + log.debug("Current phase: ${phase}. Waiting for phase: ${desiredPhase}...") + sleep(checkIntervalSeconds * 1000) + } + + // Never reached the desired Phase, so throw a RuntimeException and end the execution + throw new RuntimeException("Timeout reached. Resource ${resourceType}/${resourceName} in namespace ${namespace} did not reach the desired phase: ${desiredPhase} within ${timeoutSeconds} seconds.") + } + + private void validateInputForWaitPhase(String resourceType, String resourceName, String namespace, String desiredPhase, int timeoutSeconds, int checkIntervalSeconds) { + if (!resourceType || !resourceName || !namespace || !desiredPhase) { + throw new IllegalArgumentException("Resource type, name, namespace, and desired phase must be provided") + } + if (timeoutSeconds <= 0 || checkIntervalSeconds <= 0) { + throw new IllegalArgumentException("Timeout and check interval must be greater than zero") + } + } + + /** + * Waits for a specific resource to reach the desired phase with default timeout and interval. + * + * @param resourceType The type of the Kubernetes resource (e.g., pod, deployment). + * @param resourceName The name of the specific resource. + * @param namespace The namespace of the resource. + * @param desiredPhase The desired phase to wait for (e.g., Running, Succeeded). + * + * @see #waitForResourcePhase(String, String, String, String, int, int) + */ + void waitForResourcePhase(String resourceType, String resourceName, String namespace, String desiredPhase) { + waitForResourcePhase(resourceType, resourceName, namespace, desiredPhase, 60, 1) + } + + @Immutable + static class CustomResource { + String namespace + String name + } + + private class Kubectl { + private List command = ['kubectl'] + + Kubectl(String... args) { + command.addAll(args) + } + + Kubectl namespace(String namespace) { + if (namespace) { + this.command += ['-n', namespace] + } + return this + } + + Kubectl mandatory(String paramName, String value) { + // Here we could assert that value != null. For historical reasons we don't, for now. + this.command += [paramName, value] + return this + } + + Kubectl mandatory(String paramName, Tuple2... values) { + if (!values) { + throw new RuntimeException("Missing values for parameter '${paramName}' in command '${command.join(' ')}'") + } + values.each { command += [paramName, "${it.v1}=${it.v2 ? it.v2 : ''}".toString()] } + return this + } + + Kubectl optional(String paramName, String value) { + if (value) { + this.command += [paramName, value] + } + return this + } + + Kubectl optional(String... params) { + command.addAll(params) + return this + } + + Kubectl dryRunOutputYaml() { + this.command += ['--dry-run=client', '-oyaml'] + return this + } + + String[] build() { + this.command + } + } } \ 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..3c3559736 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,80 @@ 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 diff --git a/src/main/groovy/com/cloudogu/gitops/kubernetes/argocd/ArgoApplication.groovy b/src/main/groovy/com/cloudogu/gitops/kubernetes/argocd/ArgoApplication.groovy index 686653777..c767091bf 100644 --- a/src/main/groovy/com/cloudogu/gitops/kubernetes/argocd/ArgoApplication.groovy +++ b/src/main/groovy/com/cloudogu/gitops/kubernetes/argocd/ArgoApplication.groovy @@ -1,67 +1,62 @@ package com.cloudogu.gitops.kubernetes.argocd - import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.utils.TemplatingEngine -import groovy.util.logging.Slf4j import java.nio.file.Path +import groovy.util.logging.Slf4j @Slf4j class ArgoApplication { - final String ARGOCD = ("templates/kubernetes/argocd/application.ftl.yaml") - - String name - String namespace - String destinationNamespace - String path - String repoUrl - String project - - private final TemplatingEngine templater = new TemplatingEngine() - - ArgoApplication(String name, String repoUrl, String namespace, String destinationNamespace, String path, String project = 'default') { - this.name = name - this.namespace = namespace - this.destinationNamespace = destinationNamespace - this.project = project - this.repoUrl = repoUrl - this.path = path - } - - Map toTemplateParams() { - return [ - name : this.name, - namespace : this.namespace, - project : this.project, - path : this.path, - destinationNamespace: this.destinationNamespace, - repoUrl : this.repoUrl - ] - } - - File getTemplateFile() { - return new File(ARGOCD) - } - - File getOutputFile(File outputDir) { - String filename = "argocd-application-${name}-${namespace}.yaml" - return new File(outputDir, filename) - } - - void generate(GitRepo repo, String subfolder) { - log.debug("Generating ArgoCDApplication for name='${name}', namespace='${namespace}''") - - def outputDir = Path.of(repo.absoluteLocalRepoTmpDir, subfolder).toFile() - outputDir.mkdirs() - - templater.template( - this.getTemplateFile(), - this.getOutputFile(outputDir), - this.toTemplateParams() - ) - - } + final String ARGOCD = ("templates/kubernetes/argocd/application.ftl.yaml") + + String name + String namespace + String destinationNamespace + String path + String repoUrl + String project + + private final TemplatingEngine templater = new TemplatingEngine() + + ArgoApplication(String name, String repoUrl, String namespace, String destinationNamespace, String path, String project = 'default') { + this.name = name + this.namespace = namespace + this.destinationNamespace = destinationNamespace + this.project = project + this.repoUrl = repoUrl + this.path = path + } + + Map toTemplateParams() { + return [name : this.name, + namespace : this.namespace, + project : this.project, + path : this.path, + destinationNamespace: this.destinationNamespace, + repoUrl : this.repoUrl] + } + + File getTemplateFile() { + return new File(ARGOCD) + } + + File getOutputFile(File outputDir) { + String filename = "argocd-application-${name}-${namespace}.yaml" + return new File(outputDir, filename) + } + + void generate(GitRepo repo, String subfolder) { + log.debug("Generating ArgoCDApplication for name='${name}', namespace='${namespace}''") + + def outputDir = Path.of(repo.absoluteLocalRepoTmpDir, subfolder).toFile() + outputDir.mkdirs() + + templater.template(this.getTemplateFile(), + this.getOutputFile(outputDir), + this.toTemplateParams()) + + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/RbacDefinition.groovy b/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/RbacDefinition.groovy index 10ab03fe6..395536d82 100644 --- a/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/RbacDefinition.groovy +++ b/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/RbacDefinition.groovy @@ -3,103 +3,99 @@ package com.cloudogu.gitops.kubernetes.rbac import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.utils.TemplatingEngine -import groovy.util.logging.Slf4j import java.nio.file.Path +import groovy.util.logging.Slf4j @Slf4j class RbacDefinition { - private final Role.Variant variant - private String name - private String namespace - private List serviceAccounts = [] - private String subfolder = "rbac" - private GitRepo repo - private Config config - - private final TemplatingEngine templater = new TemplatingEngine() - - RbacDefinition(Role.Variant variant) { - this.variant = variant - } - - RbacDefinition withName(String name) { - this.name = name - return this - } - - RbacDefinition withNamespace(String namespace) { - this.namespace = namespace - return this - } - - RbacDefinition withServiceAccounts(List accounts) { - this.serviceAccounts = accounts - return this - } - - RbacDefinition withServiceAccountsFrom(String saNamespace, List saNames) { - return withServiceAccounts(ServiceAccountRef.fromNames(saNamespace, saNames)) - } - - RbacDefinition withSubfolder(String subfolder) { - this.subfolder = subfolder - return this - } - - RbacDefinition withRepo(GitRepo repo) { - this.repo = repo - return this - } - - RbacDefinition withConfig(Config config) { - this.config = config - return this - } - - void generate() { - if (!repo) { - throw new IllegalStateException("SCMM repo must be set using withRepo() before calling generate()") - } - - log.trace("Generating RBAC for name='${name}', namespace='${namespace}', subfolder='${subfolder}'") - - File outputDir = Path.of(repo.absoluteLocalRepoTmpDir, subfolder).toFile() - outputDir.mkdirs() - - generateRole(outputDir) - - generateRoleBinding(outputDir) - } - - private void generateRole(File outputDir) { - if(variant == Role.Variant.CLUSTER_ADMIN) { - log.trace("Skipping creation of ClusterRole cluster-admin") - return - } - - def role = new Role(name, namespace, variant, config) - - templater.template( - role.getTemplateFile(), - role.getOutputFile(outputDir), - role.toTemplateParams() - ) - } - - private void generateRoleBinding(File outputDir) { - String roleName = name - if(variant == Role.Variant.CLUSTER_ADMIN) { - roleName = "cluster-admin" - } - def binding = new RoleBinding(name, namespace, roleName, serviceAccounts) - - templater.template( - binding.getTemplateFile(), - binding.getOutputFile(outputDir), - binding.toTemplateParams() - ) - } + private final Role.Variant variant + private String name + private String namespace + private List serviceAccounts = [] + private String subfolder = "rbac" + private GitRepo repo + private Config config + + private final TemplatingEngine templater = new TemplatingEngine() + + RbacDefinition(Role.Variant variant) { + this.variant = variant + } + + RbacDefinition withName(String name) { + this.name = name + return this + } + + RbacDefinition withNamespace(String namespace) { + this.namespace = namespace + return this + } + + RbacDefinition withServiceAccounts(List accounts) { + this.serviceAccounts = accounts + return this + } + + RbacDefinition withServiceAccountsFrom(String saNamespace, List saNames) { + return withServiceAccounts(ServiceAccountRef.fromNames(saNamespace, saNames)) + } + + RbacDefinition withSubfolder(String subfolder) { + this.subfolder = subfolder + return this + } + + RbacDefinition withRepo(GitRepo repo) { + this.repo = repo + return this + } + + RbacDefinition withConfig(Config config) { + this.config = config + return this + } + + void generate() { + if (!repo) { + throw new IllegalStateException("SCMM repo must be set using withRepo() before calling generate()") + } + + log.trace("Generating RBAC for name='${name}', namespace='${namespace}', subfolder='${subfolder}'") + + File outputDir = Path.of(repo.absoluteLocalRepoTmpDir, subfolder).toFile() + outputDir.mkdirs() + + generateRole(outputDir) + + generateRoleBinding(outputDir) + } + + private void generateRole(File outputDir) { + if (variant == Role.Variant.CLUSTER_ADMIN) { + log.trace("Skipping creation of ClusterRole cluster-admin") + return + } + + def role = new Role(name, namespace, variant, config) + + templater.template(role.getTemplateFile(), + role.getOutputFile(outputDir), + role.toTemplateParams()) + } + + private void generateRoleBinding(File outputDir) { + String roleName = name + if (variant == Role.Variant.CLUSTER_ADMIN) { + roleName = "cluster-admin" + } + def binding = new RoleBinding(name, namespace, roleName, serviceAccounts) + + templater.template(binding.getTemplateFile(), + binding.getOutputFile(outputDir), + binding.toTemplateParams()) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/Role.groovy b/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/Role.groovy index 9e31cf129..b81e22f65 100644 --- a/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/Role.groovy +++ b/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/Role.groovy @@ -3,54 +3,52 @@ package com.cloudogu.gitops.kubernetes.rbac import com.cloudogu.gitops.config.Config class Role { - String name - String namespace - Variant variant - Config config - - Role(String name, String namespace, Variant variant, Config config) { - if (!name?.trim()) throw new IllegalArgumentException("Role name must not be blank") - if (!namespace?.trim()) throw new IllegalArgumentException("Role namespace must not be blank") - if (!variant) throw new IllegalArgumentException("Role variant must not be null") - if (!config) throw new IllegalArgumentException("Config must not be null") - - this.name = name - this.namespace = namespace - this.variant = variant - this.config = config - } - - enum Variant { - ARGOCD("templates/kubernetes/rbac/argocd-role.ftl.yaml"), - CLUSTER_ADMIN("") - - final String templatePath - - Variant(String templatePath) { - this.templatePath = templatePath - } - } - - Map toTemplateParams() { - return [ - name : name, - namespace: namespace, - config : config - ] - } - - File getTemplateFile() { - if(variant == Variant.CLUSTER_ADMIN) { - throw new IllegalStateException("cluster-admin role shall not be created") - } - return new File(variant.getTemplatePath()) - } - - File getOutputFile(File outputDir) { - if(variant == Variant.CLUSTER_ADMIN) { - throw new IllegalStateException("cluster-admin role shall not be created") - } - String filename = "role-${name}-${namespace}.yaml" - return new File(outputDir, filename) - } + String name + String namespace + Variant variant + Config config + + Role(String name, String namespace, Variant variant, Config config) { + if (!name?.trim()) throw new IllegalArgumentException("Role name must not be blank") + if (!namespace?.trim()) throw new IllegalArgumentException("Role namespace must not be blank") + if (!variant) throw new IllegalArgumentException("Role variant must not be null") + if (!config) throw new IllegalArgumentException("Config must not be null") + + this.name = name + this.namespace = namespace + this.variant = variant + this.config = config + } + + enum Variant { + ARGOCD("templates/kubernetes/rbac/argocd-role.ftl.yaml"), + CLUSTER_ADMIN("") + + final String templatePath + + Variant(String templatePath) { + this.templatePath = templatePath + } + } + + Map toTemplateParams() { + return [name : name, + namespace: namespace, + config : config] + } + + File getTemplateFile() { + if (variant == Variant.CLUSTER_ADMIN) { + throw new IllegalStateException("cluster-admin role shall not be created") + } + return new File(variant.getTemplatePath()) + } + + File getOutputFile(File outputDir) { + if (variant == Variant.CLUSTER_ADMIN) { + throw new IllegalStateException("cluster-admin role shall not be created") + } + String filename = "role-${name}-${namespace}.yaml" + return new File(outputDir, filename) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/RoleBinding.groovy b/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/RoleBinding.groovy index 5b7cd36f9..8a01ab27d 100644 --- a/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/RoleBinding.groovy +++ b/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/RoleBinding.groovy @@ -1,53 +1,51 @@ package com.cloudogu.gitops.kubernetes.rbac class RoleBinding { - String name - String kind - String namespace - String roleName - String roleKind - List serviceAccounts - - RoleBinding(String name, String namespace, String roleName, List serviceAccounts) { - if (!name?.trim()) throw new IllegalArgumentException("RoleBinding name must not be blank") - if (!namespace?.trim()) throw new IllegalArgumentException("RoleBinding namespace must not be blank") - if (!roleName?.trim()) throw new IllegalArgumentException("Role name must not be blank") - if (!serviceAccounts || serviceAccounts.isEmpty()) throw new IllegalArgumentException("At least one service account is required") - - this.name = name - this.kind = "RoleBinding" - this.namespace = namespace - this.roleName = roleName - this.roleKind = "Role" - this.serviceAccounts = serviceAccounts - - if(roleName == "cluster-admin") { - this.kind = "ClusterRoleBinding" - this.roleKind = "ClusterRole" - } - } - - Map toTemplateParams() { - return [ - name : name, - kind : kind, - namespace : namespace, - roleName : roleName, - roleKind : roleKind, - serviceAccounts: serviceAccounts.collect { it.toMap() } - ] - } - - String getTemplatePath() { - return "templates/kubernetes/rbac/rolebinding.ftl.yaml" - } - - File getTemplateFile() { - return new File(getTemplatePath()) - } - - File getOutputFile(File outputDir) { - String filename = "rolebinding-${name}-${namespace}.yaml" - return new File(outputDir, filename) - } -} + String name + String kind + String namespace + String roleName + String roleKind + List serviceAccounts + + RoleBinding(String name, String namespace, String roleName, List serviceAccounts) { + if (!name?.trim()) throw new IllegalArgumentException("RoleBinding name must not be blank") + if (!namespace?.trim()) throw new IllegalArgumentException("RoleBinding namespace must not be blank") + if (!roleName?.trim()) throw new IllegalArgumentException("Role name must not be blank") + if (!serviceAccounts || serviceAccounts.isEmpty()) throw new IllegalArgumentException("At least one service account is required") + + this.name = name + this.kind = "RoleBinding" + this.namespace = namespace + this.roleName = roleName + this.roleKind = "Role" + this.serviceAccounts = serviceAccounts + + if (roleName == "cluster-admin") { + this.kind = "ClusterRoleBinding" + this.roleKind = "ClusterRole" + } + } + + Map toTemplateParams() { + return [name : name, + kind : kind, + namespace : namespace, + roleName : roleName, + roleKind : roleKind, + serviceAccounts: serviceAccounts.collect { it.toMap() }] + } + + String getTemplatePath() { + return "templates/kubernetes/rbac/rolebinding.ftl.yaml" + } + + File getTemplateFile() { + return new File(getTemplatePath()) + } + + File getOutputFile(File outputDir) { + String filename = "rolebinding-${name}-${namespace}.yaml" + return new File(outputDir, filename) + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/ServiceAccountRef.groovy b/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/ServiceAccountRef.groovy index 64ce2b9f8..6837abf7a 100644 --- a/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/ServiceAccountRef.groovy +++ b/src/main/groovy/com/cloudogu/gitops/kubernetes/rbac/ServiceAccountRef.groovy @@ -1,32 +1,32 @@ package com.cloudogu.gitops.kubernetes.rbac class ServiceAccountRef { - String name - String namespace + String name + String namespace - ServiceAccountRef(String name, String namespace) { - if (!name?.trim()) { - throw new IllegalArgumentException("ServiceAccount name must not be blank") - } - if (!namespace?.trim()) { - throw new IllegalArgumentException("ServiceAccount namespace must not be blank") - } - this.name = name - this.namespace = namespace - } + ServiceAccountRef(String name, String namespace) { + if (!name?.trim()) { + throw new IllegalArgumentException("ServiceAccount name must not be blank") + } + if (!namespace?.trim()) { + throw new IllegalArgumentException("ServiceAccount namespace must not be blank") + } + this.name = name + this.namespace = namespace + } - static List fromNames(String namespace, List names) { - if (!namespace?.trim()) { - throw new IllegalArgumentException("Namespace must not be blank for service accounts") - } + static List fromNames(String namespace, List names) { + if (!namespace?.trim()) { + throw new IllegalArgumentException("Namespace must not be blank for service accounts") + } - return names - .findAll { it?.trim() } - .unique() - .collect { new ServiceAccountRef(it, namespace) } - } + return names + .findAll { it?.trim() } + .unique() + .collect { new ServiceAccountRef(it, namespace) } + } - Map toMap() { - return [name: name, namespace: namespace] - } -} + Map toMap() { + return [name: name, namespace: namespace] + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/okhttp/RetryInterceptor.groovy b/src/main/groovy/com/cloudogu/gitops/okhttp/RetryInterceptor.groovy index 32124d1c1..a03208ae2 100644 --- a/src/main/groovy/com/cloudogu/gitops/okhttp/RetryInterceptor.groovy +++ b/src/main/groovy/com/cloudogu/gitops/okhttp/RetryInterceptor.groovy @@ -1,6 +1,7 @@ package com.cloudogu.gitops.okhttp import groovy.util.logging.Slf4j + import okhttp3.Interceptor import okhttp3.Response import org.jetbrains.annotations.NotNull @@ -8,72 +9,70 @@ import org.jetbrains.annotations.NotNull /** * Retries request on specific status codes as well as timeouts. * Both error codes (like temporary (!) 500 or 401/403) and timeouts occur often during our jenkins initialization, - * due to necessary restarts, e.g. after plugin installs. - */ + * due to necessary restarts, e.g. after plugin installs.*/ @Slf4j class RetryInterceptor implements Interceptor { - private int retries - private int waitPeriodInMs + private int retries + private int waitPeriodInMs - // Number of retries in uncommonly high, because we might have to outlive a unexpected Jenkins restart - RetryInterceptor(int retries = 180, int waitPeriodInMs = 2000) { - this.waitPeriodInMs = waitPeriodInMs - this.retries = retries - } + // Number of retries in uncommonly high, because we might have to outlive a unexpected Jenkins restart + RetryInterceptor(int retries = 180, int waitPeriodInMs = 2000) { + this.waitPeriodInMs = waitPeriodInMs + this.retries = retries + } - @Override - Response intercept(@NotNull Chain chain) throws IOException { - def i = 0 - Response response = null - IOException lastException = null + @Override + Response intercept(@NotNull Chain chain) throws IOException { + def i = 0 + Response response = null + IOException lastException = null - do { - try { - response = chain.proceed(chain.request()) + do { + try { + response = chain.proceed(chain.request()) - if (response.code() !in getStatusCodesToRetry()) { - // Success or non-retriable error - return the response - return response - } + if (response.code() !in getStatusCodesToRetry()) { + // Success or non-retriable error - return the response + return response + } - log.trace("Retry HTTP Request to {} due to status code {}", chain.request().url().toString(), response.code()) - response.close() + log.trace("Retry HTTP Request to {} due to status code {}", chain.request().url().toString(), response.code()) + response.close() - } catch (SocketTimeoutException e) { - lastException = e - log.trace("Retry HTTP Request to {} due to SocketTimeoutException: {}", chain.request().url().toString(), e.message) - } + } catch (SocketTimeoutException e) { + lastException = e + log.trace("Retry HTTP Request to {} due to SocketTimeoutException: {}", chain.request().url().toString(), e.message) + } - // Wait before next retry (but not after the last attempt) - if (i < retries) { - Thread.sleep(waitPeriodInMs) - } - ++i + // Wait before next retry (but not after the last attempt) + if (i < retries) { + Thread.sleep(waitPeriodInMs) + } + ++i - } while(i <= retries) + } while (i <= retries) - // If we got here, all retries failed - if (response != null) { - // Return the last failed response - return response - } else if (lastException != null) { - // All attempts resulted in timeout - throw the last exception - throw lastException - } else { - // This should never happen, but as a safety net - throw new IOException("Request failed after ${retries} retries") - } - } + // If we got here, all retries failed + if (response != null) { + // Return the last failed response + return response + } else if (lastException != null) { + // All attempts resulted in timeout - throw the last exception + throw lastException + } else { + // This should never happen, but as a safety net + throw new IOException("Request failed after ${retries} retries") + } + } - private List getStatusCodesToRetry() { - return [ - // list of codes from curl --retry - 408, // Request Timeout - 429, // Too Many Requests - 500, // Internal Server Error - 502, // Bad Gateway - 503, // Service Unavailable - 504, // Gateway Timeout - ] - } + private List getStatusCodesToRetry() { + return [// list of codes from curl --retry + 408, // Request Timeout + 429, // Too Many Requests + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout + ] + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/okhttp/ScmManagerAPI.groovy b/src/main/groovy/com/cloudogu/gitops/okhttp/ScmManagerAPI.groovy index 7d677ac7f..2a27563b1 100644 --- a/src/main/groovy/com/cloudogu/gitops/okhttp/ScmManagerAPI.groovy +++ b/src/main/groovy/com/cloudogu/gitops/okhttp/ScmManagerAPI.groovy @@ -1,4 +1,3 @@ package com.cloudogu.gitops.okhttp -class ScmManagerAPI { -} \ No newline at end of file +class ScmManagerAPI {} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/utils/AirGappedUtils.groovy b/src/main/groovy/com/cloudogu/gitops/utils/AirGappedUtils.groovy index 35cd12ee6..b26a83830 100644 --- a/src/main/groovy/com/cloudogu/gitops/utils/AirGappedUtils.groovy +++ b/src/main/groovy/com/cloudogu/gitops/utils/AirGappedUtils.groovy @@ -6,121 +6,118 @@ 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.HelmClient + +import java.nio.file.Path +import jakarta.inject.Singleton import groovy.util.logging.Slf4j import groovy.yaml.YamlSlurper -import jakarta.inject.Singleton -import java.nio.file.Path @Slf4j @Singleton class AirGappedUtils { - private Config config - private GitRepoFactory repoProvider - private FileSystemUtils fileSystemUtils - private HelmClient helmClient - private GitHandler gitHandler - - AirGappedUtils(Config config, GitRepoFactory repoProvider, - FileSystemUtils fileSystemUtils, HelmClient helmClient, GitHandler gitHandler) { - this.config = config - this.repoProvider = repoProvider - this.fileSystemUtils = fileSystemUtils - this.helmClient = helmClient - this.gitHandler = gitHandler - } - - /** - * In air-gapped mode, the chart's dependencies can't be resolved. - * As helm does not provide an option for changing them interactively, we push the charts into a separate repo. - * We alter these repos to resolve dependencies locally from SCM. - * - * @return the repo namespace and name - */ - String mirrorHelmRepoToGit(HelmConfig helmConfig) { - String repoName = helmConfig.chart - String namespace = GitRepo.NAMESPACE_3RD_PARTY_DEPENDENCIES - String repoNamespaceAndName = "${namespace}/${repoName}" - String localHelmChartFolder = "${config.application.localHelmChartFolder}/${repoName}" - - validateChart(repoNamespaceAndName, localHelmChartFolder, repoName) - - GitRepo repo = repoProvider.getRepo(repoNamespaceAndName, gitHandler.tenant) - - repo.createRepositoryAndSetPermission("Mirror of Helm chart $repoName from ${helmConfig.repoURL}", false) - - repo.cloneRepo() - - repo.copyDirectoryContents(localHelmChartFolder) - - def chartYaml = localizeChartYaml(repo) - - // Chart.lock contains pinned dependencies and digest. - // We either have to update or remove them. Take the easier approach. - new File(repo.absoluteLocalRepoTmpDir, 'Chart.lock').delete() - - repo.commitAndPush("Chart ${chartYaml.name}, version: ${chartYaml.version}\n\n" + - "Source: ${helmConfig.repoURL}\n" + - "Dependencies localized to run in air-gapped environments", chartYaml.version as String) - return repoNamespaceAndName - } - - private void validateChart(repoNamespaceAndName, String localHelmChartFolder, String repoName) { - log.debug("Validating helm chart before pushing it to SCM, by running helm template.\n" + - "Potential repo: ${repoNamespaceAndName}, chart folder: ${localHelmChartFolder}") - try { - helmClient.template(repoName, localHelmChartFolder) - } catch (RuntimeException e) { - throw new RuntimeException("Helm chart in folder ${localHelmChartFolder} seems invalid.", e) - } - } - - private Map localizeChartYaml(GitRepo gitRepo) { - log.debug("Preparing repo ${gitRepo.repoTarget} for air-gapped use: Changing Chart.yaml to resolve depencies locally") - - def chartYamlPath = Path.of(gitRepo.absoluteLocalRepoTmpDir, 'Chart.yaml') - - Map chartYaml = new YamlSlurper().parse(chartYamlPath) as Map - Map chartLock = parseChartLockIfExists(gitRepo) - - List dependencies = chartYaml.dependencies as List ?: [] - for (Map chartYamlDep : dependencies) { - resolveDependencyVersion(chartLock, chartYamlDep, gitRepo) - - // Remove link to external repo, to force using local one - chartYamlDep.repository = '' - } - fileSystemUtils.writeYaml(chartYaml, chartYamlPath.toFile()) - return chartYaml - } - - private static Map parseChartLockIfExists(GitRepo scmmRepo) { - def chartLock = Path.of(scmmRepo.absoluteLocalRepoTmpDir, 'Chart.lock') - if (!chartLock.toFile().exists()) { - return [:] - } - new YamlSlurper().parse(chartLock) as Map - } - - /** - * Resolve proper dependency version from Chart.lock, e.g. 5.18.* -> 5.18.1 - */ - private void resolveDependencyVersion(Map chartLock, Map chartYamlDep, GitRepo gitRepo) { - def chartLockDep = findByName(chartLock.dependencies as List, chartYamlDep.name as String) - if (chartLockDep) { - chartYamlDep.version = chartLockDep.version - } else if ((chartYamlDep.version as String).contains('*')) { - throw new RuntimeException("Unable to determine proper version for dependency " + - "${chartYamlDep.name} (version: ${chartYamlDep.version}) from repo ${gitRepo.repoTarget}") - } - } - - Map findByName(List list, String name) { - if (!list) return [:] - // Note that list.find{} does not work in GraalVM native image: - // UnsupportedFeatureError: Runtime reflection is not supported - list.stream() - .filter(map -> map.name == name) - .findFirst().orElse([:]) - } + private Config config + private GitRepoFactory repoProvider + private FileSystemUtils fileSystemUtils + private HelmClient helmClient + private GitHandler gitHandler + + AirGappedUtils(Config config, GitRepoFactory repoProvider, + FileSystemUtils fileSystemUtils, HelmClient helmClient, GitHandler gitHandler) { + this.config = config + this.repoProvider = repoProvider + this.fileSystemUtils = fileSystemUtils + this.helmClient = helmClient + this.gitHandler = gitHandler + } + + /** + * In air-gapped mode, the chart's dependencies can't be resolved. + * As helm does not provide an option for changing them interactively, we push the charts into a separate repo. + * We alter these repos to resolve dependencies locally from SCM. + * + * @return the repo namespace and name + */ + String mirrorHelmRepoToGit(HelmConfig helmConfig) { + String repoName = helmConfig.chart + String namespace = GitRepo.NAMESPACE_3RD_PARTY_DEPENDENCIES + String repoNamespaceAndName = "${namespace}/${repoName}" + String localHelmChartFolder = "${config.application.localHelmChartFolder}/${repoName}" + + validateChart(repoNamespaceAndName, localHelmChartFolder, repoName) + + GitRepo repo = repoProvider.getRepo(repoNamespaceAndName, gitHandler.tenant) + + repo.createRepositoryAndSetPermission("Mirror of Helm chart $repoName from ${helmConfig.repoURL}", false) + + repo.cloneRepo() + + repo.copyDirectoryContents(localHelmChartFolder) + + def chartYaml = localizeChartYaml(repo) + + // Chart.lock contains pinned dependencies and digest. + // We either have to update or remove them. Take the easier approach. + new File(repo.absoluteLocalRepoTmpDir, 'Chart.lock').delete() + + repo.commitAndPush("Chart ${chartYaml.name}, version: ${chartYaml.version}\n\n" + "Source: ${helmConfig.repoURL}\n" + + "Dependencies localized to run in air-gapped environments", chartYaml.version as String) + return repoNamespaceAndName + } + + private void validateChart(repoNamespaceAndName, String localHelmChartFolder, String repoName) { + log.debug("Validating helm chart before pushing it to SCM, by running helm template.\n" + "Potential repo: ${repoNamespaceAndName}, chart folder: ${localHelmChartFolder}") + try { + helmClient.template(repoName, localHelmChartFolder) + } catch (RuntimeException e) { + throw new RuntimeException("Helm chart in folder ${localHelmChartFolder} seems invalid.", e) + } + } + + private Map localizeChartYaml(GitRepo gitRepo) { + log.debug("Preparing repo ${gitRepo.repoTarget} for air-gapped use: Changing Chart.yaml to resolve depencies locally") + + def chartYamlPath = Path.of(gitRepo.absoluteLocalRepoTmpDir, 'Chart.yaml') + + Map chartYaml = new YamlSlurper().parse(chartYamlPath) as Map + Map chartLock = parseChartLockIfExists(gitRepo) + + List dependencies = chartYaml.dependencies as List ?: [] + for (Map chartYamlDep : dependencies) { + resolveDependencyVersion(chartLock, chartYamlDep, gitRepo) + + // Remove link to external repo, to force using local one + chartYamlDep.repository = '' + } + fileSystemUtils.writeYaml(chartYaml, chartYamlPath.toFile()) + return chartYaml + } + + private static Map parseChartLockIfExists(GitRepo scmmRepo) { + def chartLock = Path.of(scmmRepo.absoluteLocalRepoTmpDir, 'Chart.lock') + if (!chartLock.toFile().exists()) { + return [:] + } + new YamlSlurper().parse(chartLock) as Map + } + + /** + * Resolve proper dependency version from Chart.lock, e.g. 5.18.* -> 5.18.1*/ + private void resolveDependencyVersion(Map chartLock, Map chartYamlDep, GitRepo gitRepo) { + def chartLockDep = findByName(chartLock.dependencies as List, chartYamlDep.name as String) + if (chartLockDep) { + chartYamlDep.version = chartLockDep.version + } else if ((chartYamlDep.version as String).contains('*')) { + throw new RuntimeException("Unable to determine proper version for dependency " + "${chartYamlDep.name} (version: ${chartYamlDep.version}) from repo ${gitRepo.repoTarget}") + } + } + + Map findByName(List list, String name) { + if (!list) return [:] + // Note that list.find{} does not work in GraalVM native image: + // UnsupportedFeatureError: Runtime reflection is not supported + list.stream() + .filter(map -> map.name == name) + .findFirst().orElse([:]) + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/utils/AllowListFreemarkerObjectWrapper.groovy b/src/main/groovy/com/cloudogu/gitops/utils/AllowListFreemarkerObjectWrapper.groovy index 4ed0d5fcf..2d354389a 100644 --- a/src/main/groovy/com/cloudogu/gitops/utils/AllowListFreemarkerObjectWrapper.groovy +++ b/src/main/groovy/com/cloudogu/gitops/utils/AllowListFreemarkerObjectWrapper.groovy @@ -4,30 +4,30 @@ import freemarker.template.* class AllowListFreemarkerObjectWrapper extends DefaultObjectWrapper { - Set allowlist + Set allowlist - AllowListFreemarkerObjectWrapper(Version freemarkerVersion, Set allowlist) { - super(freemarkerVersion) - this.allowlist = allowlist - } + AllowListFreemarkerObjectWrapper(Version freemarkerVersion, Set allowlist) { + super(freemarkerVersion) + this.allowlist = allowlist + } - TemplateHashModel getStaticModels() { - final TemplateHashModel originalStaticModels = super.getStaticModels() - final Set allowlistCopy = this.allowlist + TemplateHashModel getStaticModels() { + final TemplateHashModel originalStaticModels = super.getStaticModels() + final Set allowlistCopy = this.allowlist - return new TemplateHashModel() { - @Override - TemplateModel get(String key) throws TemplateModelException { - if (allowlistCopy.contains(key)) { - return originalStaticModels.get(key) - } - return null - } + return new TemplateHashModel() { + @Override + TemplateModel get(String key) throws TemplateModelException { + if (allowlistCopy.contains(key)) { + return originalStaticModels.get(key) + } + return null + } - @Override - boolean isEmpty() throws TemplateModelException { - return allowlistCopy.isEmpty() - } - } - } + @Override + boolean isEmpty() throws TemplateModelException { + return allowlistCopy.isEmpty() + } + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/utils/CommandExecutor.groovy b/src/main/groovy/com/cloudogu/gitops/utils/CommandExecutor.groovy index 5eed1b4ed..5db864f51 100644 --- a/src/main/groovy/com/cloudogu/gitops/utils/CommandExecutor.groovy +++ b/src/main/groovy/com/cloudogu/gitops/utils/CommandExecutor.groovy @@ -1,140 +1,139 @@ package com.cloudogu.gitops.utils -import groovy.util.logging.Slf4j +import java.util.concurrent.TimeUnit import jakarta.inject.Singleton -import org.apache.commons.io.output.TeeOutputStream +import groovy.util.logging.Slf4j -import java.util.concurrent.TimeUnit +import org.apache.commons.io.output.TeeOutputStream @Slf4j @Singleton class CommandExecutor { - /* This timeout is mainly here to not freeze forever the apply process in the worst case scenario. - - Calls to init-scmm.sh and init-jenkins.sh take several minutes at best and might be slower with poor connections - to the internet. - Once they are migrated to groovy we can reduce this timeout.*/ - public static final int PROCESS_TIMEOUT_MINUTES = 15 - - Output execute(String[] command, boolean failOnError = true) { - Process proc = doExecute(command) - return getOutput(proc, command.join(" "), failOnError) - } - - /** - * Please prefer using {@link #execute(java.lang.String[], boolean)}, because - * it avoids quoting issues when passing arguments containing whitespaces. - */ - @Deprecated - Output execute(String command, boolean failOnError = true) { - Process proc = doExecute(command) - return getOutput(proc, command, failOnError) - } - - /** - * @param envp a List of Objects (converted to Strings using toString), each member of which has environment - * variable settings in the format name=value, or null if the subprocess should inherit - * the environment of the current process. - */ - Output execute(String command, Map additionalEnv, boolean failOnError = true) { - Map newEnv = [:] - newEnv.putAll(System.getenv()) // Copy existing environment variables - newEnv.putAll(additionalEnv) - - Process proc = doExecute(command, newEnv.collect { key, value -> "${key}=${value}" }) - return getOutput(proc, command, failOnError) - } - - Output execute(String[] command1, String[] command2, boolean failOnError = true) { - String pipedCommand = "${command1.join(' ')} | ${command2.join(' ')}" - def process1 = doExecute(command1) - def process2 = doExecute(command2) - - def finalOutput = getOutput(process1.pipeTo(process2), pipedCommand, false) - // Proc1 should have finished when proc2 has. - // Still, there is the occasional "IllegalThreadStateException: process hasn't exited"... concurrency 🤷 - // Avoid the exceptions, by explicitly waiting for the process to end - waitForOrKill(process1, command1.join(' ')) - - if (process1.exitValue() > 0) { - log.error("Pipefail! First process of command failed ${pipedCommand}.") - log.error("Stderr: ${process1.err.text.trim()}") - } - if (process2.exitValue() > 0) { - log.error("Executing command failed: ${pipedCommand}") - log.error("Stderr: ${finalOutput.stdErr}") - log.error("StdOut: ${finalOutput.stdOut}") - } - - boolean success = process1.exitValue() == 0 && process2.exitValue() == 0 - if (!success && failOnError) { - throw new RuntimeException("Executing command failed: ${pipedCommand}") - } - - return finalOutput - } - - protected Process doExecute(String command, List envp = null) { - log.trace("Executing command: '${command}'") - command.execute(envp, null) - } - - protected Process doExecute(String[] command) { - log.trace("Executing command: '${command}'") - command.execute() - } - - protected Output getOutput(Process proc, String command, boolean failOnError = true) { - ByteArrayOutputStream stdOut = new ByteArrayOutputStream() - ByteArrayOutputStream stdErr = new ByteArrayOutputStream() - TeeOutputStream teeOut, teeErr - - if (log.isTraceEnabled()) { - // While waiting for the process to finish, also print stdout and stderr streams through to the main process - teeOut = new TeeOutputStream(stdOut, System.out) - teeErr = new TeeOutputStream(stdErr, System.err) - proc.consumeProcessOutput(teeOut, teeErr) - } else { - proc.consumeProcessOutput(stdOut, stdErr) - } - - waitForOrKill(proc, command) - - // Make sure all bytes have been written, before returning output - if (teeOut) teeOut.flush() - if (teeErr) teeErr.flush() - def output = new Output(stdErr.toString().trim(), stdOut.toString().trim(), proc.exitValue()) - - if (failOnError && proc.exitValue() > 0) { - log.error("Executing command failed: ${command}") - log.error("Stderr: ${output.stdErr}") - log.error("StdOut: ${output.stdOut}") - if (failOnError) { - throw new RuntimeException("Executing command failed: ${command}") - } - } - - return output - } - - protected void waitForOrKill(Process proc, String command) { - def processFinished = proc.waitFor(PROCESS_TIMEOUT_MINUTES, TimeUnit.MINUTES) - if (!processFinished) { - log.error("Timeout waiting for command ${command}. Killing process.") - proc.waitForOrKill(1) - } - } - - static class Output { - String stdErr - String stdOut - int exitCode - - Output(String stdErr, String stdOut, int exitCode) { - this.stdErr = stdErr - this.stdOut = stdOut - this.exitCode = exitCode - } - } -} + /* This timeout is mainly here to not freeze forever the apply process in the worst case scenario. + + Calls to init-scmm.sh and init-jenkins.sh take several minutes at best and might be slower with poor connections + to the internet. + Once they are migrated to groovy we can reduce this timeout.*/ + public static final int PROCESS_TIMEOUT_MINUTES = 15 + + Output execute(String[] command, boolean failOnError = true) { + Process proc = doExecute(command) + return getOutput(proc, command.join(" "), failOnError) + } + + /** + * Please prefer using {@link #execute(java.lang.String [ ], boolean)}, because + * it avoids quoting issues when passing arguments containing whitespaces.*/ + @Deprecated + Output execute(String command, boolean failOnError = true) { + Process proc = doExecute(command) + return getOutput(proc, command, failOnError) + } + + /** + * @param envp a List of Objects (converted to Strings using toString), each member of which has environment + * variable settings in the format name=value, or null if the subprocess should inherit + * the environment of the current process. + */ + Output execute(String command, Map additionalEnv, boolean failOnError = true) { + Map newEnv = [:] + newEnv.putAll(System.getenv()) // Copy existing environment variables + newEnv.putAll(additionalEnv) + + Process proc = doExecute(command, newEnv.collect { key, value -> "${key}=${value}" }) + return getOutput(proc, command, failOnError) + } + + Output execute(String[] command1, String[] command2, boolean failOnError = true) { + String pipedCommand = "${command1.join(' ')} | ${command2.join(' ')}" + def process1 = doExecute(command1) + def process2 = doExecute(command2) + + def finalOutput = getOutput(process1.pipeTo(process2), pipedCommand, false) + // Proc1 should have finished when proc2 has. + // Still, there is the occasional "IllegalThreadStateException: process hasn't exited"... concurrency 🤷 + // Avoid the exceptions, by explicitly waiting for the process to end + waitForOrKill(process1, command1.join(' ')) + + if (process1.exitValue() > 0) { + log.error("Pipefail! First process of command failed ${pipedCommand}.") + log.error("Stderr: ${process1.err.text.trim()}") + } + if (process2.exitValue() > 0) { + log.error("Executing command failed: ${pipedCommand}") + log.error("Stderr: ${finalOutput.stdErr}") + log.error("StdOut: ${finalOutput.stdOut}") + } + + boolean success = process1.exitValue() == 0 && process2.exitValue() == 0 + if (!success && failOnError) { + throw new RuntimeException("Executing command failed: ${pipedCommand}") + } + + return finalOutput + } + + protected Process doExecute(String command, List envp = null) { + log.trace("Executing command: '${command}'") + command.execute(envp, null) + } + + protected Process doExecute(String[] command) { + log.trace("Executing command: '${command}'") + command.execute() + } + + protected Output getOutput(Process proc, String command, boolean failOnError = true) { + ByteArrayOutputStream stdOut = new ByteArrayOutputStream() + ByteArrayOutputStream stdErr = new ByteArrayOutputStream() + TeeOutputStream teeOut, teeErr + + if (log.isTraceEnabled()) { + // While waiting for the process to finish, also print stdout and stderr streams through to the main process + teeOut = new TeeOutputStream(stdOut, System.out) + teeErr = new TeeOutputStream(stdErr, System.err) + proc.consumeProcessOutput(teeOut, teeErr) + } else { + proc.consumeProcessOutput(stdOut, stdErr) + } + + waitForOrKill(proc, command) + + // Make sure all bytes have been written, before returning output + if (teeOut) teeOut.flush() + if (teeErr) teeErr.flush() + def output = new Output(stdErr.toString().trim(), stdOut.toString().trim(), proc.exitValue()) + + if (failOnError && proc.exitValue() > 0) { + log.error("Executing command failed: ${command}") + log.error("Stderr: ${output.stdErr}") + log.error("StdOut: ${output.stdOut}") + if (failOnError) { + throw new RuntimeException("Executing command failed: ${command}") + } + } + + return output + } + + protected void waitForOrKill(Process proc, String command) { + def processFinished = proc.waitFor(PROCESS_TIMEOUT_MINUTES, TimeUnit.MINUTES) + if (!processFinished) { + log.error("Timeout waiting for command ${command}. Killing process.") + proc.waitForOrKill(1) + } + } + + static class Output { + String stdErr + String stdOut + int exitCode + + Output(String stdErr, String stdOut, int exitCode) { + this.stdErr = stdErr + this.stdOut = stdOut + this.exitCode = exitCode + } + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/utils/DockerImageParser.groovy b/src/main/groovy/com/cloudogu/gitops/utils/DockerImageParser.groovy index d9caacfd6..ce0084a8b 100644 --- a/src/main/groovy/com/cloudogu/gitops/utils/DockerImageParser.groovy +++ b/src/main/groovy/com/cloudogu/gitops/utils/DockerImageParser.groovy @@ -1,70 +1,70 @@ package com.cloudogu.gitops.utils class DockerImageParser { - static class Image { - public String registry - public String repository - public String tag + static class Image { + public String registry + public String repository + public String tag - Image(String registry, String repository, String tag) { - this.registry = registry - this.repository = repository - this.tag = tag - } + Image(String registry, String repository, String tag) { + this.registry = registry + this.repository = repository + this.tag = tag + } - String getRegistryAndRepositoryAsString() { - if (registry === "") { - return repository - } + String getRegistryAndRepositoryAsString() { + if (registry === "") { + return repository + } - return "$registry/$repository" - } + return "$registry/$repository" + } - String getRegistry() { - return registry - } + String getRegistry() { + return registry + } - String getRepository() { - return repository - } + String getRepository() { + return repository + } - String getTag() { - return tag - } + String getTag() { + return tag + } - @Override - String toString() { - return getRegistryAndRepositoryAsString() + ":$tag" - } - } + @Override + String toString() { + return getRegistryAndRepositoryAsString() + ":$tag" + } + } - static Image parse(String image) { - if (!image.contains(":")) { - // Most helm charts expect an explicit image tag, otherwise they use the version set by the app. - // This will likely be unexpected so force using a tag - throw new RuntimeException("Cannot set image '$image' due to missing tag. Must be the format '\$repository:\$tag'") - } + static Image parse(String image) { + if (!image.contains(":")) { + // Most helm charts expect an explicit image tag, otherwise they use the version set by the app. + // This will likely be unexpected so force using a tag + throw new RuntimeException("Cannot set image '$image' due to missing tag. Must be the format '\$repository:\$tag'") + } - // docker.io / foo/bar : latest - // ^ registry ^ repository ^ tag - // ^ ------------- image ----------------- - def tuple = splitTag(image) - def imageWithoutTag = tuple.v1 - def tag = tuple.v2 + // docker.io / foo/bar : latest + // ^ registry ^ repository ^ tag + // ^ ------------- image ----------------- + def tuple = splitTag(image) + def imageWithoutTag = tuple.v1 + def tag = tuple.v2 - def parts = imageWithoutTag.split("/") - def repository = parts.takeRight(2).join("/") - parts = parts.dropRight(2) - def registry = parts.join("/") + def parts = imageWithoutTag.split("/") + def repository = parts.takeRight(2).join("/") + parts = parts.dropRight(2) + def registry = parts.join("/") - return new Image(registry, repository, tag) - } + return new Image(registry, repository, tag) + } - private static Tuple2 splitTag(String image) { - String[] imageParts = image.split(":") - String tag = imageParts.last() - def imageWithoutTag = imageParts.dropRight(1).join(":") + private static Tuple2 splitTag(String image) { + String[] imageParts = image.split(":") + String tag = imageParts.last() + def imageWithoutTag = imageParts.dropRight(1).join(":") - return new Tuple2(imageWithoutTag, tag) - } -} + return new Tuple2(imageWithoutTag, tag) + } +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/utils/FileSystemUtils.groovy b/src/main/groovy/com/cloudogu/gitops/utils/FileSystemUtils.groovy index 3ae61244a..c8a282ec3 100644 --- a/src/main/groovy/com/cloudogu/gitops/utils/FileSystemUtils.groovy +++ b/src/main/groovy/com/cloudogu/gitops/utils/FileSystemUtils.groovy @@ -1,302 +1,295 @@ //file:noinspection GrMethodMayBeStatic - it's not static to be able to hook in for testing package com.cloudogu.gitops.utils +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.regex.Pattern +import jakarta.inject.Singleton import groovy.io.FileType import groovy.util.logging.Slf4j import groovy.yaml.YamlBuilder import groovy.yaml.YamlSlurper -import jakarta.inject.Singleton -import org.apache.commons.io.FileUtils -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.StandardCopyOption -import java.util.regex.Pattern +import org.apache.commons.io.FileUtils @Slf4j @Singleton class FileSystemUtils { - /** - * Replaces text in files. If you want to change a YAML field, better use - * {@link #readYaml(java.nio.file.Path)} and - * {@link #writeYaml(java.util.Map, java.io.File)} - */ - File replaceFileContent(String folder, String fileToChange, String from, String to) { - File file = new File(folder + "/" + fileToChange) - String newConfig = file.text.replace(from, to) - file.setText(newConfig) - return file - } - - String replaceFileContent(String fileToChange, String from, String to) { - File file = new File(fileToChange) - String newConfig = file.text.replaceAll(from, to) - file.setText(newConfig) - return file - } - - String getSubstringOfFile(String fileLocation, CharSequence pattern, int from, int to) { - File file = new File(fileLocation) - String found = "" - file.readLines().forEach(line -> { - if (line.contains(pattern)) { - found = line.substring(from, to) - } - }) - return found - } - - String getSubstringOfFile(String fileLocation, CharSequence pattern, int from) { - File file = new File(fileLocation) - String found = "" - file.readLines().forEach(line -> { - if (line.contains(pattern)) { - found = line.substring(from) - } - }) - return found - } - - String getLineFromFile(String fileLocation, CharSequence pattern) { - File file = new File(fileLocation) - String found = "" - String fileText = file.getText() - String[] lines = fileText.split("\n") - for (int i = 0; i < lines.size(); i++) { - if (lines[i].contains(pattern)) { - found = lines[i] - } - } - return found - } - - List getAllLinesFromFile(String fileLocation, CharSequence pattern) { - File file = new File(fileLocation) - List foundLines = new ArrayList<>() - file.readLines().forEach(line -> { - if (line.contains(pattern)) { - foundLines.add(line) - } - }) - return foundLines - } - - static void deleteFile(String path) { - boolean successfullyDeleted = new File(path).delete() - if (!successfullyDeleted) { - log.warn("Faild to delete file ${path}") - } - } - - static void deleteDir(String path) { - boolean successfullyDeleted = new File(path).deleteDir() - if (!successfullyDeleted) { - log.warn("Faild to delete dir ${path}") - } - } - - String goBackToDir(String filePath, String directory) { - return filePath.substring(0, filePath.indexOf(directory) + directory.length()) - } - - String getRootDir() { - return System.getProperty("user.dir") - } - - List getAllFilesFromDirectoryWithEnding(String directory, String ending) { - List foundFiles = new ArrayList<>() - new File(directory).eachFileRecurse(FileType.FILES) { - if (it.name.endsWith(ending)) { - foundFiles.add(it) - } - } - return foundFiles - } - - void listDirectories(String parentDir) { - List list = [] - - File dir = new File(parentDir) - dir.eachFileRecurse(FileType.FILES) { file -> - list << file - } - list.each { - println it.path - } - } - - static void makeWritable(File directory) { - if (!directory.exists()) { - return - } - directory.eachFileRecurse { file -> - if (!file.canWrite()) { - file.setWritable(true) - } - } - } - - void copyDirectory(String source, String destination) { - copyDirectory(source, destination, null) - } - - void copyDirectory(String source, String destination, FileFilter fileFilter) { - - log.debug("Copying directory " + source + " to " + destination) - File sourceDir = new File(source) - File destinationDir = new File(destination) - - try { - FileUtils.copyDirectory(sourceDir, destinationDir, fileFilter) - } catch (IOException e) { - log.error("An error occured while copying directories: ", e) - } - } - - void copyFile(String sourcePath, String destinationPath) { - File sourceFile = new File(sourcePath) - File destinationFile = new File(destinationPath) - - log.debug("Copying file from ${sourcePath} to ${destinationPath}") - - try { - File parentDir = destinationFile.getParentFile() - if (!parentDir.exists()) { - log.debug("Creating missing destination directories: ${parentDir}") - parentDir.mkdirs() - } - - FileUtils.copyFile(sourceFile, destinationFile) - log.debug("File copy completed successfully.") - } catch (IOException e) { - log.error("An error occurred while copying the file: ", e) - } - } - - - void createDirectory(String directory) { - log.trace("Creating folder: " + directory) - new File(directory).mkdirs() - } - - Path copyToTempDir(String filePath) { - def sourcePath = Path.of(filePath) - def destDir = File.createTempDir("gitops-playground-").toPath() - def destPath = destDir.resolve(sourcePath.fileName) - return Files.copy(sourcePath, destPath) - } - - void deleteEmptyFiles(Path path, Pattern pathPattern) { - Files.walk(path).filter { it.size() == 0 && it.toString() =~ pathPattern }.each { Path it -> - log.trace("Deleting empty file $it") - it.toFile().delete() - } - } - - Path createTempDir() { - File.createTempDir("gitops-playground-").toPath() - } - - - Path createTempFile() { - def file = File.createTempFile("gitops-playground-", '') - file.deleteOnExit() - - return file.toPath() - } - - Map readYaml(Path path) { - def ys = new YamlSlurper() - return (ys.parse path) as Map - } - - Path writeTempFile(Map mapValues) { - def tmpHelmValues = createTempFile() - writeYaml(mapValues, tmpHelmValues.toFile()) - return tmpHelmValues - } - - // Note that YAML builder seems to use double quotes to escape strings. So for example: - // This: log-format-upstream: '..."$request"...' - // Becomes: log-format-upstream: "...\"$request\"..." - // Harder to read but same payload. Not sure if we can do something about it. - void writeYaml(Map yaml, File file) { - def builder = new YamlBuilder() - builder yaml - file.setText(builder.toString()) - } - - void deleteFilesExcept(File parentPath, String... fileOrFolderNamesToKeep) { - for (File file : parentPath.listFiles()) { - if (file.name in fileOrFolderNamesToKeep) { - continue - } - if (!file.isDirectory()) { - file.delete() - } else { - file.deleteDir() - } - } - } - - /** - * Moves all direct children of sourceDir into an existing targetDir. - * Conflicts are overwritten. - * Directories are merged recursively. - */ - void moveDirectoryMergeOverwrite(Path sourceDir, Path targetDir) { - if (!Files.exists(targetDir)) { - Files.createDirectories(targetDir.parent) - // fast path: try moving the whole directory - try { - Files.move(sourceDir, targetDir) - return - } catch (IOException ignored) { - // fallback to merge logic - Files.createDirectories(targetDir) - } - } else if (!Files.isDirectory(targetDir)) { - // target exists as file -> overwrite it with directory - Files.delete(targetDir) - Files.createDirectories(targetDir) - } - - Files.list(sourceDir).forEach { Path child -> - Path dest = targetDir.resolve(child.fileName.toString()) - if (Files.isDirectory(child)) { - moveDirectoryMergeOverwrite(child, dest) - } else { - moveFileOverwrite(child, dest) - } - } - - // remove empty source dir - try { - Files.deleteIfExists(sourceDir) - } catch (IOException ignored) {} - } - - private void moveFileOverwrite(Path sourceFile, Path targetFile) { - Files.createDirectories(targetFile.parent) - - try { - Files.move(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING) - } catch (IOException moveFailed) { - // cross-device fallback - Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING) - Files.delete(sourceFile) - } - } - - - /** - * This filter can be used to copy whole directories without .git folder. - */ - static class IgnoreDotGitFolderFilter implements FileFilter { - @Override - boolean accept(File file) { - return !file.absolutePath.contains(File.separator + ".git") - } - } + /** + * Replaces text in files. If you want to change a YAML field, better use + * {@link #readYaml(java.nio.file.Path)} and + * {@link #writeYaml(java.util.Map, java.io.File)} */ + File replaceFileContent(String folder, String fileToChange, String from, String to) { + File file = new File(folder + "/" + fileToChange) + String newConfig = file.text.replace(from, to) + file.setText(newConfig) + return file + } + + String replaceFileContent(String fileToChange, String from, String to) { + File file = new File(fileToChange) + String newConfig = file.text.replaceAll(from, to) + file.setText(newConfig) + return file + } + + String getSubstringOfFile(String fileLocation, CharSequence pattern, int from, int to) { + File file = new File(fileLocation) + String found = "" + file.readLines().forEach(line -> { + if (line.contains(pattern)) { + found = line.substring(from, to) + } + }) + return found + } + + String getSubstringOfFile(String fileLocation, CharSequence pattern, int from) { + File file = new File(fileLocation) + String found = "" + file.readLines().forEach(line -> { + if (line.contains(pattern)) { + found = line.substring(from) + } + }) + return found + } + + String getLineFromFile(String fileLocation, CharSequence pattern) { + File file = new File(fileLocation) + String found = "" + String fileText = file.getText() + String[] lines = fileText.split("\n") + for (int i = 0; i < lines.size(); i++) { + if (lines[i].contains(pattern)) { + found = lines[i] + } + } + return found + } + + List getAllLinesFromFile(String fileLocation, CharSequence pattern) { + File file = new File(fileLocation) + List foundLines = new ArrayList<>() + file.readLines().forEach(line -> { + if (line.contains(pattern)) { + foundLines.add(line) + } + }) + return foundLines + } + + static void deleteFile(String path) { + boolean successfullyDeleted = new File(path).delete() + if (!successfullyDeleted) { + log.warn("Faild to delete file ${path}") + } + } + + static void deleteDir(String path) { + boolean successfullyDeleted = new File(path).deleteDir() + if (!successfullyDeleted) { + log.warn("Faild to delete dir ${path}") + } + } + + String goBackToDir(String filePath, String directory) { + return filePath.substring(0, filePath.indexOf(directory) + directory.length()) + } + + String getRootDir() { + return System.getProperty("user.dir") + } + + List getAllFilesFromDirectoryWithEnding(String directory, String ending) { + List foundFiles = new ArrayList<>() + new File(directory).eachFileRecurse(FileType.FILES) { + if (it.name.endsWith(ending)) { + foundFiles.add(it) + } + } + return foundFiles + } + + void listDirectories(String parentDir) { + List list = [] + + File dir = new File(parentDir) + dir.eachFileRecurse(FileType.FILES) { file -> list << file + } + list.each { + println it.path + } + } + + static void makeWritable(File directory) { + if (!directory.exists()) { + return + } + directory.eachFileRecurse { file -> + if (!file.canWrite()) { + file.setWritable(true) + } + } + } + + void copyDirectory(String source, String destination) { + copyDirectory(source, destination, null) + } + + void copyDirectory(String source, String destination, FileFilter fileFilter) { + + log.debug("Copying directory " + source + " to " + destination) + File sourceDir = new File(source) + File destinationDir = new File(destination) + + try { + FileUtils.copyDirectory(sourceDir, destinationDir, fileFilter) + } catch (IOException e) { + log.error("An error occured while copying directories: ", e) + } + } + + void copyFile(String sourcePath, String destinationPath) { + File sourceFile = new File(sourcePath) + File destinationFile = new File(destinationPath) + + log.debug("Copying file from ${sourcePath} to ${destinationPath}") + + try { + File parentDir = destinationFile.getParentFile() + if (!parentDir.exists()) { + log.debug("Creating missing destination directories: ${parentDir}") + parentDir.mkdirs() + } + + FileUtils.copyFile(sourceFile, destinationFile) + log.debug("File copy completed successfully.") + } catch (IOException e) { + log.error("An error occurred while copying the file: ", e) + } + } + + void createDirectory(String directory) { + log.trace("Creating folder: " + directory) + new File(directory).mkdirs() + } + + Path copyToTempDir(String filePath) { + def sourcePath = Path.of(filePath) + def destDir = File.createTempDir("gitops-playground-").toPath() + def destPath = destDir.resolve(sourcePath.fileName) + return Files.copy(sourcePath, destPath) + } + + void deleteEmptyFiles(Path path, Pattern pathPattern) { + Files.walk(path).filter { it.size() == 0 && it.toString() =~ pathPattern }.each { Path it -> + log.trace("Deleting empty file $it") + it.toFile().delete() + } + } + + Path createTempDir() { + File.createTempDir("gitops-playground-").toPath() + } + + Path createTempFile() { + def file = File.createTempFile("gitops-playground-", '') + file.deleteOnExit() + + return file.toPath() + } + + Map readYaml(Path path) { + def ys = new YamlSlurper() + return (ys.parse path) as Map + } + + Path writeTempFile(Map mapValues) { + def tmpHelmValues = createTempFile() + writeYaml(mapValues, tmpHelmValues.toFile()) + return tmpHelmValues + } + + // Note that YAML builder seems to use double quotes to escape strings. So for example: + // This: log-format-upstream: '..."$request"...' + // Becomes: log-format-upstream: "...\"$request\"..." + // Harder to read but same payload. Not sure if we can do something about it. + void writeYaml(Map yaml, File file) { + def builder = new YamlBuilder() + builder yaml + file.setText(builder.toString()) + } + + void deleteFilesExcept(File parentPath, String... fileOrFolderNamesToKeep) { + for (File file : parentPath.listFiles()) { + if (file.name in fileOrFolderNamesToKeep) { + continue + } + if (!file.isDirectory()) { + file.delete() + } else { + file.deleteDir() + } + } + } + + /** + * Moves all direct children of sourceDir into an existing targetDir. + * Conflicts are overwritten. + * Directories are merged recursively.*/ + void moveDirectoryMergeOverwrite(Path sourceDir, Path targetDir) { + if (!Files.exists(targetDir)) { + Files.createDirectories(targetDir.parent) + // fast path: try moving the whole directory + try { + Files.move(sourceDir, targetDir) + return + } catch (IOException ignored) { + // fallback to merge logic + Files.createDirectories(targetDir) + } + } else if (!Files.isDirectory(targetDir)) { + // target exists as file -> overwrite it with directory + Files.delete(targetDir) + Files.createDirectories(targetDir) + } + + Files.list(sourceDir).forEach { Path child -> + Path dest = targetDir.resolve(child.fileName.toString()) + if (Files.isDirectory(child)) { + moveDirectoryMergeOverwrite(child, dest) + } else { + moveFileOverwrite(child, dest) + } + } + + // remove empty source dir + try { + Files.deleteIfExists(sourceDir) + } catch (IOException ignored) {} + } + + private void moveFileOverwrite(Path sourceFile, Path targetFile) { + Files.createDirectories(targetFile.parent) + + try { + Files.move(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING) + } catch (IOException moveFailed) { + // cross-device fallback + Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING) + Files.delete(sourceFile) + } + } + + /** + * This filter can be used to copy whole directories without .git folder.*/ + static class IgnoreDotGitFolderFilter implements FileFilter { + @Override + boolean accept(File file) { + return !file.absolutePath.contains(File.separator + ".git") + } + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/utils/MapUtils.groovy b/src/main/groovy/com/cloudogu/gitops/utils/MapUtils.groovy index a3e6a835d..40fb4aa5c 100644 --- a/src/main/groovy/com/cloudogu/gitops/utils/MapUtils.groovy +++ b/src/main/groovy/com/cloudogu/gitops/utils/MapUtils.groovy @@ -1,21 +1,21 @@ -package com.cloudogu.gitops.utils +package com.cloudogu.gitops.utils class MapUtils { - static Map deepMerge(Map src, Map target) { - src.each { key, value -> - if (value == null) { - target[key] = null - return - } - def oldVal = target.containsKey(key) ? target[key] : null - if (oldVal instanceof Map && value instanceof Map) { - target[key] = deepMerge((Map) value, (Map) oldVal) - } else { - target[key] = value - } - } - return target - } + static Map deepMerge(Map src, Map target) { + src.each { key, value -> + if (value == null) { + target[key] = null + return + } + def oldVal = target.containsKey(key) ? target[key] : null + if (oldVal instanceof Map && value instanceof Map) { + target[key] = deepMerge((Map) value, (Map) oldVal) + } else { + target[key] = value + } + } + return target + } -} +} \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/utils/NetworkingUtils.groovy b/src/main/groovy/com/cloudogu/gitops/utils/NetworkingUtils.groovy index 9dd533748..5e02968d8 100644 --- a/src/main/groovy/com/cloudogu/gitops/utils/NetworkingUtils.groovy +++ b/src/main/groovy/com/cloudogu/gitops/utils/NetworkingUtils.groovy @@ -1,107 +1,103 @@ package com.cloudogu.gitops.utils import com.cloudogu.gitops.kubernetes.api.K8sClient -import groovy.util.logging.Slf4j + import jakarta.inject.Singleton +import groovy.util.logging.Slf4j @Slf4j @Singleton class NetworkingUtils { - private K8sClient k8sClient - private CommandExecutor commandExecutor - - NetworkingUtils(K8sClient k8sClient = new K8sClient(new CommandExecutor(), new FileSystemUtils(), null), - CommandExecutor commandExecutor = new CommandExecutor()) { - this.k8sClient = k8sClient - this.commandExecutor = commandExecutor - } - - String createUrl(String hostname, String port, String postfix = "") { - // argo forwards to HTTPS so symply us HTTP here - String url = "http://" + hostname + ":" + port + postfix - log.debug("Creating url: " + url) - return url - } - - String findClusterBindAddress() { - log.debug("Figuring out the address of the k8s cluster") - - String potentialClusterBindAddress = k8sClient.waitForInternalNodeIp() - potentialClusterBindAddress = potentialClusterBindAddress.replaceAll("'", "") - - String localAddress = localAddress - - log.debug("Local address: " + localAddress) - log.debug("Cluster address: " + potentialClusterBindAddress) - - if (!potentialClusterBindAddress) { - throw new RuntimeException("Could not connect to kubernetes cluster: no cluster bind address") - } - - if (localAddress == potentialClusterBindAddress) { - // This happens, when running on local cluster that runs in the host network. - // The reasons for introducing this might not be valid anymore: - // https://github.com/cloudogu/gitops-playground/commit/ea805d - // We no longer use jenkins notifications and have removed the address part from the welcome screen. - // So in the future, we might consider removing this and the whole localAdresse part to reduce complexity. - log.debug("Local address and cluster bind address are equal, so returning localhost") - return "localhost" - } else { - log.debug("Installing on external cluster, so returning cluster ip address") - return potentialClusterBindAddress - } - } - - /** - * Try to emulate the command "ip route get 1" by iterating the interfaces by index and returning first local address - */ - String getLocalAddress() { - try { - List sortedInterfaces = - Collections.list(NetworkInterface.getNetworkInterfaces()).sort { it.index } - - for (NetworkInterface anInterface : sortedInterfaces) { - for (InetAddress address : Collections.list(anInterface.inetAddresses)) { - if (!address.isLoopbackAddress() && address.isSiteLocalAddress()) { - return address.getHostAddress() - } - } - } - return '' - } catch (SocketException e) { - throw new RuntimeException("Could not determine local ip address", e) - } - } - - /** - * Legacy function with misleading name. Returns the part after the protocol of an URL. - * e.g. - * http://host:42/path returns host:42/path - * - * @return the part after http:///https://. Otherwise returns the input url. Works for urls without protocol, - * but not for outer protocols like ftp:// 😬 Good enough for here, but should be removed anyway. - */ - @Deprecated - static String getHost(String url) { - if (url.contains("https://")) - return url.substring(8) - if (url.contains("http://")) - return url.substring(7) - return url - } - - /** - * Extracts the protocol from an URL string. - * - * @return http or https. Defensively empty string in all other cases. - */ - @Deprecated - static String getProtocol(String url) { - if (url.contains("https://")) - return "https" - if (url.contains("http://")) - return "http" - return '' - } + private K8sClient k8sClient + private CommandExecutor commandExecutor + + NetworkingUtils(K8sClient k8sClient = new K8sClient(new CommandExecutor(), new FileSystemUtils(), null), + CommandExecutor commandExecutor = new CommandExecutor()) { + this.k8sClient = k8sClient + this.commandExecutor = commandExecutor + } + + String createUrl(String hostname, String port, String postfix = "") { + // argo forwards to HTTPS so symply us HTTP here + String url = "http://" + hostname + ":" + port + postfix + log.debug("Creating url: " + url) + return url + } + + String findClusterBindAddress() { + log.debug("Figuring out the address of the k8s cluster") + + String potentialClusterBindAddress = k8sClient.waitForInternalNodeIp() + potentialClusterBindAddress = potentialClusterBindAddress.replaceAll("'", "") + + String localAddress = localAddress + + log.debug("Local address: " + localAddress) + log.debug("Cluster address: " + potentialClusterBindAddress) + + if (!potentialClusterBindAddress) { + throw new RuntimeException("Could not connect to kubernetes cluster: no cluster bind address") + } + + if (localAddress == potentialClusterBindAddress) { + // This happens, when running on local cluster that runs in the host network. + // The reasons for introducing this might not be valid anymore: + // https://github.com/cloudogu/gitops-playground/commit/ea805d + // We no longer use jenkins notifications and have removed the address part from the welcome screen. + // So in the future, we might consider removing this and the whole localAdresse part to reduce complexity. + log.debug("Local address and cluster bind address are equal, so returning localhost") + return "localhost" + } else { + log.debug("Installing on external cluster, so returning cluster ip address") + return potentialClusterBindAddress + } + } + + /** + * Try to emulate the command "ip route get 1" by iterating the interfaces by index and returning first local address*/ + String getLocalAddress() { + try { + List sortedInterfaces = + Collections.list(NetworkInterface.getNetworkInterfaces()).sort { it.index } + + for (NetworkInterface anInterface : sortedInterfaces) { + for (InetAddress address : Collections.list(anInterface.inetAddresses)) { + if (!address.isLoopbackAddress() && address.isSiteLocalAddress()) { + return address.getHostAddress() + } + } + } + return '' + } catch (SocketException e) { + throw new RuntimeException("Could not determine local ip address", e) + } + } + + /** + * Legacy function with misleading name. Returns the part after the protocol of an URL. + * e.g. + * http://host:42/path returns host:42/path + * + * @return the part after http:///https://. Otherwise returns the input url. Works for urls without protocol, + * but not for outer protocols like ftp:// 😬 Good enough for here, but should be removed anyway. + */ + @Deprecated + static String getHost(String url) { + if (url.contains("https://")) return url.substring(8) + if (url.contains("http://")) return url.substring(7) + return url + } + + /** + * Extracts the protocol from an URL string. + * + * @return http or https. Defensively empty string in all other cases. + */ + @Deprecated + static String getProtocol(String url) { + if (url.contains("https://")) return "https" + if (url.contains("http://")) return "http" + return '' + } } \ No newline at end of file diff --git a/src/main/groovy/com/cloudogu/gitops/utils/TemplatingEngine.groovy b/src/main/groovy/com/cloudogu/gitops/utils/TemplatingEngine.groovy index 381904553..da92ce65e 100644 --- a/src/main/groovy/com/cloudogu/gitops/utils/TemplatingEngine.groovy +++ b/src/main/groovy/com/cloudogu/gitops/utils/TemplatingEngine.groovy @@ -1,101 +1,95 @@ package com.cloudogu.gitops.utils +import java.nio.file.Files +import java.nio.file.Path +import java.util.regex.Pattern +import groovy.yaml.YamlSlurper + import freemarker.template.Configuration import freemarker.template.Template import freemarker.template.Version -import groovy.yaml.YamlSlurper - -import java.nio.file.Files -import java.nio.file.Path -import java.util.regex.Pattern class TemplatingEngine { - private Configuration engine - - TemplatingEngine(Configuration engine = null) { - def configuration = new Configuration(new Version("2.3.32")) - this.engine = engine ?: configuration - this.engine.setSharedVariable("nullToEmpty", ''); - } - - /** - * Executes template with parameters and replaces the .ftl in the file name. - */ - File replaceTemplate(File templateFile, Map parameters) { - def targetFile = new File(templateFile.toString().replace(".ftl", "")) - def rendered = template(templateFile, parameters) - - // Only write file if template has non-empty output. - // This avoids creating empty files when the entire template is skipped via <#if>. - if (rendered?.trim()) { - targetFile.text = rendered - } else { - targetFile.delete() - } - - templateFile.delete() - return targetFile - } - - /** - * Recursively templates all .ftl files in path. - * - * That is, apply {@link #replaceTemplate(java.io.File, java.util.Map)} to all files matching filepathMatches. - */ - void replaceTemplates(File path, Map parameters, Pattern filepathMatches = ~/\.ftl/) { - Files.walk(path.toPath()) - .filter { filepathMatches.matcher(it.toString()).find() } - .each { Path it -> replaceTemplate(it.toFile(), parameters) } - } - - 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 - } - - /** - * Executes template and writes to targetFile, keeping the template file. - */ - File template(File templateFile, File targetFile, Map parameters) { - Template template = prepareTemplate(templateFile) - template.process(parameters, targetFile.newWriter()) - - return targetFile - } - - - - String template(File templateFile, Map parameters) { - Template template = prepareTemplate(templateFile) - - StringWriter writer = new StringWriter() - template.process(parameters, writer) - - return writer.toString() - } - - String template(String template, Map parameters) { - StringWriter writer = new StringWriter() - Template templateObj = new Template("template", new StringReader(template), engine) - templateObj.process(parameters, writer) - return writer.toString() - } - - protected Template prepareTemplate(File templateFile) { - if (!templateFile.name.contains(".ftl")) { - throw new RuntimeException("File must contain .ftl to be a template") - } - - engine.setDirectoryForTemplateLoading(templateFile.parentFile) - - def template = engine.getTemplate(templateFile.name) - template - } - - + private Configuration engine + + TemplatingEngine(Configuration engine = null) { + def configuration = new Configuration(new Version("2.3.32")) + this.engine = engine ?: configuration + this.engine.setSharedVariable("nullToEmpty", ''); + } + + /** + * Executes template with parameters and replaces the .ftl in the file name.*/ + File replaceTemplate(File templateFile, Map parameters) { + def targetFile = new File(templateFile.toString().replace(".ftl", "")) + def rendered = template(templateFile, parameters) + + // Only write file if template has non-empty output. + // This avoids creating empty files when the entire template is skipped via <#if>. + if (rendered?.trim()) { + targetFile.text = rendered + } else { + targetFile.delete() + } + + templateFile.delete() + return targetFile + } + + /** + * Recursively templates all .ftl files in path. + * + * That is, apply {@link #replaceTemplate(java.io.File, java.util.Map)} to all files matching filepathMatches. */ + void replaceTemplates(File path, Map parameters, Pattern filepathMatches = ~/\.ftl/) { + Files.walk(path.toPath()) + .filter { filepathMatches.matcher(it.toString()).find() } + .each { Path it -> replaceTemplate(it.toFile(), parameters) } + } + + 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 + } + + /** + * Executes template and writes to targetFile, keeping the template file.*/ + File template(File templateFile, File targetFile, Map parameters) { + Template template = prepareTemplate(templateFile) + template.process(parameters, targetFile.newWriter()) + + return targetFile + } + + String template(File templateFile, Map parameters) { + Template template = prepareTemplate(templateFile) + + StringWriter writer = new StringWriter() + template.process(parameters, writer) + + return writer.toString() + } + + String template(String template, Map parameters) { + StringWriter writer = new StringWriter() + Template templateObj = new Template("template", new StringReader(template), engine) + templateObj.process(parameters, writer) + return writer.toString() + } + + protected Template prepareTemplate(File templateFile) { + if (!templateFile.name.contains(".ftl")) { + throw new RuntimeException("File must contain .ftl to be a template") + } + + engine.setDirectoryForTemplateLoading(templateFile.parentFile) + + def template = engine.getTemplate(templateFile.name) + template + } + } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy b/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy index d6778ad9b..beb4fbc0f 100644 --- a/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/ApplicationConfiguratorTest.groovy @@ -1,5 +1,9 @@ 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 @@ -9,635 +13,575 @@ 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.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) - - - 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 - 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.mail.mailServer = 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.mail.mailUrl).isEqualTo("http://mail.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.mail.mailServer = 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.mail.mailUrl).isEqualTo("http://mail-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.mail.mailUrl).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.mail.mailUrl = 'mail' - 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.mail.mailUrl).isEqualTo("mail") - 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) + 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 + 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.mail.mailServer = 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.mail.mailUrl).isEqualTo("http://mail.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.mail.mailServer = 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.mail.mailUrl).isEqualTo("http://mail-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.mail.mailUrl).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.mail.mailUrl = 'mail' + 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.mail.mailUrl).isEqualTo("mail") + 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/ApplicationTest.groovy b/src/test/groovy/com/cloudogu/gitops/ApplicationTest.groovy index 35d717af7..b997ccdf2 100644 --- a/src/test/groovy/com/cloudogu/gitops/ApplicationTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/ApplicationTest.groovy @@ -1,139 +1,125 @@ package com.cloudogu.gitops +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.git.config.ScmTenantSchema -import groovy.transform.CompileStatic + import io.micronaut.context.ApplicationContext -import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test class ApplicationTest { - private Config config = new Config() + private Config config = new Config() + + @Test + void 'feature\'s ordering is correct'() { + def application = ApplicationContext.run() + .registerSingleton(config) + .getBean(Application) + def features = application.features.collect { it.class.simpleName } - @Test - void 'feature\'s ordering is correct'() { - def application = ApplicationContext.run() - .registerSingleton(config) - .getBean(Application) - def features = application.features.collect { it.class.simpleName } + assertThat(features).isEqualTo(['Registry', 'GitHandler', 'Jenkins', 'ArgoCD', 'Ingress', 'CertManager', 'Mail', 'Monitoring', 'ExternalSecretsOperator', 'Vault', 'ContentLoader']) + } - assertThat(features).isEqualTo(['Registry', 'GitHandler' ,'Jenkins', 'ArgoCD', 'Ingress', 'CertManager', 'Mail', 'Monitoring', 'ExternalSecretsOperator', 'Vault', 'ContentLoader']) - } + @Test + void 'get active namespaces correctly'() { + config.registry.active = true + config.jenkins.active = true + config.features.monitoring.active = true + config.features.argocd.active = true + config.content.examples = true + config.features.ingress.active = true + config.application.namePrefix = 'test1-' + config.content.namespaces = ['${config.application.namePrefix}example-apps-staging', + '${config.application.namePrefix}example-apps-production'] + List namespaceList = new ArrayList<>(Arrays.asList("test1-argocd", + "test1-example-apps-staging", + "test1-example-apps-production", + "test1-" + config.features.ingress.ingressNamespace, + "test1-monitoring", + "test1-registry", + "test1-jenkins")) + def application = ApplicationContext.run() + .registerSingleton(config) + .getBean(Application) + application.setNamespaceListToConfig(config) + assertThat(config.application.namespaces.getActiveNamespaces()).containsExactlyInAnyOrderElementsOf(namespaceList) + } - @Test - void 'get active namespaces correctly'() { - config.registry.active = true - config.jenkins.active = true - config.features.monitoring.active = true - config.features.argocd.active = true - config.content.examples = true - config.features.ingress.active = true - config.application.namePrefix = 'test1-' - config.content.namespaces = [ - '${config.application.namePrefix}example-apps-staging', - '${config.application.namePrefix}example-apps-production' - ] - List namespaceList = new ArrayList<>(Arrays.asList( - "test1-argocd", - "test1-example-apps-staging", - "test1-example-apps-production", - "test1-" + config.features.ingress.ingressNamespace, - "test1-monitoring", - "test1-registry", - "test1-jenkins" - )) - def application = ApplicationContext.run() - .registerSingleton(config) - .getBean(Application) - application.setNamespaceListToConfig(config) - assertThat(config.application.namespaces.getActiveNamespaces()).containsExactlyInAnyOrderElementsOf(namespaceList) - } + @Test + void 'get active namespaces correctly in Openshift'() { + config.registry.active = true + config.jenkins.active = true + config.features.monitoring.active = true + config.features.argocd.active = true + config.content.examples = true + config.features.ingress.active = true + config.application.namePrefix = 'test1-' + config.application.openshift = true + config.content.namespaces = ['${config.application.namePrefix}example-apps-staging', + '${config.application.namePrefix}example-apps-production'] + List namespaceList = new ArrayList<>(Arrays.asList("test1-argocd", + "test1-example-apps-staging", + "test1-example-apps-production", + "test1-" + config.features.ingress.ingressNamespace, + "test1-monitoring", + "test1-registry", + "test1-jenkins")) + def application = ApplicationContext.run() + .registerSingleton(config) + .getBean(Application) + application.setNamespaceListToConfig(config) + assertThat(config.application.namespaces.getActiveNamespaces()).containsExactlyInAnyOrderElementsOf(namespaceList) + } - @Test - void 'get active namespaces correctly in Openshift'() { - config.registry.active = true - config.jenkins.active = true - config.features.monitoring.active = true - config.features.argocd.active = true - config.content.examples = true - config.features.ingress.active = true - config.application.namePrefix = 'test1-' - config.application.openshift = true - config.content.namespaces = [ - '${config.application.namePrefix}example-apps-staging', - '${config.application.namePrefix}example-apps-production' - ] - List namespaceList = new ArrayList<>(Arrays.asList( - "test1-argocd", - "test1-example-apps-staging", - "test1-example-apps-production", - "test1-" + config.features.ingress.ingressNamespace, - "test1-monitoring", - "test1-registry", - "test1-jenkins" - )) - def application = ApplicationContext.run() - .registerSingleton(config) - .getBean(Application) - application.setNamespaceListToConfig(config) - assertThat(config.application.namespaces.getActiveNamespaces()).containsExactlyInAnyOrderElementsOf(namespaceList) - } + @Test + void 'handles content namespaces without template'() { + config.content.namespaces = ['example-apps-staging', + 'example-apps-production'] + def application = ApplicationContext.run() + .registerSingleton(config) + .getBean(Application) + application.setNamespaceListToConfig(config) + assertThat(config.application.namespaces.getActiveNamespaces()).containsAll(["example-apps-staging", + "example-apps-production",]) + } - @Test - void 'handles content namespaces without template'() { - config.content.namespaces = [ - 'example-apps-staging', - 'example-apps-production' - ] - def application = ApplicationContext.run() - .registerSingleton(config) - .getBean(Application) - application.setNamespaceListToConfig(config) - assertThat(config.application.namespaces.getActiveNamespaces()).containsAll([ - "example-apps-staging", - "example-apps-production", - ]) - } + @Test + void 'handles empty content namespaces'() { + def application = ApplicationContext.run() + .registerSingleton(config) + .getBean(Application) + application.setNamespaceListToConfig(config) + // No exception == happy + } - @Test - void 'handles empty content namespaces'() { - def application = ApplicationContext.run() - .registerSingleton(config) - .getBean(Application) - application.setNamespaceListToConfig(config) - // No exception == happy - } - @Test - void 'get active namespaces correctly in Openshift if jenkins and scm are external'() { - config.registry.active = true - config.jenkins.active = true - config.jenkins.internal = false - config.scm.scmManager = new ScmTenantSchema.ScmManagerTenantConfig() - config.scm.scmManager.internal = false - config.features.monitoring.active = true - config.features.argocd.active = true - config.content.examples = true - config.features.ingress.active = true - config.application.namePrefix = 'test1-' - config.application.openshift = true - config.content.namespaces = [ - '${config.application.namePrefix}example-apps-staging', - '${config.application.namePrefix}example-apps-production' - ] - List namespaceList = new ArrayList<>(Arrays.asList( - "test1-argocd", - "test1-example-apps-staging", - "test1-example-apps-production", - "test1-" + config.features.ingress.ingressNamespace, - "test1-monitoring", - "test1-registry", - )) - def application = ApplicationContext.run() - .registerSingleton(config) - .getBean(Application) - application.setNamespaceListToConfig(config) - assertThat(config.application.namespaces.getActiveNamespaces()).containsExactlyInAnyOrderElementsOf(namespaceList) - } + @Test + void 'get active namespaces correctly in Openshift if jenkins and scm are external'() { + config.registry.active = true + config.jenkins.active = true + config.jenkins.internal = false + config.scm.scmManager = new ScmTenantSchema.ScmManagerTenantConfig() + config.scm.scmManager.internal = false + config.features.monitoring.active = true + config.features.argocd.active = true + config.content.examples = true + config.features.ingress.active = true + config.application.namePrefix = 'test1-' + config.application.openshift = true + config.content.namespaces = ['${config.application.namePrefix}example-apps-staging', + '${config.application.namePrefix}example-apps-production'] + List namespaceList = new ArrayList<>(Arrays.asList("test1-argocd", + "test1-example-apps-staging", + "test1-example-apps-production", + "test1-" + config.features.ingress.ingressNamespace, + "test1-monitoring", + "test1-registry",)) + def application = ApplicationContext.run() + .registerSingleton(config) + .getBean(Application) + application.setNamespaceListToConfig(config) + assertThat(config.application.namespaces.getActiveNamespaces()).containsExactlyInAnyOrderElementsOf(namespaceList) + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/FeatureTest.groovy b/src/test/groovy/com/cloudogu/gitops/FeatureTest.groovy index 17176bcaf..73113e45e 100644 --- a/src/test/groovy/com/cloudogu/gitops/FeatureTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/FeatureTest.groovy @@ -3,82 +3,77 @@ package com.cloudogu.gitops import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.K8sClientForTest -import org.junit.jupiter.api.Test + +import org.junit.jupiter.api.Test class FeatureTest { - Config config = new Config( - application: new Config.ApplicationSchema( - namePrefix: "foo-") - ) - - K8sClientForTest k8sClient = new K8sClientForTest( config) - - @Test - void 'Image pull secrets are create automatically'() { - config.registry.createImagePullSecrets = true - config.registry.proxyUrl = 'proxy-url' - config.registry.proxyUsername = 'proxy-user' - config.registry.proxyPassword = 'proxy-pw' - config.registry.url = 'url' - config.registry.readOnlyUsername = 'ROuser' - config.registry.readOnlyPassword = 'ROpw' - config.registry.username = 'user' - config.registry.password = 'pw' - - createFeatureWithImage().install() - - k8sClient.commandExecutorForTest.assertExecuted( - 'kubectl create secret docker-registry proxy-registry -n foo-my-ns' + - ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') - } - - protected FeatureWithImageForTest createFeatureWithImage() { - Feature feature = new FeatureWithImageForTest() - feature.config = config - feature.k8sClient = k8sClient - feature.namespace = 'foo-my-ns' - feature - } - - @Test - void 'Image pull secrets: Falls back to using readOnly credentials and URL '() { - config.registry.createImagePullSecrets = true - config.registry.url = 'url' - config.registry.readOnlyUsername = 'ROuser' - config.registry.readOnlyPassword = 'ROpw' - config.registry.username = 'user' - config.registry.password = 'pw' - - createFeatureWithImage().install() - - k8sClient.commandExecutorForTest.assertExecuted( - 'kubectl create secret docker-registry proxy-registry -n foo-my-ns' + - ' --docker-server url --docker-username ROuser --docker-password ROpw') - } - - @Test - void 'Image pull secrets: Falls back to using credentials and URL '() { - config.registry.createImagePullSecrets = true - config.registry.url = 'url' - config.registry.username = 'user' - config.registry.password = 'pw' - - createFeatureWithImage().install() - - k8sClient.commandExecutorForTest.assertExecuted( - 'kubectl create secret docker-registry proxy-registry -n foo-my-ns' + - ' --docker-server url --docker-username user --docker-password pw') - } - - class FeatureWithImageForTest extends Feature implements FeatureWithImage { - - String namespace - Config config - K8sClient k8sClient - - @Override - boolean isEnabled() { - return true - } - } + Config config = new Config(application: new Config.ApplicationSchema(namePrefix: "foo-")) + + K8sClientForTest k8sClient = new K8sClientForTest(config) + + @Test + void 'Image pull secrets are create automatically'() { + config.registry.createImagePullSecrets = true + config.registry.proxyUrl = 'proxy-url' + config.registry.proxyUsername = 'proxy-user' + config.registry.proxyPassword = 'proxy-pw' + config.registry.url = 'url' + config.registry.readOnlyUsername = 'ROuser' + config.registry.readOnlyPassword = 'ROpw' + config.registry.username = 'user' + config.registry.password = 'pw' + + createFeatureWithImage().install() + + k8sClient.commandExecutorForTest.assertExecuted('kubectl create secret docker-registry proxy-registry -n foo-my-ns' + + ' --docker-server proxy-url --docker-username proxy-user --docker-password proxy-pw') + } + + protected FeatureWithImageForTest createFeatureWithImage() { + Feature feature = new FeatureWithImageForTest() + feature.config = config + feature.k8sClient = k8sClient + feature.namespace = 'foo-my-ns' + feature + } + + @Test + void 'Image pull secrets: Falls back to using readOnly credentials and URL '() { + config.registry.createImagePullSecrets = true + config.registry.url = 'url' + config.registry.readOnlyUsername = 'ROuser' + config.registry.readOnlyPassword = 'ROpw' + config.registry.username = 'user' + config.registry.password = 'pw' + + createFeatureWithImage().install() + + k8sClient.commandExecutorForTest.assertExecuted('kubectl create secret docker-registry proxy-registry -n foo-my-ns' + + ' --docker-server url --docker-username ROuser --docker-password ROpw') + } + + @Test + void 'Image pull secrets: Falls back to using credentials and URL '() { + config.registry.createImagePullSecrets = true + config.registry.url = 'url' + config.registry.username = 'user' + config.registry.password = 'pw' + + createFeatureWithImage().install() + + k8sClient.commandExecutorForTest.assertExecuted('kubectl create secret docker-registry proxy-registry -n foo-my-ns' + + ' --docker-server url --docker-username user --docker-password pw') + } + + class FeatureWithImageForTest extends Feature implements FeatureWithImage { + + String namespace + Config config + K8sClient k8sClient + + @Override + boolean isEnabled() { + return true + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScriptedTest.groovy b/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScriptedTest.groovy index c7310bcee..b3f2f8085 100644 --- a/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScriptedTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScriptedTest.groovy @@ -1,5 +1,10 @@ package com.cloudogu.gitops.cli +import static com.cloudogu.gitops.config.Config.ApplicationSchema +import static com.cloudogu.gitops.config.Config.JenkinsSchema +import static org.assertj.core.api.Assertions.assertThat +import static org.assertj.core.api.Fail.fail + import com.cloudogu.gitops.Application import com.cloudogu.gitops.Feature import com.cloudogu.gitops.config.Config @@ -7,111 +12,102 @@ import com.cloudogu.gitops.destroy.Destroyer import com.cloudogu.gitops.destroy.DestructionHandler import com.cloudogu.gitops.features.git.config.ScmTenantSchema import com.cloudogu.gitops.features.git.config.ScmTenantSchema.ScmManagerTenantConfig -import io.github.classgraph.ClassGraph -import io.github.classgraph.ClassInfo + import io.micronaut.context.ApplicationContext import io.micronaut.core.annotation.Order -import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat -import static org.assertj.core.api.Fail.fail -import static com.cloudogu.gitops.config.Config.* +import io.github.classgraph.ClassGraph +import io.github.classgraph.ClassInfo +import org.junit.jupiter.api.Test /** * It is difficult to test if *all* classes are instantiated. * Except for edge cases like outputConfigFile or delete, * the core logic of the application is to install all {@link Feature}s in the proper {@link Order}. - * At least this we can test! - */ + * At least this we can test!*/ class GitopsPlaygroundCliMainScriptedTest { - ApplicationContext applicationContext - GitopsPlaygroundCliScriptedForTest gitopsPlaygroundCliScripted = new GitopsPlaygroundCliScriptedForTest() - Config config = new Config( - jenkins: new JenkinsSchema(url: 'http://jenkins'), - scm: new ScmTenantSchema( - scmManager: new ScmManagerTenantConfig(url: 'http://scmm')) - ) - - /** - * This test makes sure that we don't forget to add new {@link Feature} classes to - * {@link GitopsPlaygroundCliMainScripted.GitopsPlaygroundCliScripted#register(Config, io.micronaut.context.ApplicationContext)} - * so they also work in the dev image. - */ - @Test - void 'all Feature classes are instantiated in the correct order'() { - gitopsPlaygroundCliScripted.createApplicationContext() - gitopsPlaygroundCliScripted.register(config, applicationContext) - - List actualClasses = applicationContext.getBean(Application).features - .collect { it.class.simpleName } - - def expectedClasses = findAllChildClasses(Feature) - - assertThat(actualClasses).containsExactlyElementsOf(expectedClasses) - } - - @Test - void 'all DestructionHandlers are instantiated in the correct order'() { - config = new Config(config.properties + [application: new ApplicationSchema(destroy: true)]) - - gitopsPlaygroundCliScripted.createApplicationContext() - gitopsPlaygroundCliScripted.register(config, applicationContext) - - List actualClasses = applicationContext.getBean(Destroyer).destructionHandlers - .collect { it.class.simpleName } - - def expectedClasses = findAllChildClasses(DestructionHandler) - - assertThat(actualClasses).containsExactlyElementsOf(expectedClasses) - } - - protected List findAllChildClasses(Class parentClass) { - boolean parentIsInterface = parentClass.isInterface() - - def featureClasses = [] - - new ClassGraph() - .acceptPackages("com.cloudogu") - .enableClassInfo() - .enableAnnotationInfo() - .scan().withCloseable { scanResult -> - scanResult.getAllClasses().each { ClassInfo classInfo -> - if (classInfo.name.endsWith("Test") || classInfo.isAbstract() || !classInfo.hasAnnotation(jakarta.inject.Singleton)) { - return - } - - if (classInfo.extendsSuperclass(parentClass) || - (parentIsInterface && classInfo.implementsInterface(parentClass))) { - - // ignore test classes - String location = classInfo.loadClass().protectionDomain?.codeSource?.location?.path ?: "" - if (location == null) location = "" - if (location =~ /[\\/]test-classes[\\/]/ || location =~ /[\\/]classes[\\/]java[\\/]test[\\/]/) { - return - } - - def orderAnnotation = classInfo.getAnnotationInfo(Order) - if (orderAnnotation) { - def orderValue = orderAnnotation.getParameterValues().getValue('value') as int - def clazz = classInfo.loadClass() - featureClasses << [clazz: clazz, orderValue: orderValue] - } else { - fail("Class ${classInfo.name} does not have @Order annotation") - } - } - } - } - - return featureClasses.sort { a, b -> - Integer.compare(a['orderValue'] as Integer, b['orderValue'] as Integer) - }.collect { it['clazz']['simpleName'] } as List - } - - class GitopsPlaygroundCliScriptedForTest extends GitopsPlaygroundCliMainScripted.GitopsPlaygroundCliScripted { - @Override - protected ApplicationContext createApplicationContext() { - applicationContext = super.createApplicationContext() - } - } + ApplicationContext applicationContext + GitopsPlaygroundCliScriptedForTest gitopsPlaygroundCliScripted = new GitopsPlaygroundCliScriptedForTest() + Config config = new Config(jenkins: new JenkinsSchema(url: 'http://jenkins'), + scm: new ScmTenantSchema(scmManager: new ScmManagerTenantConfig(url: 'http://scmm'))) + + /** + * This test makes sure that we don't forget to add new {@link Feature} classes to + * {@link GitopsPlaygroundCliMainScripted.GitopsPlaygroundCliScripted#register(Config, io.micronaut.context.ApplicationContext)} + * so they also work in the dev image.*/ + @Test + void 'all Feature classes are instantiated in the correct order'() { + gitopsPlaygroundCliScripted.createApplicationContext() + gitopsPlaygroundCliScripted.register(config, applicationContext) + + List actualClasses = applicationContext.getBean(Application).features + .collect { it.class.simpleName } + + def expectedClasses = findAllChildClasses(Feature) + + assertThat(actualClasses).containsExactlyElementsOf(expectedClasses) + } + + @Test + void 'all DestructionHandlers are instantiated in the correct order'() { + config = new Config(config.properties + [application: new ApplicationSchema(destroy: true)]) + + gitopsPlaygroundCliScripted.createApplicationContext() + gitopsPlaygroundCliScripted.register(config, applicationContext) + + List actualClasses = applicationContext.getBean(Destroyer).destructionHandlers + .collect { it.class.simpleName } + + def expectedClasses = findAllChildClasses(DestructionHandler) + + assertThat(actualClasses).containsExactlyElementsOf(expectedClasses) + } + + protected List findAllChildClasses(Class parentClass) { + boolean parentIsInterface = parentClass.isInterface() + + def featureClasses = [] + + new ClassGraph() + .acceptPackages("com.cloudogu") + .enableClassInfo() + .enableAnnotationInfo() + .scan().withCloseable { scanResult -> + scanResult.getAllClasses().each { ClassInfo classInfo -> + if (classInfo.name.endsWith("Test") || classInfo.isAbstract() || !classInfo.hasAnnotation(jakarta.inject.Singleton)) { + return + } + + if (classInfo.extendsSuperclass(parentClass) || (parentIsInterface && classInfo.implementsInterface(parentClass))) { + + // ignore test classes + String location = classInfo.loadClass().protectionDomain?.codeSource?.location?.path ?: "" + if (location == null) location = "" + if (location =~ /[\\/]test-classes[\\/]/ || location =~ /[\\/]classes[\\/]java[\\/]test[\\/]/) { + return + } + + def orderAnnotation = classInfo.getAnnotationInfo(Order) + if (orderAnnotation) { + def orderValue = orderAnnotation.getParameterValues().getValue('value') as int + def clazz = classInfo.loadClass() + featureClasses << [clazz: clazz, orderValue: orderValue] + } else { + fail("Class ${classInfo.name} does not have @Order annotation") + } + } + } + } + + return featureClasses.sort { a, b -> Integer.compare(a['orderValue'] as Integer, b['orderValue'] as Integer) + }.collect { it['clazz']['simpleName'] } as List + } + + class GitopsPlaygroundCliScriptedForTest extends GitopsPlaygroundCliMainScripted.GitopsPlaygroundCliScripted { + @Override + protected ApplicationContext createApplicationContext() { + applicationContext = super.createApplicationContext() + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainTest.groovy b/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainTest.groovy index c57089de2..264c81d0f 100644 --- a/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainTest.groovy @@ -1,65 +1,65 @@ package com.cloudogu.gitops.cli +import static org.assertj.core.api.Assertions.assertThat + import com.github.stefanbirkner.systemlambda.SystemLambda import org.junit.jupiter.api.Test import picocli.CommandLine.Command import picocli.CommandLine.Option -import static org.assertj.core.api.Assertions.assertThat - class GitopsPlaygroundCliMainTest { - @Test - void 'application returns exit code 0 on success'() { - def gitopsPlaygroundCliMain = new GitopsPlaygroundCliMain() - int status = SystemLambda.catchSystemExit(() -> { - gitopsPlaygroundCliMain.exec(['--mock'] as String[], MockedCommand.class) - }) + @Test + void 'application returns exit code 0 on success'() { + def gitopsPlaygroundCliMain = new GitopsPlaygroundCliMain() + int status = SystemLambda.catchSystemExit(() -> { + gitopsPlaygroundCliMain.exec(['--mock'] as String[], MockedCommand.class) + }) - assertThat(status).isZero() - } + assertThat(status).isZero() + } - @Test - void 'application returns exit code 1 on exception'() { - def gitopsPlaygroundCliMain = new GitopsPlaygroundCliMain() - int status = SystemLambda.catchSystemExit(() -> { - gitopsPlaygroundCliMain.exec(['--mock'] as String[], ThrowingCommand.class) - }) + @Test + void 'application returns exit code 1 on exception'() { + def gitopsPlaygroundCliMain = new GitopsPlaygroundCliMain() + int status = SystemLambda.catchSystemExit(() -> { + gitopsPlaygroundCliMain.exec(['--mock'] as String[], ThrowingCommand.class) + }) - assertThat(status).isNotZero() - } + assertThat(status).isNotZero() + } - @Test - void 'application returns exit code != 0 on invalid param'() { - int status = SystemLambda.catchSystemExit(() -> { - GitopsPlaygroundCliMain.main(['--parameter-that-doesnt-exist ', - '--debug' // avoids changing default log pattern - ] as String[]) - }) + @Test + void 'application returns exit code != 0 on invalid param'() { + int status = SystemLambda.catchSystemExit(() -> { + GitopsPlaygroundCliMain.main(['--parameter-that-doesnt-exist ', + '--debug' // avoids changing default log pattern + ] as String[]) + }) - assertThat(status).isNotZero() - } + assertThat(status).isNotZero() + } - static class ThrowingCommand extends MockedCommand { - @Override - ReturnCode run(String[] args) { - throw new RuntimeException("mock") - } - } + static class ThrowingCommand extends MockedCommand { + @Override + ReturnCode run(String[] args) { + throw new RuntimeException("mock") + } + } - @SuppressWarnings('unused') - // Used for annotations - static class MockedCommand extends GitopsPlaygroundCli { + @SuppressWarnings('unused') + // Used for annotations + static class MockedCommand extends GitopsPlaygroundCli { - @Override - ReturnCode run(String[] args) { - return ReturnCode.SUCCESS - } + @Override + ReturnCode run(String[] args) { + return ReturnCode.SUCCESS + } - @Command - void mockedCommand() {} + @Command + void mockedCommand() {} - @Option(names = ['--mock']) - private boolean mock - } -} + @Option(names = ['--mock']) + private boolean mock + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliTest.groovy b/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliTest.groovy index a52f93c2d..401fba0c4 100644 --- a/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliTest.groovy @@ -1,16 +1,25 @@ package com.cloudogu.gitops.cli -import ch.qos.logback.classic.Logger -import ch.qos.logback.classic.LoggerContext -import ch.qos.logback.classic.encoder.PatternLayoutEncoder -import ch.qos.logback.core.ConsoleAppender +import static groovy.test.GroovyAssert.shouldFail +import static org.assertj.core.api.Assertions.assertThat +import static org.mockito.ArgumentMatchers.any +import static org.mockito.Mockito.* + import com.cloudogu.gitops.Application import com.cloudogu.gitops.config.ApplicationConfigurator import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.destroy.Destroyer import com.cloudogu.gitops.kubernetes.api.K8sClient -import com.fasterxml.jackson.dataformat.yaml.YAMLMapper + import io.micronaut.context.ApplicationContext + +import java.util.concurrent.TimeUnit + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import ch.qos.logback.core.ConsoleAppender +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout @@ -18,381 +27,321 @@ import org.mockito.invocation.InvocationOnMock import org.mockito.stubbing.Answer import org.slf4j.LoggerFactory -import java.util.concurrent.TimeUnit - -import static groovy.test.GroovyAssert.shouldFail -import static org.assertj.core.api.Assertions.assertThat -import static org.mockito.ArgumentMatchers.any -import static org.mockito.Mockito.* - // Avoids blocking if input is read by error @Timeout(value = 10, unit = TimeUnit.SECONDS) class GitopsPlaygroundCliTest { - static final String ORIGINAL_LOGGING_PATTERN = loggingEncoder.pattern + static final String ORIGINAL_LOGGING_PATTERN = loggingEncoder.pattern + + K8sClient k8sClient = mock(K8sClient) + Application application = mock(Application) + ApplicationConfigurator applicationConfigurator = mock(ApplicationConfigurator) + Destroyer destroyer = mock(Destroyer) + GitopsPlaygroundCliForTest cli = new GitopsPlaygroundCliForTest() + static YAMLMapper yamlMapper = new YAMLMapper() + + @AfterEach + void setup() { + // Restore logging pattern, if modified + loggingEncoder.setPattern(ORIGINAL_LOGGING_PATTERN) + } + + @Test + void 'Starts regularly'() { + def status = cli.run('--yes') + + assertThat(status).isEqualTo(ReturnCode.SUCCESS) + verify(applicationConfigurator).initConfig(any(Config)) + verify(application).start() + } + + @Test + void 'Starts with config file'() { + String pathToConfigFile = "./src/test/resources/testMainConfig.yaml" + + assertThat(new File(pathToConfigFile).isFile()).withFailMessage("config file for test do not exists anymore.").isTrue() + + def status = cli.run('--config-file=' + pathToConfigFile) + assertThat(status).isEqualTo(ReturnCode.SUCCESS) + + // Verify the first interaction + verify(applicationConfigurator).initConfig(any(Config)) + + // Check application starts + verify(application).start() + } + + @Test + void 'Starts with config map'() { + when(k8sClient.getConfigMap('my-config', 'config.yaml')).thenReturn('{"application": {"yes": true}}') + + def status = cli.run("--config-map=my-config") + + assertThat(status).isEqualTo(ReturnCode.SUCCESS) + // ensure init is called with Config + verify(applicationConfigurator).initConfig(any(Config)) + verify(application).start() + } + + @Test + void 'Outputs config file'() { + def status = cli.run('--output-config-file') + + assertThat(status).isEqualTo(ReturnCode.SUCCESS) + verify(applicationConfigurator, never()).initConfig(any(Config)) + verify(application, never()).start() + } + + @Test + void 'Outputs version'() { + def cli = new GitopsPlaygroundCliForTest() + def status = cli.run('--version') + + assertThat(status).isEqualTo(ReturnCode.SUCCESS) + verify(applicationConfigurator, never()).initConfig(any(Config)) + verify(application, never()).start() + } - K8sClient k8sClient = mock(K8sClient) - Application application = mock(Application) - ApplicationConfigurator applicationConfigurator = mock(ApplicationConfigurator) - Destroyer destroyer = mock(Destroyer) - GitopsPlaygroundCliForTest cli = new GitopsPlaygroundCliForTest() - static YAMLMapper yamlMapper = new YAMLMapper() + @Test + void 'Outputs help'() { + def cli = new GitopsPlaygroundCliForTest() + def status = cli.run('--help') - @AfterEach - void setup() { - // Restore logging pattern, if modified - loggingEncoder.setPattern(ORIGINAL_LOGGING_PATTERN) - } + assertThat(status).isEqualTo(ReturnCode.SUCCESS) + verify(applicationConfigurator, never()).initConfig(any(Config)) + verify(application, never()).start() + } - @Test - void 'Starts regularly'() { - def status = cli.run('--yes') + @Test + void 'Returns error, when applying is not confirmed'() { + writeViaSystemIn('something') + def status = cli.run() - assertThat(status).isEqualTo(ReturnCode.SUCCESS) - verify(applicationConfigurator).initConfig(any(Config)) - verify(application).start() - } + assertThat(status).isEqualTo(ReturnCode.NOT_CONFIRMED) + } - @Test - void 'Starts with config file'() { - String pathToConfigFile = "./src/test/resources/testMainConfig.yaml" + @Test + void 'Runs when applying is confirmed'() { + writeViaSystemIn('y') - assertThat(new File(pathToConfigFile).isFile()).withFailMessage("config file for test do not exists anymore.").isTrue() + cli.run() - def status = cli.run('--config-file=' + pathToConfigFile) - assertThat(status).isEqualTo(ReturnCode.SUCCESS) + verify(application).start() + } - // Verify the first interaction - verify(applicationConfigurator).initConfig(any(Config)) + @Test + void 'Runs without confirmation when yes parameter is set'() { + cli.run('--yes') - // Check application starts - verify(application).start() - } + verify(application).start() + } - @Test - void 'Starts with config map'() { - when(k8sClient.getConfigMap('my-config', 'config.yaml')).thenReturn('{"application": {"yes": true}}') + @Test + void 'Returns error, when destroying is not confirmed'() { - def status = cli.run("--config-map=my-config") + writeViaSystemIn('something') - assertThat(status).isEqualTo(ReturnCode.SUCCESS) - // ensure init is called with Config - verify(applicationConfigurator).initConfig(any(Config)) - verify(application).start() - } + def status = cli.run('--destroy') - @Test - void 'Outputs config file'() { - def status = cli.run('--output-config-file') + assertThat(status).isEqualTo(ReturnCode.NOT_CONFIRMED) + } - assertThat(status).isEqualTo(ReturnCode.SUCCESS) - verify(applicationConfigurator, never()).initConfig(any(Config)) - verify(application, never()).start() - } + @Test + void 'Destroys when confirmed'() { - @Test - void 'Outputs version'() { - def cli = new GitopsPlaygroundCliForTest() - def status = cli.run('--version') + writeViaSystemIn('y') - assertThat(status).isEqualTo(ReturnCode.SUCCESS) - verify(applicationConfigurator, never()).initConfig(any(Config)) - verify(application, never()).start() - } + cli.run '--destroy' - @Test - void 'Outputs help'() { - def cli = new GitopsPlaygroundCliForTest() - def status = cli.run('--help') + verify(destroyer).destroy() + verify(application, never()).start() + } - assertThat(status).isEqualTo(ReturnCode.SUCCESS) - verify(applicationConfigurator, never()).initConfig(any(Config)) - verify(application, never()).start() - } + @Test + void 'Destroys without confirmation when yes parameter is set'() { + cli.run('--destroy', '--yes') - @Test - void 'Returns error, when applying is not confirmed'() { - writeViaSystemIn('something') - def status = cli.run() + verify(destroyer).destroy() + } - assertThat(status).isEqualTo(ReturnCode.NOT_CONFIRMED) - } + @Test + void 'sets simplified logging pattern'() { + cli.run('--yes') - @Test - void 'Runs when applying is confirmed'() { - writeViaSystemIn('y') + assertThat(getLoggingPattern()).doesNotContain('%logger', '%thread') + } - cli.run() + @Test + void 'keeps simplified logging pattern when trace is enabled'() { + cli.run('--trace', '--yes') - verify(application).start() - } + assertThat(getLoggingPattern()).contains('%logger', '%thread') + } - @Test - void 'Runs without confirmation when yes parameter is set'() { - cli.run('--yes') + @Test + void 'keeps simplified logging pattern when debug is enabled'() { + cli.run('--debug', '--yes') - verify(application).start() - } + assertThat(getLoggingPattern()).contains('%logger', '%thread') + } - @Test - void 'Returns error, when destroying is not confirmed'() { + @Test + void 'fails on invalid config file'() { - writeViaSystemIn('something') + def configFile = File.createTempFile("gop", '.yaml') + configFile.deleteOnExit() + configFile.text = 'something: not-matching-our-schema' - def status = cli.run('--destroy') + def exception = shouldFail(RuntimeException) { + cli.run("--config-file=${configFile}", '--yes') + } + assertThat(exception.message).contains('Config file invalid') + } - assertThat(status).isEqualTo(ReturnCode.NOT_CONFIRMED) - } + @Test + void 'fails on invalid config map'() { + when(k8sClient.getConfigMap('my-config', 'config.yaml')).thenReturn('something: not-matching-our-schema') - @Test - void 'Destroys when confirmed'() { + def exception = shouldFail(RuntimeException) { + cli.run('--config-map=my-config', '--yes') + } + assertThat(exception.message).contains('Config file invalid') + } - writeViaSystemIn('y') + @Test + void 'Precedence: config file overwrite confiMap, cli overwrites config file'() { - cli.run '--destroy' + def cmConfig = [application: [username: 'cmUser', password: 'cmPw', namePrefix: 'cmPref']] + def fileConfig = [application: [username: 'fileUser', password: 'filePw']] - verify(destroyer).destroy() - verify(application, never()).start() - } + def configFile = File.createTempFile("gop", '.yaml') + configFile.deleteOnExit() - @Test - void 'Destroys without confirmation when yes parameter is set'() { - cli.run('--destroy', '--yes') + configFile.text = toYaml(fileConfig) + when(k8sClient.getConfigMap('my-config', 'config.yaml')).thenReturn(toYaml(cmConfig)) - verify(destroyer).destroy() - } + cli.run("--config-file=${configFile}", '--config-map=my-config', '--username=paramUser', '--yes') - @Test - void 'sets simplified logging pattern'() { - cli.run('--yes') + assertThat(cli.lastSchema.application.username).isEqualTo('paramUser') + assertThat(cli.lastSchema.application.password).isEqualTo('filePw') + assertThat(cli.lastSchema.application.namePrefix).isEqualTo('cmPref') + } - assertThat(getLoggingPattern()).doesNotContain('%logger', '%thread') - } + @Test + void 'Helm null values overwrite'() { - @Test - void 'keeps simplified logging pattern when trace is enabled'() { - cli.run('--trace', '--yes') + def fileConfig = [features: [monitoring: [helm: [repoURL: "https://prometheus-community.github.io/helm-chartsTEST"]]]] - assertThat(getLoggingPattern()).contains('%logger', '%thread') - } + def configFile = File.createTempFile("gop", '.yaml') + configFile.deleteOnExit() - @Test - void 'keeps simplified logging pattern when debug is enabled'() { - cli.run('--debug', '--yes') + configFile.text = toYaml(fileConfig) - assertThat(getLoggingPattern()).contains('%logger', '%thread') - } + cli.run("--config-file=${configFile}", "--yes") - @Test - void 'fails on invalid config file'() { - - def configFile = File.createTempFile("gop", '.yaml') - configFile.deleteOnExit() - configFile.text = 'something: not-matching-our-schema' - - def exception = shouldFail(RuntimeException) { - cli.run("--config-file=${configFile}", '--yes') - } - assertThat(exception.message).contains('Config file invalid') - } - - @Test - void 'fails on invalid config map'() { - when(k8sClient.getConfigMap('my-config', 'config.yaml')).thenReturn('something: not-matching-our-schema') - - def exception = shouldFail(RuntimeException) { - cli.run('--config-map=my-config', '--yes') - } - assertThat(exception.message).contains('Config file invalid') - } - - @Test - void 'Precedence: config file overwrite confiMap, cli overwrites config file'() { - - def cmConfig = [ - application: [ - username: 'cmUser', password: 'cmPw', namePrefix: 'cmPref' - ] - ] - def fileConfig = [ - application: [ - username: 'fileUser', password: 'filePw' - ] - ] - - def configFile = File.createTempFile("gop", '.yaml') - configFile.deleteOnExit() - - configFile.text = toYaml(fileConfig) - when(k8sClient.getConfigMap('my-config', 'config.yaml')).thenReturn(toYaml(cmConfig)) - - cli.run("--config-file=${configFile}", '--config-map=my-config', '--username=paramUser', '--yes') - - assertThat(cli.lastSchema.application.username).isEqualTo('paramUser') - assertThat(cli.lastSchema.application.password).isEqualTo('filePw') - assertThat(cli.lastSchema.application.namePrefix).isEqualTo('cmPref') - } - - - @Test - void 'Helm null values overwrite'() { - - - def fileConfig = [ - features: [ - monitoring: [ - helm: [ - repoURL: "https://prometheus-community.github.io/helm-chartsTEST" - ] - ] - ] - ] - - def configFile = File.createTempFile("gop", '.yaml') - configFile.deleteOnExit() - - configFile.text = toYaml(fileConfig) - - cli.run("--config-file=${configFile}", "--yes") - - assertThat(cli.lastSchema.features.monitoring.helm.chart).isEqualTo('kube-prometheus-stack') - assertThat(cli.lastSchema.features.monitoring.helm.repoURL).isEqualTo('https://prometheus-community.github.io/helm-chartsTEST') - assertThat(cli.lastSchema.features.monitoring.helm.version).isEqualTo('80.2.2') - } - - - @Test - void 'ensure helm defaults are used, if not set'() { - // this test sets only a few values for helm configuration and expect, that defaults are used. - - def fileConfig = [ - jenkins : [ - helm: [ - version: '5.8.1' - ] - ], - scm : [ - scmManager: [ - helm: [ - values: [ - initialDelaySeconds: 120 - ] - ] - ] - ], - features: [ - monitoring : [ - helm: [ - version : '66.2.1', - grafanaImage: 'localhost:30000/proxy/grafana:latest' - ] - ], - secrets : [ - externalSecrets: [ - helm: [ - chart: 'my-secrets' - ] - ], - vault : [ - helm: [ - repoURL: 'localhost:3000/proxy/vault:latest' - ] - ], - ], - certManager: [ - helm: [ - image: 'localhost:30000/proxy/cert-manager-controller:latest' - ] - ] - ] - ] - - def configFile = File.createTempFile("gop", ".yaml") - configFile.deleteOnExit() - - configFile.text = toYaml(fileConfig) - - cli.run("--config-file=${configFile}", "--yes") - def myconfig = cli.lastSchema; - assertThat(myconfig.jenkins.helm.chart).isEqualTo('jenkins') - assertThat(myconfig.jenkins.helm.repoURL).isEqualTo('https://charts.jenkins.io') - assertThat(myconfig.jenkins.helm.version).isEqualTo('5.8.1') // overridden - - assertThat(myconfig.scm.scmManager.helm.chart).isEqualTo('scm-manager') - assertThat(myconfig.scm.scmManager.helm.repoURL).isEqualTo('https://packages.scm-manager.org/repository/helm-v2-releases/') - assertThat(myconfig.scm.scmManager.helm.version).isEqualTo('3.11.4') - assertThat(myconfig.scm.scmManager.helm.values.initialDelaySeconds).isEqualTo(120) // overridden - - assertThat(cli.lastSchema.features.monitoring.helm.chart).isEqualTo('kube-prometheus-stack') - assertThat(cli.lastSchema.features.monitoring.helm.repoURL).isEqualTo('https://prometheus-community.github.io/helm-charts') - assertThat(cli.lastSchema.features.monitoring.helm.version).isEqualTo('66.2.1') - assertThat(cli.lastSchema.features.monitoring.helm.grafanaSidecarImage).isEqualTo('') - assertThat(cli.lastSchema.features.monitoring.helm.prometheusImage).isEqualTo('') - assertThat(cli.lastSchema.features.monitoring.helm.prometheusConfigReloaderImage).isEqualTo('') - assertThat(cli.lastSchema.features.monitoring.helm.prometheusOperatorImage).isEqualTo('') - assertThat(cli.lastSchema.features.monitoring.helm.grafanaImage).isEqualTo('localhost:30000/proxy/grafana:latest') - - assertThat(cli.lastSchema.features.secrets.externalSecrets.helm.chart).isEqualTo('my-secrets') - assertThat(cli.lastSchema.features.secrets.externalSecrets.helm.repoURL).isEqualTo('https://charts.external-secrets.io') - assertThat(cli.lastSchema.features.secrets.externalSecrets.helm.version).isEqualTo('0.9.16') - - assertThat(cli.lastSchema.features.secrets.vault.helm.chart).isEqualTo('vault') - assertThat(cli.lastSchema.features.secrets.vault.helm.repoURL).isEqualTo('localhost:3000/proxy/vault:latest') - assertThat(cli.lastSchema.features.secrets.vault.helm.version).isEqualTo('0.25.0') - - assertThat(cli.lastSchema.features.certManager.helm.chart).isEqualTo('cert-manager') - assertThat(cli.lastSchema.features.certManager.helm.repoURL).isEqualTo('https://charts.jetstack.io') - assertThat(cli.lastSchema.features.certManager.helm.version).isEqualTo('1.16.1') - assertThat(cli.lastSchema.features.certManager.helm.startupAPICheckImage).isEqualTo('') - assertThat(cli.lastSchema.features.certManager.helm.webhookImage).isEqualTo('') - assertThat(cli.lastSchema.features.certManager.helm.cainjectorImage).isEqualTo('') - assertThat(cli.lastSchema.features.certManager.helm.acmeSolverImage).isEqualTo('') - assertThat(cli.lastSchema.features.certManager.helm.image).isEqualTo('localhost:30000/proxy/cert-manager-controller:latest') - } - - static String getLoggingPattern() { - loggingEncoder.pattern - } - - static PatternLayoutEncoder getLoggingEncoder() { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory() - def rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME) - def consoleAppender = rootLogger.getAppender('STDOUT') as ConsoleAppender - consoleAppender.getEncoder() as PatternLayoutEncoder - } - - void writeViaSystemIn(String value) { - ByteArrayInputStream inContent = new ByteArrayInputStream("${value}\n".getBytes()) - System.setIn(inContent) - } - - static String toYaml(Map map) { - yamlMapper.writeValueAsString(map) - } - - class GitopsPlaygroundCliForTest extends GitopsPlaygroundCli { - ApplicationContext applicationContext = mock(ApplicationContext) - Config lastSchema = null - - GitopsPlaygroundCliForTest() { - super(GitopsPlaygroundCliTest.this.k8sClient, GitopsPlaygroundCliTest.this.applicationConfigurator) - - when(applicationConfigurator.initConfig(any(Config))).thenAnswer(new Answer() { - @Override - Config answer(InvocationOnMock invocation) throws Throwable { - lastSchema = invocation.getArgument(0) - return lastSchema - } - }) - - } - - @Override - protected ApplicationContext createApplicationContext() { - when(applicationContext.getBean(Application)).thenReturn(application) - when(applicationContext.getBean(Destroyer)).thenReturn(destroyer) - - return applicationContext - } - } + assertThat(cli.lastSchema.features.monitoring.helm.chart).isEqualTo('kube-prometheus-stack') + assertThat(cli.lastSchema.features.monitoring.helm.repoURL).isEqualTo('https://prometheus-community.github.io/helm-chartsTEST') + assertThat(cli.lastSchema.features.monitoring.helm.version).isEqualTo('80.2.2') + } + + @Test + void 'ensure helm defaults are used, if not set'() { + // this test sets only a few values for helm configuration and expect, that defaults are used. + + def fileConfig = [jenkins : [helm: [version: '5.8.1']], + scm : [scmManager: [helm: [values: [initialDelaySeconds: 120]]]], + features: [monitoring : [helm: [version : '66.2.1', + grafanaImage: 'localhost:30000/proxy/grafana:latest']], + secrets : [externalSecrets: [helm: [chart: 'my-secrets']], + vault : [helm: [repoURL: 'localhost:3000/proxy/vault:latest']],], + certManager: [helm: [image: 'localhost:30000/proxy/cert-manager-controller:latest']]]] + + def configFile = File.createTempFile("gop", ".yaml") + configFile.deleteOnExit() + + configFile.text = toYaml(fileConfig) + + cli.run("--config-file=${configFile}", "--yes") + def myconfig = cli.lastSchema; + assertThat(myconfig.jenkins.helm.chart).isEqualTo('jenkins') + assertThat(myconfig.jenkins.helm.repoURL).isEqualTo('https://charts.jenkins.io') + assertThat(myconfig.jenkins.helm.version).isEqualTo('5.8.1') // overridden + + assertThat(myconfig.scm.scmManager.helm.chart).isEqualTo('scm-manager') + assertThat(myconfig.scm.scmManager.helm.repoURL).isEqualTo('https://packages.scm-manager.org/repository/helm-v2-releases/') + assertThat(myconfig.scm.scmManager.helm.version).isEqualTo('3.11.4') + assertThat(myconfig.scm.scmManager.helm.values.initialDelaySeconds).isEqualTo(120) // overridden + + assertThat(cli.lastSchema.features.monitoring.helm.chart).isEqualTo('kube-prometheus-stack') + assertThat(cli.lastSchema.features.monitoring.helm.repoURL).isEqualTo('https://prometheus-community.github.io/helm-charts') + assertThat(cli.lastSchema.features.monitoring.helm.version).isEqualTo('66.2.1') + assertThat(cli.lastSchema.features.monitoring.helm.grafanaSidecarImage).isEqualTo('') + assertThat(cli.lastSchema.features.monitoring.helm.prometheusImage).isEqualTo('') + assertThat(cli.lastSchema.features.monitoring.helm.prometheusConfigReloaderImage).isEqualTo('') + assertThat(cli.lastSchema.features.monitoring.helm.prometheusOperatorImage).isEqualTo('') + assertThat(cli.lastSchema.features.monitoring.helm.grafanaImage).isEqualTo('localhost:30000/proxy/grafana:latest') + + assertThat(cli.lastSchema.features.secrets.externalSecrets.helm.chart).isEqualTo('my-secrets') + assertThat(cli.lastSchema.features.secrets.externalSecrets.helm.repoURL).isEqualTo('https://charts.external-secrets.io') + assertThat(cli.lastSchema.features.secrets.externalSecrets.helm.version).isEqualTo('0.9.16') + + assertThat(cli.lastSchema.features.secrets.vault.helm.chart).isEqualTo('vault') + assertThat(cli.lastSchema.features.secrets.vault.helm.repoURL).isEqualTo('localhost:3000/proxy/vault:latest') + assertThat(cli.lastSchema.features.secrets.vault.helm.version).isEqualTo('0.25.0') + + assertThat(cli.lastSchema.features.certManager.helm.chart).isEqualTo('cert-manager') + assertThat(cli.lastSchema.features.certManager.helm.repoURL).isEqualTo('https://charts.jetstack.io') + assertThat(cli.lastSchema.features.certManager.helm.version).isEqualTo('1.16.1') + assertThat(cli.lastSchema.features.certManager.helm.startupAPICheckImage).isEqualTo('') + assertThat(cli.lastSchema.features.certManager.helm.webhookImage).isEqualTo('') + assertThat(cli.lastSchema.features.certManager.helm.cainjectorImage).isEqualTo('') + assertThat(cli.lastSchema.features.certManager.helm.acmeSolverImage).isEqualTo('') + assertThat(cli.lastSchema.features.certManager.helm.image).isEqualTo('localhost:30000/proxy/cert-manager-controller:latest') + } + + static String getLoggingPattern() { + loggingEncoder.pattern + } + + static PatternLayoutEncoder getLoggingEncoder() { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory() + def rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME) + def consoleAppender = rootLogger.getAppender('STDOUT') as ConsoleAppender + consoleAppender.getEncoder() as PatternLayoutEncoder + } + + void writeViaSystemIn(String value) { + ByteArrayInputStream inContent = new ByteArrayInputStream("${value}\n".getBytes()) + System.setIn(inContent) + } + + static String toYaml(Map map) { + yamlMapper.writeValueAsString(map) + } + + class GitopsPlaygroundCliForTest extends GitopsPlaygroundCli { + ApplicationContext applicationContext = mock(ApplicationContext) + Config lastSchema = null + + GitopsPlaygroundCliForTest() { + super(GitopsPlaygroundCliTest.this.k8sClient, GitopsPlaygroundCliTest.this.applicationConfigurator) + + when(applicationConfigurator.initConfig(any(Config))).thenAnswer(new Answer() { + @Override + Config answer(InvocationOnMock invocation) throws Throwable { + lastSchema = invocation.getArgument(0) + return lastSchema + } + }) + + } + + @Override + protected ApplicationContext createApplicationContext() { + when(applicationContext.getBean(Application)).thenReturn(application) + when(applicationContext.getBean(Destroyer)).thenReturn(destroyer) + + return applicationContext + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/config/schema/ConfigTest.groovy b/src/test/groovy/com/cloudogu/gitops/config/schema/ConfigTest.groovy index 73ed63343..30c84afe2 100644 --- a/src/test/groovy/com/cloudogu/gitops/config/schema/ConfigTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/config/schema/ConfigTest.groovy @@ -1,61 +1,59 @@ package com.cloudogu.gitops.config.schema +import static com.cloudogu.gitops.config.Config.* +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.Config -import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat -import static com.cloudogu.gitops.config.Config.* +import org.junit.jupiter.api.Test class ConfigTest { - Config testConfig = new Config(registry: new RegistrySchema( - twoRegistries: true, - internalPort: 123)) + Config testConfig = new Config(registry: new RegistrySchema(twoRegistries: true, + internalPort: 123)) - @Test - void 'converts to yaml including internals'() { - String config = testConfig.toYaml(true) + @Test + void 'converts to yaml including internals'() { + String config = testConfig.toYaml(true) - assertThat(config).startsWith("""--- + assertThat(config).startsWith("""--- registry: internal: true """) - } + } - @Test - void 'converts config map to yaml'() { + @Test + void 'converts config map to yaml'() { - String config = testConfig.toYaml(false) + String config = testConfig.toYaml(false) - assertThat(config).startsWith("""--- + assertThat(config).startsWith("""--- registry: active: false """) - } - - @Test - void 'creates from schema overwriting only Map values, ignoring null values'() { - Config expectedValues = new Config( - application: new ApplicationSchema( - // Overwrites a default String - username: 'myUser', - // Overwrites a default Boolean - yes: true, - // Sets an otherwise empty string - namePrefix: "aPrefix"), - // Overwrites a default Integer - registry: new RegistrySchema(internalPort: 42)) - - def actualValues = fromMap(expectedValues.toMap()) - - assertThat(actualValues.application.username).isEqualTo(expectedValues.application.username) - assertThat(actualValues.application.yes).isEqualTo(expectedValues.application.yes) - assertThat(actualValues.application.namePrefix).isEqualTo(expectedValues.application.namePrefix) - assertThat(actualValues.registry.internalPort).isEqualTo(expectedValues.registry.internalPort) - } - - @Test - void 'getting Tenantname from Config'(){ - testConfig.application.namePrefix='testprefix-' - assertThat(testConfig.application.getTenantName()).isEqualTo("testprefix") - } + } + + @Test + void 'creates from schema overwriting only Map values, ignoring null values'() { + Config expectedValues = new Config(application: new ApplicationSchema(// Overwrites a default String + username: 'myUser', + // Overwrites a default Boolean + yes: true, + // Sets an otherwise empty string + namePrefix: "aPrefix"), + // Overwrites a default Integer + registry: new RegistrySchema(internalPort: 42)) + + def actualValues = fromMap(expectedValues.toMap()) + + assertThat(actualValues.application.username).isEqualTo(expectedValues.application.username) + assertThat(actualValues.application.yes).isEqualTo(expectedValues.application.yes) + assertThat(actualValues.application.namePrefix).isEqualTo(expectedValues.application.namePrefix) + assertThat(actualValues.registry.internalPort).isEqualTo(expectedValues.registry.internalPort) + } + + @Test + void 'getting Tenantname from Config'() { + testConfig.application.namePrefix = 'testprefix-' + assertThat(testConfig.application.getTenantName()).isEqualTo("testprefix") + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/config/schema/JsonConfigValidatorTest.groovy b/src/test/groovy/com/cloudogu/gitops/config/schema/JsonConfigValidatorTest.groovy index 973f776b2..8a1433cbb 100644 --- a/src/test/groovy/com/cloudogu/gitops/config/schema/JsonConfigValidatorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/config/schema/JsonConfigValidatorTest.groovy @@ -1,74 +1,51 @@ package com.cloudogu.gitops.config.schema +import static groovy.test.GroovyAssert.shouldFail + +import java.util.stream.Stream import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource -import java.util.stream.Stream - -import static groovy.test.GroovyAssert.shouldFail - class JsonConfigValidatorTest { - static Stream validSchemas() { - Stream.Builder ret = Stream.builder() + static Stream validSchemas() { + Stream.Builder ret = Stream.builder() - ret.add(Arguments.of("multiple values", [ - features: [ - argocd: [ - url: "http://localhost/argocd" - ] - ] - ])) + ret.add(Arguments.of("multiple values", [features: [argocd: [url: "http://localhost/argocd"]]])) - return ret.build() - } + return ret.build() + } - @ParameterizedTest(name = "{0}") - @MethodSource("validSchemas") - void 'test valid schemas'(String description, Map schema) { + @ParameterizedTest(name = "{0}") + @MethodSource("validSchemas") + void 'test valid schemas'(String description, Map schema) { - JsonSchemaValidator.validate(schema) - } + JsonSchemaValidator.validate(schema) + } - static Stream invalidSchemas() { - Stream.Builder ret = Stream.builder() + static Stream invalidSchemas() { + Stream.Builder ret = Stream.builder() - ret.add(Arguments.of("wrong type for registry.internalPort", [ - registry: [ - internalPort: "this should be a number" - ] - ])) + ret.add(Arguments.of("wrong type for registry.internalPort", [registry: [internalPort: "this should be a number"]])) - ret.add(Arguments.of("invalid additional key within registry", [ - registry: [ - url: "", - unexpectedKey: "this should error" - ] - ])) + ret.add(Arguments.of("invalid additional key within registry", [registry: [url : "", + unexpectedKey: "this should error"]])) - ret.add(Arguments.of("invalid additional key on root level", [ - registry: [ - url: "", - ], - unexpectedKey: "this should not exist" - ])) + ret.add(Arguments.of("invalid additional key on root level", [registry : [url: "",], + unexpectedKey: "this should not exist"])) - ret.add(Arguments.of("specifying dynamic value", [ - application: [ - namePrefix: "prefix", - namePrefixForEnvVars: "prefix" - ], - ])) + ret.add(Arguments.of("specifying dynamic value", [application: [namePrefix : "prefix", + namePrefixForEnvVars: "prefix"],])) - return ret.build() - } + return ret.build() + } - @ParameterizedTest(name = "{0}") - @MethodSource("invalidSchemas") - void 'test invalid schemas'(String description, Map schema) { - shouldFail(RuntimeException) { - JsonSchemaValidator.validate(schema) - } - } -} + @ParameterizedTest(name = "{0}") + @MethodSource("invalidSchemas") + void 'test invalid schemas'(String description, Map schema) { + shouldFail(RuntimeException) { + JsonSchemaValidator.validate(schema) + } + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/config/schema/JsonSchemaGeneratorTest.groovy b/src/test/groovy/com/cloudogu/gitops/config/schema/JsonSchemaGeneratorTest.groovy index 3b489bdca..fae91d55f 100644 --- a/src/test/groovy/com/cloudogu/gitops/config/schema/JsonSchemaGeneratorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/config/schema/JsonSchemaGeneratorTest.groovy @@ -1,24 +1,24 @@ package com.cloudogu.gitops.config.schema +import static org.assertj.core.api.Assertions.assertThat import groovy.json.JsonOutput import groovy.json.JsonSlurper -import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test class JsonSchemaGeneratorTest { - @Test - void 'test configuration schema is not ouf of date'() { - // slurp and output to ensure consistent formatting - def slurper = new JsonSlurper() - def output = new JsonOutput() + @Test + void 'test configuration schema is not ouf of date'() { + // slurp and output to ensure consistent formatting + def slurper = new JsonSlurper() + def output = new JsonOutput() - def expect = output.toJson(slurper.parseText(new JsonSchemaGenerator().createSchema().toString())) - def actual = output.toJson(slurper.parse(new File(System.getProperty("user.dir"), "docs/configuration.schema.json"))) + def expect = output.toJson(slurper.parseText(new JsonSchemaGenerator().createSchema().toString())) + def actual = output.toJson(slurper.parse(new File(System.getProperty("user.dir"), "docs/configuration.schema.json"))) - assertThat(actual) - .as("Config in docs/configuration.schema.json must be updated. Run GenerateJsonSchema class.") - .isEqualTo(expect) - } -} + assertThat(actual) + .as("Config in docs/configuration.schema.json must be updated. Run GenerateJsonSchema class.") + .isEqualTo(expect) + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/destroy/DestroyerDependencyInjectionTest.groovy b/src/test/groovy/com/cloudogu/gitops/destroy/DestroyerDependencyInjectionTest.groovy index 40ccb59c1..e60b98eb0 100644 --- a/src/test/groovy/com/cloudogu/gitops/destroy/DestroyerDependencyInjectionTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/destroy/DestroyerDependencyInjectionTest.groovy @@ -1,33 +1,25 @@ package com.cloudogu.gitops.destroy import com.cloudogu.gitops.config.Config + import io.micronaut.context.ApplicationContext + import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test class DestroyerDependencyInjectionTest { - @Test - void 'can create bean'() { - def destroyer = ApplicationContext.run() - .registerSingleton(Config.fromMap([ - scm : [ - scmManager: [ - url : 'http://localhost:9091/scm', - username: 'admin', - password: 'admin' - ] - ], - jenkins : [ - url : 'http://localhost:9090', - username: 'admin', - password: 'admin', - ], - application: [ - insecure: true - ] - ])) - .getBean(Destroyer) + @Test + void 'can create bean'() { + def destroyer = ApplicationContext.run() + .registerSingleton(Config.fromMap([scm : [scmManager: [url : 'http://localhost:9091/scm', + username: 'admin', + password: 'admin']], + jenkins : [url : 'http://localhost:9090', + username: 'admin', + password: 'admin',], + application: [insecure: true]])) + .getBean(Destroyer) - Assertions.assertThat(destroyer.destructionHandlers).hasSize(3) - } -} + Assertions.assertThat(destroyer.destructionHandlers).hasSize(3) + } +} \ 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 f8b8dd277..20f4016da 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/CertManagerTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/CertManagerTest.groovy @@ -1,5 +1,11 @@ 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.git.GitHandler @@ -7,167 +13,153 @@ 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.verify -import static org.mockito.Mockito.when - @ExtendWith(MockitoExtension.class) class CertManagerTest { - String chartVersion = "1.16.1" - Config config = Config.fromMap([ - features: [ - certManager: [ - active: true, - helm : [ - chart : 'cert-manager', - repoURL: 'https://charts.jetstack.io', - version: chartVersion, - ], - ], - ], - ]) - - Path temporaryYamlFile - FileSystemUtils fileSystemUtils = new FileSystemUtils() - - @Mock - DeploymentStrategy deploymentStrategy - @Mock - AirGappedUtils airGappedUtils - @Mock - GitHandler gitHandler - @Mock - GitProvider gitProvider - - @Test - void 'Helm release is installed'() { - createCertManager().install() - - verify(deploymentStrategy).deployFeature('https://charts.jetstack.io', 'cert-manager', - 'cert-manager', chartVersion, 'cert-manager', - 'cert-manager', temporaryYamlFile, RepoType.HELM) - } - - @Test - void 'Sets pod resource limits and requests'() { - config.application.podResources = true - - createCertManager().install() - - assertThat(parseActualYaml()['resources'] as Map).containsKeys('limits', 'requests') - assertThat(parseActualYaml()['cainjector']['resources'] as Map).containsKeys('limits', 'requests') - assertThat(parseActualYaml()['webhook']['resources'] as Map).containsKeys('limits', 'requests') - } - - @Test - void "is disabled via active flag"() { - config.features.certManager.active = false - createCertManager().install() - assertThat(temporaryYamlFile).isNull() - } - - @Test - void 'helm release is installed in air-gapped mode'() { - when(gitHandler.getResourcesScm()).thenReturn(gitProvider) - when(gitProvider.repoUrl(any())).thenReturn("http://scmm.scm-manager.svc.cluster.local/scm/repo/a/b") - - 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 SourceChart = rootChartsFolder.resolve('cert-manager') - Files.createDirectories(SourceChart) - - Map ChartYaml = [version: chartVersion] - fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) - - createCertManager().install() - - def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) - verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) - assertThat(helmConfig.value.chart).isEqualTo('cert-manager') - // check existing value, but its not used in deploy. - assertThat(helmConfig.value.repoURL).isEqualTo('https://charts.jetstack.io') - assertThat(helmConfig.value.version).isEqualTo(chartVersion) - // important check: scmmRepoUrl is overridden with our values. - verify(deploymentStrategy).deployFeature( - 'http://scmm.scm-manager.svc.cluster.local/scm/repo/a/b', - 'cert-manager', '.', chartVersion, 'cert-manager', - 'cert-manager', temporaryYamlFile, RepoType.GIT) - } - - @Test - void 'check images are overriddes'() { - when(gitHandler.getResourcesScm()).thenReturn(gitProvider) - when(gitProvider.repoUrl(any())).thenReturn("http://test") - - // Prep - config.application.mirrorRepos = true - // test values - config.features.certManager.helm.image = "this.is.my.registry:30000/this.is.my.repository/myImage:1" - config.features.certManager.helm.webhookImage = "this.is.my.registry:30000/this.is.my.repository/myWebhook:2" - config.features.certManager.helm.cainjectorImage = "this.is.my.registry:30000/this.is.my.repository/myCainjectorImage:3" - config.features.certManager.helm.acmeSolverImage = "this.is.my.registry:30000/this.is.my.repository/myAcmeSolverImage:4" - config.features.certManager.helm.startupAPICheckImage = "this.is.my.registry:30000/this.is.my.repository/myStartupAPICheckImage:5" - 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('cert-manager') - Files.createDirectories(SourceChart) - - Map ChartYaml = [version: chartVersion] - fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) - createCertManager().install() - - def templateFile = parseActualYaml() - - // Cert-Manager - assertThat(parseActualYaml()['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myImage') - assertThat(parseActualYaml()['image']['tag'] as String).isEqualTo('1') - // myWebhook - assertThat(parseActualYaml()['webhook']['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myWebhook') - assertThat(parseActualYaml()['webhook']['image']['tag'] as String).isEqualTo('2') - // cainjectorImage - assertThat(parseActualYaml()['cainjector']['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myCainjectorImage') - assertThat(parseActualYaml()['cainjector']['image']['tag'] as String).isEqualTo('3') - // myWebhook - assertThat(parseActualYaml()['acmesolver']['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myAcmeSolverImage') - assertThat(parseActualYaml()['acmesolver']['image']['tag'] as String).isEqualTo('4') - // myWebhook - assertThat(parseActualYaml()['startupapicheck']['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myStartupAPICheckImage') - assertThat(parseActualYaml()['startupapicheck']['image']['tag'] as String).isEqualTo('5') - - } - - private CertManager createCertManager() { - // We use the real FileSystemUtils and not a mock to make sure file editing works as expected - new CertManager(config, new FileSystemUtils() { - @Override - Path writeTempFile(Map mapValues) { - def ret = super.writeTempFile(mapValues) - temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) - return ret - } - }, deploymentStrategy, new K8sClientForTest(config), airGappedUtils, gitHandler) - } - - private Map parseActualYaml() { - def ys = new YamlSlurper() - return ys.parse(temporaryYamlFile) as Map - } + String chartVersion = "1.16.1" + Config config = Config.fromMap([features: [certManager: [active: true, + helm : [chart : 'cert-manager', + repoURL: 'https://charts.jetstack.io', + version: chartVersion,],],],]) + + Path temporaryYamlFile + FileSystemUtils fileSystemUtils = new FileSystemUtils() + + @Mock + DeploymentStrategy deploymentStrategy + @Mock + AirGappedUtils airGappedUtils + @Mock + GitHandler gitHandler + @Mock + GitProvider gitProvider + + @Test + void 'Helm release is installed'() { + createCertManager().install() + + verify(deploymentStrategy).deployFeature('https://charts.jetstack.io', 'cert-manager', + 'cert-manager', chartVersion, 'cert-manager', + 'cert-manager', temporaryYamlFile, RepoType.HELM) + } + + @Test + void 'Sets pod resource limits and requests'() { + config.application.podResources = true + + createCertManager().install() + + assertThat(parseActualYaml()['resources'] as Map).containsKeys('limits', 'requests') + assertThat(parseActualYaml()['cainjector']['resources'] as Map).containsKeys('limits', 'requests') + assertThat(parseActualYaml()['webhook']['resources'] as Map).containsKeys('limits', 'requests') + } + + @Test + void "is disabled via active flag"() { + config.features.certManager.active = false + createCertManager().install() + assertThat(temporaryYamlFile).isNull() + } + + @Test + void 'helm release is installed in air-gapped mode'() { + when(gitHandler.getResourcesScm()).thenReturn(gitProvider) + when(gitProvider.repoUrl(any())).thenReturn("http://scmm.scm-manager.svc.cluster.local/scm/repo/a/b") + + 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 SourceChart = rootChartsFolder.resolve('cert-manager') + Files.createDirectories(SourceChart) + + Map ChartYaml = [version: chartVersion] + fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) + + createCertManager().install() + + def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) + verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) + assertThat(helmConfig.value.chart).isEqualTo('cert-manager') + // check existing value, but its not used in deploy. + assertThat(helmConfig.value.repoURL).isEqualTo('https://charts.jetstack.io') + assertThat(helmConfig.value.version).isEqualTo(chartVersion) + // important check: scmmRepoUrl is overridden with our values. + verify(deploymentStrategy).deployFeature('http://scmm.scm-manager.svc.cluster.local/scm/repo/a/b', + 'cert-manager', '.', chartVersion, 'cert-manager', + 'cert-manager', temporaryYamlFile, RepoType.GIT) + } + + @Test + void 'check images are overriddes'() { + when(gitHandler.getResourcesScm()).thenReturn(gitProvider) + when(gitProvider.repoUrl(any())).thenReturn("http://test") + + // Prep + config.application.mirrorRepos = true + // test values + config.features.certManager.helm.image = "this.is.my.registry:30000/this.is.my.repository/myImage:1" + config.features.certManager.helm.webhookImage = "this.is.my.registry:30000/this.is.my.repository/myWebhook:2" + config.features.certManager.helm.cainjectorImage = "this.is.my.registry:30000/this.is.my.repository/myCainjectorImage:3" + config.features.certManager.helm.acmeSolverImage = "this.is.my.registry:30000/this.is.my.repository/myAcmeSolverImage:4" + config.features.certManager.helm.startupAPICheckImage = "this.is.my.registry:30000/this.is.my.repository/myStartupAPICheckImage:5" + 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('cert-manager') + Files.createDirectories(SourceChart) + + Map ChartYaml = [version: chartVersion] + fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) + createCertManager().install() + + def templateFile = parseActualYaml() + + // Cert-Manager + assertThat(parseActualYaml()['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myImage') + assertThat(parseActualYaml()['image']['tag'] as String).isEqualTo('1') + // myWebhook + assertThat(parseActualYaml()['webhook']['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myWebhook') + assertThat(parseActualYaml()['webhook']['image']['tag'] as String).isEqualTo('2') + // cainjectorImage + assertThat(parseActualYaml()['cainjector']['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myCainjectorImage') + assertThat(parseActualYaml()['cainjector']['image']['tag'] as String).isEqualTo('3') + // myWebhook + assertThat(parseActualYaml()['acmesolver']['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myAcmeSolverImage') + assertThat(parseActualYaml()['acmesolver']['image']['tag'] as String).isEqualTo('4') + // myWebhook + assertThat(parseActualYaml()['startupapicheck']['image']['repository'] as String).isEqualTo('this.is.my.registry:30000/this.is.my.repository/myStartupAPICheckImage') + assertThat(parseActualYaml()['startupapicheck']['image']['tag'] as String).isEqualTo('5') + + } + + private CertManager createCertManager() { + // We use the real FileSystemUtils and not a mock to make sure file editing works as expected + new CertManager(config, new FileSystemUtils() { + @Override + Path writeTempFile(Map mapValues) { + def ret = super.writeTempFile(mapValues) + temporaryYamlFile = Path.of(ret.toString().replace(".ftl", "")) + return ret + } + }, deploymentStrategy, new K8sClientForTest(config), 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/ContentLoaderTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/ContentLoaderTest.groovy index 916a77bce..fd726fee1 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/ContentLoaderTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/ContentLoaderTest.groovy @@ -1,17 +1,32 @@ 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.git.GitHandler import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.kubernetes.api.K8sClient +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.TestGitRepoFactory import com.cloudogu.gitops.utils.git.ScmManagerMock +import com.cloudogu.gitops.utils.git.TestGitRepoFactory import com.cloudogu.gitops.utils.git.TestScmManagerApiClient -import com.cloudogu.gitops.utils.* + 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 @@ -28,958 +43,855 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.mockito.ArgumentCaptor -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) - - @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.content.examples = true - config.registry.createImagePullSecrets = true - config.content.namespaces = ['example-apps-staging', 'example-apps-production'] - - createContent().install() - - assertRegistrySecrets('reg-user', 'reg-pw') - } - - @Test - void 'deploys image pull secrets from read-only vars'() { - config.content.examples = true - config.registry.createImagePullSecrets = true - config.content.namespaces = ['example-apps-staging', 'example-apps-production'] - config.registry.readOnlyUsername = 'other-user' - config.registry.readOnlyPassword = 'other-pw' - - createContent().install() - - assertRegistrySecrets('other-user', 'other-pw') - } - - @Test - void 'deploys additional image pull secrets for proxy registry'() { - config.content.examples = true - 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().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().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().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() - 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() - 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().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().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().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().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().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().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().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().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().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) + + @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.content.examples = true + config.registry.createImagePullSecrets = true + config.content.namespaces = ['example-apps-staging', 'example-apps-production'] + + createContent().install() + + assertRegistrySecrets('reg-user', 'reg-pw') + } + + @Test + void 'deploys image pull secrets from read-only vars'() { + config.content.examples = true + config.registry.createImagePullSecrets = true + config.content.namespaces = ['example-apps-staging', 'example-apps-production'] + config.registry.readOnlyUsername = 'other-user' + config.registry.readOnlyPassword = 'other-pw' + + createContent().install() + + assertRegistrySecrets('other-user', 'other-pw') + } + + @Test + void 'deploys additional image pull secrets for proxy registry'() { + config.content.examples = true + 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().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().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().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() + 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() + 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().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().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().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().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().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().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().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().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().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().install() + // No exception means success + } + + @Test + void 'Parses Repo coordinates'() { + + config.content.repos = contentRepos + + def content = createContent() + def actualTargetRepos = content.cloneContentRepos() + def repos = actualTargetRepos - @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), - ] + assertThat(actualTargetRepos).hasSameSizeAs(expectedTargetRepos) - scmmApiClient.mockRepoApiBehaviour() - - createContent().install() - // No exception means success - } - - @Test - void 'Parses Repo coordinates'() { + expectedTargetRepos.each { expected -> - config.content.repos = contentRepos - - def content = createContent() - - def actualTargetRepos = content.cloneContentRepos() - def repos = actualTargetRepos + 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(actualTargetRepos).hasSameSizeAs(expectedTargetRepos) + assertThat(actual[0].clonedContentRepo.absolutePath).isEqualTo(new File(findRoot(repos), "${expected.namespace}/${expected.repoName}").absolutePath) + } + } - 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().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().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().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().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().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().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().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().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().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().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().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().install() - verify(jenkins, never()).createJenkinsjob(any(), any()) - } - - - 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 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() { - new ContentLoaderForTest(config, k8sClient, scmmRepoProvider, jenkins, gitHandler) - } - - 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 { - CloneCommand cloneSpy - - ContentLoaderForTest(Config config, K8sClient k8sClient, GitRepoFactory repoProvider, Jenkins jenkins, GitHandler gitHandler) { - super(config, k8sClient, repoProvider, jenkins, gitHandler) - } - - @Override - protected CloneCommand gitClone() { - cloneSpy = spy(super.gitClone().setNoCheckout(true)) - } - } + @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().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().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().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().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().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().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().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().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().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().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().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().install() + verify(jenkins, never()).createJenkinsjob(any(), any()) + } + + 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 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() { + new ContentLoaderForTest(config, k8sClient, scmmRepoProvider, jenkins, gitHandler) + } + + 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 { + CloneCommand cloneSpy + + ContentLoaderForTest(Config config, K8sClient k8sClient, GitRepoFactory repoProvider, Jenkins jenkins, GitHandler gitHandler) { + super(config, k8sClient, repoProvider, jenkins, gitHandler) + } + + @Override + protected CloneCommand gitClone() { + cloneSpy = spy(super.gitClone().setNoCheckout(true)) + } + } } \ 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..efaab3c33 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/ExternalSecretsOperatorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/ExternalSecretsOperatorTest.groovy @@ -1,5 +1,11 @@ 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.git.GitHandler @@ -8,183 +14,167 @@ 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 + 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 + } } \ 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..674b26906 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/IngressTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/IngressTest.groovy @@ -1,5 +1,11 @@ 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.git.GitHandler @@ -7,204 +13,188 @@ 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 + DeploymentStrategy deploymentStrategy + @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(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') + + } + + @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(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) + } - 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 + } + }, deploymentStrategy, 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 3704d4f1f..4c86807c0 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/JenkinsTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/JenkinsTest.groovy @@ -1,370 +1,359 @@ 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.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) + 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(''' 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(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')) - 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.content.examples = 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.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('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.content.examples = 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.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(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()) - } + 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.content.examples = 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.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('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.content.examples = 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.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(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'() { - @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()) - } + 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 - } + @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 + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/MailTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/MailTest.groovy index 74a62b9c3..7c9fb696e 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/MailTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/MailTest.groovy @@ -1,229 +1,213 @@ 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.git.GitHandler -import com.cloudogu.gitops.utils.git.ScmManagerMock import com.cloudogu.gitops.utils.AirGappedUtils import com.cloudogu.gitops.utils.FileSystemUtils -import com.cloudogu.gitops.utils.git.GitHandlerForTests import com.cloudogu.gitops.utils.K8sClientForTest +import com.cloudogu.gitops.utils.git.GitHandlerForTests +import com.cloudogu.gitops.utils.git.ScmManagerMock + +import java.nio.file.Files +import java.nio.file.Path import groovy.yaml.YamlSlurper + import org.junit.jupiter.api.Test import org.mockito.ArgumentCaptor import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder -import java.nio.file.Files -import java.nio.file.Path +class MailTest { -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.* + Config config = Config.fromMap([application: [namePrefix: "foo-"], + features : [mail: [mailServer: true + + ]]]) + + DeploymentStrategy deploymentStrategy = mock(DeploymentStrategy) + AirGappedUtils airGappedUtils = mock(AirGappedUtils) + Path temporaryYamlFile = null + FileSystemUtils fileSystemUtils = new FileSystemUtils() + K8sClientForTest k8sClient = new K8sClientForTest(config) + GitHandler gitHandler = new GitHandlerForTests(config, new ScmManagerMock()) + + @Test + void "is disabled via active flag"() { + config.features.mail.mailServer = false + createMail().install() + assertThat(temporaryYamlFile).isNull() + } + + @Test + void 'uses ingress if enabled'() { + config.features.mail.mailUrl = 'http://mail.local' + createMail().install() + + def ingressYaml = parseActualYaml()['ingress'] + assertThat(ingressYaml['enabled']).isEqualTo(true) + assertThat((ingressYaml['hosts'] as List)[0]['host']).isEqualTo('mail.local') + } + + @Test + void 'does not use ingress by default'() { + createMail().install() -class MailTest { + assertThat(parseActualYaml()).doesNotContainKey('ingress') + } - Config config = Config.fromMap([ - application: [ - namePrefix: "foo-" - ], - features : [ - mail: [ - mailServer: true - - ] - ] - ]) - - - DeploymentStrategy deploymentStrategy = mock(DeploymentStrategy) - AirGappedUtils airGappedUtils = mock(AirGappedUtils) - Path temporaryYamlFile = null - FileSystemUtils fileSystemUtils = new FileSystemUtils() - K8sClientForTest k8sClient = new K8sClientForTest(config) - GitHandler gitHandler = new GitHandlerForTests(config, new ScmManagerMock()) - - @Test - void "is disabled via active flag"() { - config.features.mail.mailServer = false - createMail().install() - assertThat(temporaryYamlFile).isNull() - } - - @Test - void 'uses ingress if enabled'() { - config.features.mail.mailUrl = 'http://mail.local' - createMail().install() - - def ingressYaml = parseActualYaml()['ingress'] - assertThat(ingressYaml['enabled']).isEqualTo(true) - assertThat((ingressYaml['hosts'] as List)[0]['host']).isEqualTo('mail.local') - } - - @Test - void 'does not use ingress by default'() { - createMail().install() - - assertThat(parseActualYaml()).doesNotContainKey('ingress') - } - - @Test - void 'Password and username can be changed'() { - String expectedUsername = 'user42' - String expectedPassword = '12345' - config.application.username = expectedUsername - config.application.password = expectedPassword - createMail().install() - - String fileContents = parseActualYaml()['auth']['fileContents'] - String actualPasswordBcrypted = ((fileContents =~ /^[^:]*:(.*)$/)[0] as List)[1] - new BCryptPasswordEncoder().matches(expectedPassword, actualPasswordBcrypted) - assertThat(new BCryptPasswordEncoder().matches(expectedPassword, actualPasswordBcrypted)).isTrue() - .withFailMessage("Expected password does not match actual hash") - } - - @Test - void 'When argocd disabled, mailhog is deployed imperatively via helm'() { - config.features.argocd.active = false - - createMail().install() - - verify(deploymentStrategy).deployFeature( - 'https://codecentric.github.io/helm-charts', - 'mailhog', - 'mailhog', - '5.0.1', - 'foo-monitoring', - 'mailhog', - temporaryYamlFile, - RepoType.HELM - - ) - - assertThat(parseActualYaml()).doesNotContainKey('resources') - assertThat(parseActualYaml()).doesNotContainKey('imagePullSecrets') - } - - @Test - void 'Sets pod resource limits and requests'() { - config.application.podResources = true - - createMail().install() - - assertThat(parseActualYaml()['resources'] as Map).containsKeys('limits', 'requests') - } - - @Test - void 'When argoCD enabled, mailhog is deployed natively via argoCD'() { - config.features.argocd.active = true - - createMail().install() - } - - @Test - void 'Allows overriding the image'() { - config.features.mail.helm.image = 'abc:42' - - createMail().install() - assertThat(parseActualYaml()['image']['repository']).isEqualTo('abc') - assertThat(parseActualYaml()['image']['tag']).isEqualTo(42) - } - - @Test - void 'Image is optional'() { - config.features.mail.helm.image = '' - - createMail().install() - assertThat(parseActualYaml()['image']).isNull() - - config.features.mail.helm.image = null - - createMail().install() - assertThat(parseActualYaml()['image']).isNull() - } - - @Test - void 'custom values are injected correctly'() { - config.features.mail.helm.values = [ - "containerPort": [ - "http": [ - "port": 9849003 //huge impossible port so it will not match any other configs - ] - ] - ] - createMail().install() - assertThat(parseActualYaml()['containerPort'] as String).contains('9849003') - - } - - - @Test - void 'helm release is 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 SourceChart = rootChartsFolder.resolve('mailhog') - Files.createDirectories(SourceChart) - - Map ChartYaml = [version: '1.2.3'] - fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) - - createMail().install() - - def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) - verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) - assertThat(helmConfig.value.chart).isEqualTo('mailhog') - assertThat(helmConfig.value.repoURL).isEqualTo('https://codecentric.github.io/helm-charts') - assertThat(helmConfig.value.version).isEqualTo('5.0.1') - verify(deploymentStrategy).deployFeature( - 'http://scmm.scm-manager.svc.cluster.local/scm/repo/a/b', - 'mailhog', '.', '1.2.3', 'foo-monitoring', - 'mailhog', 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' - - createMail().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()['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) - } - - @Test - void 'empty security context in openshift'() { - config.application.openshift = true - createMail().install() - assertThat(parseActualYaml()['securityContext']['fsGroup']).isEqualTo(null) - assertThat(parseActualYaml()['securityContext']['runAsUser']).isEqualTo(null) - assertThat(parseActualYaml()['securityContext']['runAsGroup']).isEqualTo(null) - } - - private Mail createMail() { - // We use the real FileSystemUtils and not a mock to make sure file editing works as expected - - new Mail(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 'Password and username can be changed'() { + String expectedUsername = 'user42' + String expectedPassword = '12345' + config.application.username = expectedUsername + config.application.password = expectedPassword + createMail().install() + + String fileContents = parseActualYaml()['auth']['fileContents'] + String actualPasswordBcrypted = ((fileContents =~ /^[^:]*:(.*)$/)[0] as List)[1] + new BCryptPasswordEncoder().matches(expectedPassword, actualPasswordBcrypted) + assertThat(new BCryptPasswordEncoder().matches(expectedPassword, actualPasswordBcrypted)).isTrue() + .withFailMessage("Expected password does not match actual hash") + } + + @Test + void 'When argocd disabled, mailhog is deployed imperatively via helm'() { + config.features.argocd.active = false + + createMail().install() + + verify(deploymentStrategy).deployFeature('https://codecentric.github.io/helm-charts', + 'mailhog', + 'mailhog', + '5.0.1', + 'foo-monitoring', + 'mailhog', + temporaryYamlFile, + RepoType.HELM + + ) + + assertThat(parseActualYaml()).doesNotContainKey('resources') + assertThat(parseActualYaml()).doesNotContainKey('imagePullSecrets') + } + + @Test + void 'Sets pod resource limits and requests'() { + config.application.podResources = true + + createMail().install() + + assertThat(parseActualYaml()['resources'] as Map).containsKeys('limits', 'requests') + } + + @Test + void 'When argoCD enabled, mailhog is deployed natively via argoCD'() { + config.features.argocd.active = true + + createMail().install() + } + + @Test + void 'Allows overriding the image'() { + config.features.mail.helm.image = 'abc:42' + + createMail().install() + assertThat(parseActualYaml()['image']['repository']).isEqualTo('abc') + assertThat(parseActualYaml()['image']['tag']).isEqualTo(42) + } + + @Test + void 'Image is optional'() { + config.features.mail.helm.image = '' + + createMail().install() + assertThat(parseActualYaml()['image']).isNull() + + config.features.mail.helm.image = null + + createMail().install() + assertThat(parseActualYaml()['image']).isNull() + } + + @Test + void 'custom values are injected correctly'() { + config.features.mail.helm.values = ["containerPort": ["http": ["port": 9849003 //huge impossible port so it will not match any other configs + ]]] + createMail().install() + assertThat(parseActualYaml()['containerPort'] as String).contains('9849003') + + } + + @Test + void 'helm release is 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 SourceChart = rootChartsFolder.resolve('mailhog') + Files.createDirectories(SourceChart) + + Map ChartYaml = [version: '1.2.3'] + fileSystemUtils.writeYaml(ChartYaml, SourceChart.resolve('Chart.yaml').toFile()) + + createMail().install() + + def helmConfig = ArgumentCaptor.forClass(Config.HelmConfig) + verify(airGappedUtils).mirrorHelmRepoToGit(helmConfig.capture()) + assertThat(helmConfig.value.chart).isEqualTo('mailhog') + assertThat(helmConfig.value.repoURL).isEqualTo('https://codecentric.github.io/helm-charts') + assertThat(helmConfig.value.version).isEqualTo('5.0.1') + verify(deploymentStrategy).deployFeature('http://scmm.scm-manager.svc.cluster.local/scm/repo/a/b', + 'mailhog', '.', '1.2.3', 'foo-monitoring', + 'mailhog', 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' + + createMail().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()['imagePullSecrets']).isEqualTo([[name: 'proxy-registry']]) + } + + @Test + void 'empty security context in openshift'() { + config.application.openshift = true + createMail().install() + assertThat(parseActualYaml()['securityContext']['fsGroup']).isEqualTo(null) + assertThat(parseActualYaml()['securityContext']['runAsUser']).isEqualTo(null) + assertThat(parseActualYaml()['securityContext']['runAsGroup']).isEqualTo(null) + } + + private Mail createMail() { + // We use the real FileSystemUtils and not a mock to make sure file editing works as expected + + new Mail(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 + } } \ 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 134f277db..259222e88 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/MonitoringTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/MonitoringTest.groovy @@ -1,172 +1,140 @@ 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.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 - ], - mail : [ - mailServer: 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. - config.features.mail.mailServer = false - 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], + mail : [mailServer: 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. + config.features.mail.mailServer = false + 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 @@ -177,11 +145,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 @@ -190,475 +155,455 @@ 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&/+*~') + } + + @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') + } - 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() + @Test + void 'When external Mailserver is NOT set'() { + config.features.mail.active = null // user should not do this in real. + config.features.mail.mailServer = false + createStack(scmManagerMock).install() + def contactPointsYaml = parseActualYaml() - 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&/+*~') - } - - @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. - config.features.mail.mailServer = false - 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(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(''' matchExpressions: - key: kubernetes.io/metadata.name operator: In @@ -668,46 +613,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 + } + }, deploymentStrategy, 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..3168cbdf0 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/RegistryTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/RegistryTest.groovy @@ -1,92 +1,83 @@ 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.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 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..d35c97806 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.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() + 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 + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDRepoSetupTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDRepoSetupTest.groovy index 1871b8530..678f85d64 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDRepoSetupTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDRepoSetupTest.groovy @@ -1,215 +1,202 @@ package com.cloudogu.gitops.features.argocd +import static org.assertj.core.api.Assertions.assertThat +import static org.junit.jupiter.api.Assertions.assertThrows + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.git.GitHandlerForTests import com.cloudogu.gitops.utils.git.TestGitProvider import com.cloudogu.gitops.utils.git.TestGitRepoFactory -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test import java.nio.file.Path -import static org.assertj.core.api.Assertions.assertThat -import static org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test class ArgoCDRepoSetupTest { - Config config - GitProvider tenantProvider - GitProvider centralProvider - - @BeforeEach - void setUp() { - config = Config.fromMap( - application: [ - namePrefix: '', - netpols : true, - namespaces: [ - dedicatedNamespaces: ["argocd", "monitoring", "ingress-nginx", "secrets"], - tenantNamespaces : ["example-apps-staging", "example-apps-production"] - ] - ], - scm: [ - scmManager: [internal: true], - gitlab : [url: ''] - ], - multiTenant: [ - scmManager : [url: ''], - gitlab : [url: ''], - useDedicatedInstance : false, - centralArgocdNamespace: 'argocd' - ], - features: [ - argocd : [ - operator : false, - active : true, - namespace: 'argocd' - ], - certManager : [active: false], - ingress: [active: true], - monitoring : [active: true, helm: [chart: 'kube-prometheus-stack', version: '42.0.3']], - mail : [active: false], - secrets : [active: true], - ] - ) - - def providers = TestGitProvider.buildProviders(config) - tenantProvider = providers.tenant as GitProvider - centralProvider = providers.central as GitProvider - } - - private ArgoCDRepoSetup createSetup(FileSystemUtils fs) { - def repoFactory = new TestGitRepoFactory(config, new FileSystemUtils()) - repoFactory.defaultProvider = tenantProvider - - def gitHandler = new GitHandlerForTests(config, tenantProvider, centralProvider) - return ArgoCDRepoSetup.create(config, fs, repoFactory, gitHandler) - } - - @Test - void 'create() single instance creates only cluster-resources and no tenantBootstrap'() { - config.multiTenant.useDedicatedInstance = false - - def setup = createSetup(new FileSystemUtils()) - - assertThat(setup.tenantBootstrap).isNull() - assertThat(setup.clusterResources).isNotNull() - assertThat(setup.allRepos).hasSize(1) - assertThat(setup.clusterResources.repo.repoTarget).isEqualTo('argocd/cluster-resources') - } - - @Test - void 'create() dedicated instance creates tenantBootstrap and clusterResources'() { - config.multiTenant.useDedicatedInstance = true - - def setup = createSetup(new FileSystemUtils()) - - assertThat(setup.tenantBootstrap).isNotNull() - assertThat(setup.clusterResources).isNotNull() - assertThat(setup.allRepos).hasSize(2) - } - - @Test - void 'tenantRepoLayout throws in single instance mode'() { - config.multiTenant.useDedicatedInstance = false - - def setup = createSetup(new FileSystemUtils()) - - assertThrows(IllegalStateException) { - setup.tenantRepoLayout() - } - } - - @Test - void 'prepareClusterResourcesRepo deletes helmDir when operator is enabled'() { - config.features.argocd.operator = true - config.multiTenant.useDedicatedInstance = false - config.application.netpols = true - def setup = createSetup(new FileSystemUtils()) - - setup.initLocalRepos() - setup.prepareClusterResourcesRepo() - - def clusterRepoLayout = setup.clusterRepoLayout() - assertThat(Path.of(clusterRepoLayout.helmDir())).doesNotExist() - } - - @Test - void 'prepareClusterResourcesRepo deletes operatorDir when operator is disabled'() { - config.features.argocd.operator = false - config.multiTenant.useDedicatedInstance = false - config.application.netpols = true - - def setup = createSetup(new FileSystemUtils()) - - setup.initLocalRepos() - setup.prepareClusterResourcesRepo() - - def clusterRepoLayout = setup.clusterRepoLayout() - assertThat(Path.of(clusterRepoLayout.operatorDir())).doesNotExist() - assertThat(Path.of(clusterRepoLayout.helmDir())).exists() - - } - - @Test - void 'prepareClusterResourcesRepo in dedicated mode deletes multiTenant folder'() { - config.features.argocd.operator = false - config.multiTenant.useDedicatedInstance = true - config.application.netpols = true - - def setup = createSetup(new FileSystemUtils()) - - setup.initLocalRepos() - setup.prepareClusterResourcesRepo() - - def clusterRepoLayout = setup.clusterRepoLayout() - - assertThat(Path.of(clusterRepoLayout.applicationsDir())).exists() - assertThat(Path.of(clusterRepoLayout.projectsDir())).exists() - assertThat(Path.of(clusterRepoLayout.multiTenantDir())).doesNotExist() - } + Config config + GitProvider tenantProvider + GitProvider centralProvider + + @BeforeEach + void setUp() { + config = Config.fromMap(application: [namePrefix: '', + netpols : true, + namespaces: [dedicatedNamespaces: ["argocd", "monitoring", "ingress-nginx", "secrets"], + tenantNamespaces : ["example-apps-staging", "example-apps-production"]]], + scm: [scmManager: [internal: true], + gitlab : [url: '']], + multiTenant: [scmManager : [url: ''], + gitlab : [url: ''], + useDedicatedInstance : false, + centralArgocdNamespace: 'argocd'], + features: [argocd : [operator : false, + active : true, + namespace: 'argocd'], + certManager: [active: false], + ingress : [active: true], + monitoring : [active: true, helm: [chart: 'kube-prometheus-stack', version: '42.0.3']], + mail : [active: false], + secrets : [active: true],]) + + def providers = TestGitProvider.buildProviders(config) + tenantProvider = providers.tenant as GitProvider + centralProvider = providers.central as GitProvider + } + + private ArgoCDRepoSetup createSetup(FileSystemUtils fs) { + def repoFactory = new TestGitRepoFactory(config, new FileSystemUtils()) + repoFactory.defaultProvider = tenantProvider + + def gitHandler = new GitHandlerForTests(config, tenantProvider, centralProvider) + return ArgoCDRepoSetup.create(config, fs, repoFactory, gitHandler) + } + + @Test + void 'create() single instance creates only cluster-resources and no tenantBootstrap'() { + config.multiTenant.useDedicatedInstance = false + + def setup = createSetup(new FileSystemUtils()) + + assertThat(setup.tenantBootstrap).isNull() + assertThat(setup.clusterResources).isNotNull() + assertThat(setup.allRepos).hasSize(1) + assertThat(setup.clusterResources.repo.repoTarget).isEqualTo('argocd/cluster-resources') + } + + @Test + void 'create() dedicated instance creates tenantBootstrap and clusterResources'() { + config.multiTenant.useDedicatedInstance = true + + def setup = createSetup(new FileSystemUtils()) + + assertThat(setup.tenantBootstrap).isNotNull() + assertThat(setup.clusterResources).isNotNull() + assertThat(setup.allRepos).hasSize(2) + } + + @Test + void 'tenantRepoLayout throws in single instance mode'() { + config.multiTenant.useDedicatedInstance = false + + def setup = createSetup(new FileSystemUtils()) + + assertThrows(IllegalStateException) { + setup.tenantRepoLayout() + } + } + + @Test + void 'prepareClusterResourcesRepo deletes helmDir when operator is enabled'() { + config.features.argocd.operator = true + config.multiTenant.useDedicatedInstance = false + config.application.netpols = true + def setup = createSetup(new FileSystemUtils()) + + setup.initLocalRepos() + setup.prepareClusterResourcesRepo() + + def clusterRepoLayout = setup.clusterRepoLayout() + assertThat(Path.of(clusterRepoLayout.helmDir())).doesNotExist() + } + + @Test + void 'prepareClusterResourcesRepo deletes operatorDir when operator is disabled'() { + config.features.argocd.operator = false + config.multiTenant.useDedicatedInstance = false + config.application.netpols = true + + def setup = createSetup(new FileSystemUtils()) + + setup.initLocalRepos() + setup.prepareClusterResourcesRepo() + + def clusterRepoLayout = setup.clusterRepoLayout() + assertThat(Path.of(clusterRepoLayout.operatorDir())).doesNotExist() + assertThat(Path.of(clusterRepoLayout.helmDir())).exists() + + } + + @Test + void 'prepareClusterResourcesRepo in dedicated mode deletes multiTenant folder'() { + config.features.argocd.operator = false + config.multiTenant.useDedicatedInstance = true + config.application.netpols = true + + def setup = createSetup(new FileSystemUtils()) + + setup.initLocalRepos() + setup.prepareClusterResourcesRepo() + + def clusterRepoLayout = setup.clusterRepoLayout() + + assertThat(Path.of(clusterRepoLayout.applicationsDir())).exists() + assertThat(Path.of(clusterRepoLayout.projectsDir())).exists() + assertThat(Path.of(clusterRepoLayout.multiTenantDir())).doesNotExist() + } - @Test - void 'prepareClusterResourcesRepo in single instance deletes multiTenant folder'() { - config.features.argocd.operator = false - config.multiTenant.useDedicatedInstance = false - config.application.netpols = true + @Test + void 'prepareClusterResourcesRepo in single instance deletes multiTenant folder'() { + config.features.argocd.operator = false + config.multiTenant.useDedicatedInstance = false + config.application.netpols = true - def setup = createSetup(new FileSystemUtils()) + def setup = createSetup(new FileSystemUtils()) - setup.initLocalRepos() - setup.prepareClusterResourcesRepo() + setup.initLocalRepos() + setup.prepareClusterResourcesRepo() - def clusterRepoLayout = setup.clusterRepoLayout() - assertThat(Path.of(clusterRepoLayout.multiTenantDir())).doesNotExist() - } + def clusterRepoLayout = setup.clusterRepoLayout() + assertThat(Path.of(clusterRepoLayout.multiTenantDir())).doesNotExist() + } - @Test - void 'prepareClusterResourcesRepo deletes netpol file when netpols disabled'() { - config.application.netpols = false + @Test + void 'prepareClusterResourcesRepo deletes netpol file when netpols disabled'() { + config.application.netpols = false - def setup = createSetup(new FileSystemUtils()) + def setup = createSetup(new FileSystemUtils()) - setup.initLocalRepos() - setup.prepareClusterResourcesRepo() + setup.initLocalRepos() + setup.prepareClusterResourcesRepo() - def clusterRepoLayout = setup.clusterRepoLayout() - assertThat(Path.of(clusterRepoLayout.netpolFile())).doesNotExist() - } + def clusterRepoLayout = setup.clusterRepoLayout() + assertThat(Path.of(clusterRepoLayout.netpolFile())).doesNotExist() + } - @Test - void 'create() sets subDirsToCopy based on enabled features'() { - config.features.ingress.active = true - config.features.monitoring.active = false - config.features.secrets.active = false - config.jenkins.active = false - config.features.mail.active = false - config.features.certManager.active = false + @Test + void 'create() sets subDirsToCopy based on enabled features'() { + config.features.ingress.active = true + config.features.monitoring.active = false + config.features.secrets.active = false + config.jenkins.active = false + config.features.mail.active = false + config.features.certManager.active = false - def setup = createSetup(new FileSystemUtils()) - def dirs = setup.clusterResources.subDirsToCopy as Set + def setup = createSetup(new FileSystemUtils()) + def dirs = setup.clusterResources.subDirsToCopy as Set - assertThat(dirs).contains(RepoLayout.argocdSubdirRel()) - assertThat(dirs).contains(RepoLayout.ingressSubdirRel()) + assertThat(dirs).contains(RepoLayout.argocdSubdirRel()) + assertThat(dirs).contains(RepoLayout.ingressSubdirRel()) - assertThat(dirs).doesNotContain(RepoLayout.monitoringSubdirRel()) - assertThat(dirs).doesNotContain(RepoLayout.secretsSubdirRel()) - assertThat(dirs).doesNotContain(RepoLayout.vaultSubdirRel()) - assertThat(dirs).doesNotContain(RepoLayout.jenkinsSubdirRel()) - assertThat(dirs).doesNotContain(RepoLayout.mailhogSubdirRel()) - assertThat(dirs).doesNotContain(RepoLayout.certManagerSubdirRel()) - } + assertThat(dirs).doesNotContain(RepoLayout.monitoringSubdirRel()) + assertThat(dirs).doesNotContain(RepoLayout.secretsSubdirRel()) + assertThat(dirs).doesNotContain(RepoLayout.vaultSubdirRel()) + assertThat(dirs).doesNotContain(RepoLayout.jenkinsSubdirRel()) + assertThat(dirs).doesNotContain(RepoLayout.mailhogSubdirRel()) + assertThat(dirs).doesNotContain(RepoLayout.certManagerSubdirRel()) + } - @Test - void 'create() includes secrets + vault subdirs when secrets feature active'() { - config.features.secrets.active = true + @Test + void 'create() includes secrets + vault subdirs when secrets feature active'() { + config.features.secrets.active = true - def setup = createSetup(new FileSystemUtils()) - def dirs = setup.clusterResources.subDirsToCopy as Set + def setup = createSetup(new FileSystemUtils()) + def dirs = setup.clusterResources.subDirsToCopy as Set - assertThat(dirs).contains(RepoLayout.secretsSubdirRel()) - assertThat(dirs).contains(RepoLayout.vaultSubdirRel()) - } -} + assertThat(dirs).contains(RepoLayout.secretsSubdirRel()) + assertThat(dirs).contains(RepoLayout.vaultSubdirRel()) + } +} \ 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 d5fd781fd..6fee73e58 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/argocd/ArgoCDTest.groovy @@ -1,1559 +1,1444 @@ 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: [examples : true, + variables: [images: [buildImages + [petclinic: 'petclinic-value']]]], + features: [argocd : [operator : false, + active : true, + configOnly : true, + emailFrom : 'argocd@example.org', + emailToUser : 'app-team@example.org', + emailToAdmin : 'infra@example.org', + resourceInclusionsCluster: ''], + mail : [mailServer: true,], + 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 + config.features.mail.mailServer = 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)['host']) doesNotHaveToString('mailhog.*monitoring.svc.cluster.local') + 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 = ['docker.withRegistry("https://${dockerRegistryProxyBaseUrl}", 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: [ - examples : true, - variables: [ - images: [ - buildImages + [petclinic: 'petclinic-value'] - ] - ] - ], - features: [ - argocd : [ - operator : false, - active : true, - configOnly : true, - emailFrom : 'argocd@example.org', - emailToUser : 'app-team@example.org', - emailToAdmin : 'infra@example.org', - resourceInclusionsCluster: '' - ], - mail : [ - mailServer: true, - ], - 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 - config.features.mail.mailServer = 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)['host']) doesNotHaveToString('mailhog.*monitoring.svc.cluster.local') - 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 = [ - 'docker.withRegistry("https://${dockerRegistryProxyBaseUrl}", 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/mailhog', - '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/mailhog.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/mailhog', - '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/mailhog.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/mailhog.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/mailhog.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/mailhog', - '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/mailhog', - '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/mailhog.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.examples ? 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/mailhog', + '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/mailhog.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/mailhog', + '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/mailhog.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/mailhog.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/mailhog.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/mailhog', + '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/mailhog', + '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/mailhog.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.examples ? 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 + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategyTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategyTest.groovy index 7e1996ee1..aad6da63d 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategyTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/deployment/ArgoCdApplicationStrategyTest.groovy @@ -1,33 +1,35 @@ package com.cloudogu.gitops.features.deployment +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.git.GitHandler import com.cloudogu.gitops.features.git.config.ScmTenantSchema import com.cloudogu.gitops.features.git.config.ScmTenantSchema.ScmManagerTenantConfig import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.git.providers.GitProvider -import com.cloudogu.gitops.utils.git.ScmManagerMock import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.git.GitHandlerForTests +import com.cloudogu.gitops.utils.git.ScmManagerMock import com.cloudogu.gitops.utils.git.TestGitRepoFactory + import groovy.yaml.YamlSlurper -import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test class ArgoCdApplicationStrategyTest { - private File localTempDir - GitHandler gitHandler = new GitHandlerForTests(new Config(), new ScmManagerMock()) - - @Test - void 'deploys feature using argo CD'() { - def strategy = createStrategy() - File valuesYaml = File.createTempFile('values', 'yaml') - strategy.deployFeature("repoURL", "repoName", "chartName", "version", - "foo-namespace", "releaseName", valuesYaml.toPath()) - - def argoCdApplicationYaml = new File("$localTempDir/apps/argocd/applications/releaseName.yaml") - assertThat(argoCdApplicationYaml.text).isEqualTo("""--- + private File localTempDir + GitHandler gitHandler = new GitHandlerForTests(new Config(), new ScmManagerMock()) + + @Test + void 'deploys feature using argo CD'() { + def strategy = createStrategy() + File valuesYaml = File.createTempFile('values', 'yaml') + strategy.deployFeature("repoURL", "repoName", "chartName", "version", + "foo-namespace", "releaseName", valuesYaml.toPath()) + + def argoCdApplicationYaml = new File("$localTempDir/apps/argocd/applications/releaseName.yaml") + assertThat(argoCdApplicationYaml.text).isEqualTo("""--- apiVersion: "argoproj.io/v1alpha1" kind: "Application" metadata: @@ -62,82 +64,70 @@ spec: - "ServerSideApply=true" - "CreateNamespace=true" """) - } - - @Test - void 'deploys feature using argo CD from git repo'() { - def strategy = createStrategy() - File valuesYaml = File.createTempFile('values', 'yaml') - strategy.deployFeature("repoURL", "repoName", "chartName", "version", - "namespace", "releaseName", valuesYaml.toPath(), DeploymentStrategy.RepoType.GIT) - - def argoCdApplicationYaml = new File("$localTempDir/apps/argocd/applications/releaseName.yaml") - def result = new YamlSlurper().parse(argoCdApplicationYaml) - def sources = result['spec']['sources'] as List - assertThat(sources[0] as Map).containsKey('path') - assertThat(sources[0]['path']).isEqualTo('chartName') - } - - @Test - void 'deploys feature with argocdOperator true, setting CreateNamespace to false'() { - def strategy = createStrategy(true) - File valuesYaml = File.createTempFile('values', 'yaml') - valuesYaml.text = """ + } + + @Test + void 'deploys feature using argo CD from git repo'() { + def strategy = createStrategy() + File valuesYaml = File.createTempFile('values', 'yaml') + strategy.deployFeature("repoURL", "repoName", "chartName", "version", + "namespace", "releaseName", valuesYaml.toPath(), DeploymentStrategy.RepoType.GIT) + + def argoCdApplicationYaml = new File("$localTempDir/apps/argocd/applications/releaseName.yaml") + def result = new YamlSlurper().parse(argoCdApplicationYaml) + def sources = result['spec']['sources'] as List + assertThat(sources[0] as Map).containsKey('path') + assertThat(sources[0]['path']).isEqualTo('chartName') + } + + @Test + void 'deploys feature with argocdOperator true, setting CreateNamespace to false'() { + def strategy = createStrategy(true) + File valuesYaml = File.createTempFile('values', 'yaml') + valuesYaml.text = """ param1: value1 param2: value2 """ - strategy.deployFeature("repoURL", "repoName", "chartName", "version", - "namespace", "releaseName", valuesYaml.toPath()) - - def argoCdApplicationYaml = new File("$localTempDir/apps/argocd/applications/releaseName.yaml") - assertThat(argoCdApplicationYaml.text).contains("CreateNamespace=false") - } - - @Test - void 'deploys feature with argocdOperator false, setting CreateNamespace to true'() { - def strategy = createStrategy(false) - File valuesYaml = File.createTempFile('values', 'yaml') - valuesYaml.text = """ + strategy.deployFeature("repoURL", "repoName", "chartName", "version", + "namespace", "releaseName", valuesYaml.toPath()) + + def argoCdApplicationYaml = new File("$localTempDir/apps/argocd/applications/releaseName.yaml") + assertThat(argoCdApplicationYaml.text).contains("CreateNamespace=false") + } + + @Test + void 'deploys feature with argocdOperator false, setting CreateNamespace to true'() { + def strategy = createStrategy(false) + File valuesYaml = File.createTempFile('values', 'yaml') + valuesYaml.text = """ param1: value1 param2: value2 """ - strategy.deployFeature("repoURL", "repoName", "chartName", "version", - "namespace", "releaseName", valuesYaml.toPath()) - - def argoCdApplicationYaml = new File("$localTempDir/apps/argocd/applications/releaseName.yaml") - assertThat(argoCdApplicationYaml.text).contains("CreateNamespace=true") - } - - private ArgoCdApplicationStrategy createStrategy(boolean argocdOperator = false) { - Config config = new Config( - application: new Config.ApplicationSchema( - namePrefix: 'foo-', - gitName: 'Cloudogu', - gitEmail: 'hello@cloudogu.com' - ), - scm: new ScmTenantSchema( - scmManager: new ScmManagerTenantConfig( - username: "dont-care-username", - password: "dont-care-password" - ) - ), - features: new Config.FeaturesSchema( - argocd: new Config.ArgoCDSchema( - operator: argocdOperator - ) - ) - ) - - def repoProvider = new TestGitRepoFactory(config, new FileSystemUtils()) { - @Override - GitRepo getRepo(String repoTarget, GitProvider gitProvider) { - def repo = super.getRepo(repoTarget, gitProvider) - localTempDir = new File(repo.getAbsoluteLocalRepoTmpDir()) - - return repo - } - } - - return new ArgoCdApplicationStrategy(config, new FileSystemUtils(), repoProvider, gitHandler) - } + strategy.deployFeature("repoURL", "repoName", "chartName", "version", + "namespace", "releaseName", valuesYaml.toPath()) + + def argoCdApplicationYaml = new File("$localTempDir/apps/argocd/applications/releaseName.yaml") + assertThat(argoCdApplicationYaml.text).contains("CreateNamespace=true") + } + + private ArgoCdApplicationStrategy createStrategy(boolean argocdOperator = false) { + Config config = new Config(application: new Config.ApplicationSchema(namePrefix: 'foo-', + gitName: 'Cloudogu', + gitEmail: 'hello@cloudogu.com'), + scm: new ScmTenantSchema(scmManager: new ScmManagerTenantConfig(username: "dont-care-username", + password: "dont-care-password")), + features: new Config.FeaturesSchema(argocd: new Config.ArgoCDSchema(operator: argocdOperator))) + + def repoProvider = new TestGitRepoFactory(config, new FileSystemUtils()) { + @Override + GitRepo getRepo(String repoTarget, GitProvider gitProvider) { + def repo = super.getRepo(repoTarget, gitProvider) + localTempDir = new File(repo.getAbsoluteLocalRepoTmpDir()) + + return repo + } + } + + return new ArgoCdApplicationStrategy(config, new FileSystemUtils(), repoProvider, gitHandler) + } } \ 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..f7f4dcc7b 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,44 @@ package com.cloudogu.gitops.features.deployment -import com.cloudogu.gitops.config.Config +import static org.mockito.ArgumentMatchers.any +import static org.mockito.ArgumentMatchers.anyString +import static org.mockito.Mockito.* -import org.junit.jupiter.api.Test +import com.cloudogu.gitops.config.Config 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 org.junit.jupiter.api.Test class DeployerTest { - private ArgoCdApplicationStrategy argoCdStrat = mock(ArgoCdApplicationStrategy.class) - private HelmStrategy helmStrat = mock(HelmStrategy.class) - - @Test - void 'When argocd disabled, deploys imperatively via helm'() { - def deployer = createDeployer(false) + private ArgoCdApplicationStrategy argoCdStrat = mock(ArgoCdApplicationStrategy.class) + private HelmStrategy helmStrat = mock(HelmStrategy.class) - deployer.deployFeature("repoURL", "repoName", "chart", "version", "namespace", "releaseName", Path.of("values.yaml")) + @Test + void 'When argocd disabled, deploys imperatively via helm'() { + def deployer = createDeployer(false) - 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")) + 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 Argo CD enabled, deploys natively via Argo CD'() { - def deployer = createDeployer(true) + @Test + void 'When Argo CD enabled, deploys natively via Argo CD'() { + def deployer = createDeployer(true) - deployer.deployFeature("repoURL", "repoName", "chart", "version", "namespace", "releaseName", Path.of("values.yaml")) + deployer.deployFeature("repoURL", "repoName", "chart", "version", "namespace", "releaseName", Path.of("values.yaml")) - 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)) - } + 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)) + } - private Deployer createDeployer(boolean argoCDActive) { - Config config = new Config(features: new Config.FeaturesSchema(argocd: new Config.ArgoCDSchema(active:argoCDActive))) + private Deployer createDeployer(boolean argoCDActive) { + Config config = new Config(features: new Config.FeaturesSchema(argocd: new Config.ArgoCDSchema(active: argoCDActive))) - return new Deployer(config, argoCdStrat, helmStrat) - } -} + return new Deployer(config, argoCdStrat, helmStrat) + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/deployment/HelmStrategyTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/deployment/HelmStrategyTest.groovy index 9f855f11a..af4206d62 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/deployment/HelmStrategyTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/deployment/HelmStrategyTest.groovy @@ -1,49 +1,46 @@ package com.cloudogu.gitops.features.deployment -import com.cloudogu.gitops.config.Config +import static groovy.test.GroovyAssert.shouldFail +import static org.assertj.core.api.Assertions.assertThat +import static org.mockito.Mockito.mock +import static org.mockito.Mockito.verify +import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.kubernetes.api.HelmClient -import org.junit.jupiter.api.Test import java.nio.file.Files import java.nio.file.Path -import static groovy.test.GroovyAssert.shouldFail -import static org.assertj.core.api.Assertions.assertThat -import static org.mockito.Mockito.mock -import static org.mockito.Mockito.verify +import org.junit.jupiter.api.Test class HelmStrategyTest { - HelmClient helmClient = mock(HelmClient) - - @Test - void 'deploys feature using helm client'() { - Path valuesYaml = Files.createTempFile('', '') - - createStrategy().deployFeature("repoURL", "repoName", "chart", "version", "foo-namespace", "releaseName", valuesYaml) - - verify(helmClient).addRepo("repoName", "repoURL") - verify(helmClient).upgrade("releaseName", "repoName/chart", [ - namespace: "foo-namespace", - version: "version", - values: valuesYaml.toString() - ]) - } - - @Test - void 'Fails to deploy from git'() { - def exception = shouldFail(RuntimeException) { - createStrategy().deployFeature("http://repoURL", "repoName", "chart", "version", "namespace", - "releaseName", Path.of("values.yaml"), DeploymentStrategy.RepoType.GIT) - } - assertThat(exception.message).isEqualTo( - "Unable to deploy helm chart via Helm CLI from Git URL, because helm does not support this out of the box.\n" + - "Repo URL: http://repoURL") - - } - - protected HelmStrategy createStrategy() { - new HelmStrategy(new Config([application: [namePrefix: "foo-"]]), helmClient) - } + HelmClient helmClient = mock(HelmClient) + + @Test + void 'deploys feature using helm client'() { + Path valuesYaml = Files.createTempFile('', '') + + createStrategy().deployFeature("repoURL", "repoName", "chart", "version", "foo-namespace", "releaseName", valuesYaml) + + verify(helmClient).addRepo("repoName", "repoURL") + verify(helmClient).upgrade("releaseName", "repoName/chart", [namespace: "foo-namespace", + version : "version", + values : valuesYaml.toString()]) + } + + @Test + void 'Fails to deploy from git'() { + def exception = shouldFail(RuntimeException) { + createStrategy().deployFeature("http://repoURL", "repoName", "chart", "version", "namespace", + "releaseName", Path.of("values.yaml"), DeploymentStrategy.RepoType.GIT) + } + assertThat(exception.message).isEqualTo("Unable to deploy helm chart via Helm CLI from Git URL, because helm does not support this out of the box.\n" + + "Repo URL: http://repoURL") + + } + + protected HelmStrategy createStrategy() { + new HelmStrategy(new Config([application: [namePrefix: "foo-"]]), helmClient) + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/features/git/GitHandlerTest.groovy b/src/test/groovy/com/cloudogu/gitops/features/git/GitHandlerTest.groovy index a9c5bf42c..d7322e02f 100644 --- a/src/test/groovy/com/cloudogu/gitops/features/git/GitHandlerTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/features/git/GitHandlerTest.groovy @@ -1,221 +1,184 @@ package com.cloudogu.gitops.features.git +import static org.junit.jupiter.api.Assertions.* +import static org.mockito.Mockito.mock + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.HelmStrategy import com.cloudogu.gitops.features.git.config.util.ScmProviderType import com.cloudogu.gitops.git.providers.GitProvider -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 com.cloudogu.gitops.utils.git.GitHandlerForTests import com.cloudogu.gitops.utils.git.GitlabMock import com.cloudogu.gitops.utils.git.ScmManagerMock -import org.junit.jupiter.api.Test -import static org.junit.jupiter.api.Assertions.* -import static org.mockito.Mockito.* +import org.junit.jupiter.api.Test class GitHandlerTest { - private static Config config(Map overrides = [:]) { - Map base = [ - application: [ - namePrefix: '' - ], - scm : [ - scmProviderType: ScmProviderType.SCM_MANAGER, // default - scmManager : [ - internal: true - ], - gitlab : [ - url: '' - ] - ], - multiTenant: [ - scmManager : [ url: '' ], - gitlab : [ url: '' ], - useDedicatedInstance: false - ] - ] - Map merged = deepMerge(base, overrides) - return new Config().fromMap(merged) - } - - /** simple deep merge for nested maps */ - @SuppressWarnings('unchecked') - private static Map deepMerge(Map left, Map right) { - Map out = [:] + left - right.each { k, v -> - if (v instanceof Map && left[k] instanceof Map) { - out[k] = deepMerge((Map) left[k], (Map) v) - } else { - out[k] = v - } - } - return out - } - - private static GitHandler handler(Config cfg) { - return new GitHandler( - cfg, - mock(HelmStrategy), - mock(FileSystemUtils), - mock(K8sClient), - mock(NetworkingUtils) - ) - } - - // ---------- validate() ------------------------------------------------------------ - - @Test - void 'validate(): ScmManager external url sets internal=false and urlForJenkins equals url'() { - def cfg = config([ - application: [namePrefix: 'fv40-'], - scm: [ - scmManager: [url: 'https://scmm.example.com/scm', internal: true] - ] - ]) - def gh = handler(cfg) - - gh.validate() - - assertFalse(cfg.scm.scmManager.internal) - assertEquals('https://scmm.example.com/scm', cfg.scm.scmManager.urlForJenkins) - } - - - @Test - void 'validate(): GitLab chosen, provider switched, scmm nulled, missing PAT or parentGroupId throws'() { - def cfg = config([ - scm: [ - gitlab: [url: 'https://gitlab.example.com'] - ] - ]) - def gh = handler(cfg) - - def ex = assertThrows(RuntimeException) { gh.validate() } - assertTrue(ex.message.toLowerCase().contains('gitlab')) - assertEquals(ScmProviderType.GITLAB, cfg.scm.scmProviderType) - assertNull(cfg.scm.scmManager) - } - - // ---------- getResourcesScm() ----------------------------------------------------- - - @Test - void 'getResourcesScm(): central wins over tenant'() { - def cfg = config() - def gitHandler = handler(cfg) - - gitHandler.tenant = mock(GitProvider, 'tenant') - gitHandler.central = mock(GitProvider, 'central') - - assertSame(gitHandler.central, gitHandler.getResourcesScm()) - } - - @Test - void 'getResourcesScm(): tenant returned when central absent, throws when none'() { - def cfg = config() - def gitHandler = handler(cfg) - - gitHandler.tenant = mock(GitProvider) - assertSame(gitHandler.tenant, gitHandler.getResourcesScm()) - - gitHandler.tenant = null - def ex = assertThrows(IllegalStateException) { gitHandler.getResourcesScm() } - assertTrue(ex.message.contains('No SCM provider')) - } - - // ---------- enable(): SCM_MANAGER tenant only ------------------------------------ - @Test - void 'ScmManager tenant-only: tenant gets 1 repository'() { - def cfg = new Config().fromMap([ - scm:[scmManager:[internal:true], gitlab:[url:'']], - multiTenant:[useDedicatedInstance:false] - ]) - - def tenant = new ScmManagerMock() - def gitHandler = new GitHandlerForTests(cfg, tenant) - - gitHandler.enable() - - assertEquals('scm-manager', cfg.scm.scmManager.namespace) - - assertTrue(tenant.createdRepos.contains('argocd/cluster-resources')) - assertEquals(1, tenant.createdRepos.size()) - - // No central provider in tenant-only scenario - assertNull(gitHandler.getCentral()) - } - - @Test - void 'ScmManager dedicated: central gets 1 repo, tenant gets 1 repo'() { - def cfg = config([ - application: [namePrefix: 'fv40-'], - scm : [ - scmProviderType: ScmProviderType.SCM_MANAGER, - scmManager : [internal: true], - gitlab : [url: ''] - ], - multiTenant: [ - useDedicatedInstance: true, - scmManager: [url: ''], - gitlab : [url: ''] - ] - ]) - - def tenant = new ScmManagerMock(namePrefix: 'fv40-') - def central = new ScmManagerMock(namePrefix: 'fv40-') - def gitHandler = new GitHandlerForTests(cfg, tenant, central) - - gitHandler.enable() - - // Central: argocd/cluster-resources - assertTrue(central.createdRepos.contains('fv40-argocd/cluster-resources')) - assertEquals(1, central.createdRepos.size()) - - // Tenant: argocd/cluster-resources - assertTrue(tenant.createdRepos.contains('fv40-argocd/cluster-resources')) - assertEquals(1, tenant.createdRepos.size()) - } - - @Test - void 'Gitlab dedicated: same layout as ScmManager dedicated'() { - def cfg = config([ - application: [namePrefix: 'fv40-'], - scm : [ - scmProviderType: ScmProviderType.GITLAB, - gitlab : [url: 'https://gitlab.example.com', password: 'pat', parentGroupId: 123], - scmManager : [internal: true] - ], - multiTenant: [ - useDedicatedInstance: true, - gitlab: [url: 'https://gitlab.example.com', password: 'pat2', parentGroupId: 456], - scmManager: [url: ''] - ] - ]) - - // Assumes your GitlabMock has a similar contract to ScmManagerMock (collects createdRepos) - def tenant = new GitlabMock(base: new URI(cfg.scm.gitlab.url), namePrefix: 'fv40-') - def central = new GitlabMock(base: new URI(cfg.multiTenant.gitlab.url), namePrefix: 'fv40-') - def gitHandler = new GitHandlerForTests(cfg, tenant, central) - - gitHandler.enable() - - // Central: argocd/cluster-resources - assertTrue(central.createdRepos.contains('fv40-argocd/cluster-resources')) - assertEquals(1, central.createdRepos.size()) - - // Tenant: argocd/cluster-resources - assertTrue(tenant.createdRepos.contains('fv40-argocd/cluster-resources')) - assertEquals(1, tenant.createdRepos.size()) - } - - @Test - void 'withOrgPrefix helper behaves as expected'() { - assertEquals('argocd/argocd', GitHandler.withOrgPrefix('', 'argocd/argocd')) - assertEquals('argocd/argocd', GitHandler.withOrgPrefix(null, 'argocd/argocd')) - assertEquals('fv40-argocd/argocd', GitHandler.withOrgPrefix('fv40-', 'argocd/argocd')) - } - + private static Config config(Map overrides = [:]) { + Map base = [application: [namePrefix: ''], + scm : [scmProviderType: ScmProviderType.SCM_MANAGER, // default + scmManager : [internal: true], + gitlab : [url: '']], + multiTenant: [scmManager : [url: ''], + gitlab : [url: ''], + useDedicatedInstance: false]] + Map merged = deepMerge(base, overrides) + return new Config().fromMap(merged) + } + + /** simple deep merge for nested maps */ + @SuppressWarnings('unchecked') + private static Map deepMerge(Map left, Map right) { + Map out = [:] + left + right.each { k, v -> + if (v instanceof Map && left[k] instanceof Map) { + out[k] = deepMerge((Map) left[k], (Map) v) + } else { + out[k] = v + } + } + return out + } + + private static GitHandler handler(Config cfg) { + return new GitHandler(cfg, + mock(HelmStrategy), + mock(FileSystemUtils), + mock(K8sClient), + mock(NetworkingUtils)) + } + + // ---------- validate() ------------------------------------------------------------ + + @Test + void 'validate(): ScmManager external url sets internal=false and urlForJenkins equals url'() { + def cfg = config([application: [namePrefix: 'fv40-'], + scm : [scmManager: [url: 'https://scmm.example.com/scm', internal: true]]]) + def gh = handler(cfg) + + gh.validate() + + assertFalse(cfg.scm.scmManager.internal) + assertEquals('https://scmm.example.com/scm', cfg.scm.scmManager.urlForJenkins) + } + + @Test + void 'validate(): GitLab chosen, provider switched, scmm nulled, missing PAT or parentGroupId throws'() { + def cfg = config([scm: [gitlab: [url: 'https://gitlab.example.com']]]) + def gh = handler(cfg) + + def ex = assertThrows(RuntimeException) { gh.validate() } + assertTrue(ex.message.toLowerCase().contains('gitlab')) + assertEquals(ScmProviderType.GITLAB, cfg.scm.scmProviderType) + assertNull(cfg.scm.scmManager) + } + + // ---------- getResourcesScm() ----------------------------------------------------- + + @Test + void 'getResourcesScm(): central wins over tenant'() { + def cfg = config() + def gitHandler = handler(cfg) + + gitHandler.tenant = mock(GitProvider, 'tenant') + gitHandler.central = mock(GitProvider, 'central') + + assertSame(gitHandler.central, gitHandler.getResourcesScm()) + } + + @Test + void 'getResourcesScm(): tenant returned when central absent, throws when none'() { + def cfg = config() + def gitHandler = handler(cfg) + + gitHandler.tenant = mock(GitProvider) + assertSame(gitHandler.tenant, gitHandler.getResourcesScm()) + + gitHandler.tenant = null + def ex = assertThrows(IllegalStateException) { gitHandler.getResourcesScm() } + assertTrue(ex.message.contains('No SCM provider')) + } + + // ---------- enable(): SCM_MANAGER tenant only ------------------------------------ + @Test + void 'ScmManager tenant-only: tenant gets 1 repository'() { + def cfg = new Config().fromMap([scm : [scmManager: [internal: true], gitlab: [url: '']], + multiTenant: [useDedicatedInstance: false]]) + + def tenant = new ScmManagerMock() + def gitHandler = new GitHandlerForTests(cfg, tenant) + + gitHandler.enable() + + assertEquals('scm-manager', cfg.scm.scmManager.namespace) + + assertTrue(tenant.createdRepos.contains('argocd/cluster-resources')) + assertEquals(1, tenant.createdRepos.size()) + + // No central provider in tenant-only scenario + assertNull(gitHandler.getCentral()) + } + + @Test + void 'ScmManager dedicated: central gets 1 repo, tenant gets 1 repo'() { + def cfg = config([application: [namePrefix: 'fv40-'], + scm : [scmProviderType: ScmProviderType.SCM_MANAGER, + scmManager : [internal: true], + gitlab : [url: '']], + multiTenant: [useDedicatedInstance: true, + scmManager : [url: ''], + gitlab : [url: '']]]) + + def tenant = new ScmManagerMock(namePrefix: 'fv40-') + def central = new ScmManagerMock(namePrefix: 'fv40-') + def gitHandler = new GitHandlerForTests(cfg, tenant, central) + + gitHandler.enable() + + // Central: argocd/cluster-resources + assertTrue(central.createdRepos.contains('fv40-argocd/cluster-resources')) + assertEquals(1, central.createdRepos.size()) + + // Tenant: argocd/cluster-resources + assertTrue(tenant.createdRepos.contains('fv40-argocd/cluster-resources')) + assertEquals(1, tenant.createdRepos.size()) + } + + @Test + void 'Gitlab dedicated: same layout as ScmManager dedicated'() { + def cfg = config([application: [namePrefix: 'fv40-'], + scm : [scmProviderType: ScmProviderType.GITLAB, + gitlab : [url: 'https://gitlab.example.com', password: 'pat', parentGroupId: 123], + scmManager : [internal: true]], + multiTenant: [useDedicatedInstance: true, + gitlab : [url: 'https://gitlab.example.com', password: 'pat2', parentGroupId: 456], + scmManager : [url: '']]]) + + // Assumes your GitlabMock has a similar contract to ScmManagerMock (collects createdRepos) + def tenant = new GitlabMock(base: new URI(cfg.scm.gitlab.url), namePrefix: 'fv40-') + def central = new GitlabMock(base: new URI(cfg.multiTenant.gitlab.url), namePrefix: 'fv40-') + def gitHandler = new GitHandlerForTests(cfg, tenant, central) + + gitHandler.enable() + + // Central: argocd/cluster-resources + assertTrue(central.createdRepos.contains('fv40-argocd/cluster-resources')) + assertEquals(1, central.createdRepos.size()) + + // Tenant: argocd/cluster-resources + assertTrue(tenant.createdRepos.contains('fv40-argocd/cluster-resources')) + assertEquals(1, tenant.createdRepos.size()) + } + + @Test + void 'withOrgPrefix helper behaves as expected'() { + assertEquals('argocd/argocd', GitHandler.withOrgPrefix('', 'argocd/argocd')) + assertEquals('argocd/argocd', GitHandler.withOrgPrefix(null, 'argocd/argocd')) + assertEquals('fv40-argocd/argocd', GitHandler.withOrgPrefix('fv40-', 'argocd/argocd')) + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/git/GitRepoTest.groovy b/src/test/groovy/com/cloudogu/gitops/git/GitRepoTest.groovy index ce27c612a..14d70e659 100644 --- a/src/test/groovy/com/cloudogu/gitops/git/GitRepoTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/git/GitRepoTest.groovy @@ -1,218 +1,207 @@ package com.cloudogu.gitops.git +import static groovy.test.GroovyAssert.shouldFail +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.providers.AccessRole import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.git.providers.Scope -import com.cloudogu.gitops.utils.git.ScmManagerMock import com.cloudogu.gitops.utils.FileSystemUtils +import com.cloudogu.gitops.utils.git.ScmManagerMock import com.cloudogu.gitops.utils.git.TestGitRepoFactory + import org.eclipse.jgit.api.Git import org.eclipse.jgit.lib.Ref import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.Mock -import static groovy.test.GroovyAssert.shouldFail -import static org.assertj.core.api.Assertions.assertThat - class GitRepoTest { - - public static final String expectedNamespace = "namespace" - public static final String expectedRepo = "repo" - Config config = Config.fromMap([ - application: [ - gitName : "Cloudogu", - gitEmail: "hello@cloudogu.com" - ], - scm : [ - scmManager: [ - username: "dont-care-username", - password: "dont-care-password" - ] - ] - ]) - - TestGitRepoFactory repoProvider = new TestGitRepoFactory(config, new FileSystemUtils()) - - @Mock - GitProvider gitProvider - - ScmManagerMock scmManagerMock - - @BeforeEach - void setup() { - scmManagerMock = new ScmManagerMock() - } - - - @Test - void "writes file"() { - def repo = getRepo("", scmManagerMock) - repo.writeFile("test.txt", "the file's content") - - def expectedFile = new File("$repo.absoluteLocalRepoTmpDir/test.txt") - assertThat(expectedFile.getText()).is("the file's content") - } - - @Test - void "overwrites file"() { - def repo = getRepo("", scmManagerMock) - def tempDir = repo.absoluteLocalRepoTmpDir - - def existingFile = new File("$tempDir/already-exists.txt") - existingFile.createNewFile() - existingFile.text = "already existing content" - - repo.writeFile("already-exists.txt", "overwritten content") - - def expectedFile = new File("$tempDir/already-exists.txt") - assertThat(expectedFile.getText()).is("overwritten content") - } - - @Test - void "writes file and creates subdirectory"() { - def repo = getRepo("", scmManagerMock) - def tempDir = repo.absoluteLocalRepoTmpDir - repo.writeFile("subdirectory/test.txt", "the file's content") - - def expectedFile = new File("$tempDir/subdirectory/test.txt") - assertThat(expectedFile.getText()).is("the file's content") - } - - @Test - void "throws error when directory conflicts with existing file"() { - def repo = getRepo("", scmManagerMock) - def tempDir = repo.absoluteLocalRepoTmpDir - new File("$tempDir/test.txt").mkdir() - - shouldFail(FileNotFoundException) { - repo.writeFile("test.txt", "the file's content") - } - } - - @Test - void 'Creates repo with empty name-prefix'() { - def repo = getRepo('expectedRepoTarget', scmManagerMock) - assertThat(repo.repoTarget).isEqualTo('expectedRepoTarget') - } - - @Test - void 'Creates repo with name-prefix'() { - config.application.namePrefix = 'abc-' - def repo = getRepo('expectedRepoTarget', scmManagerMock) - assertThat(repo.repoTarget).isEqualTo('abc-expectedRepoTarget') - } - - @Test - void 'Creates repo with name-prefix when in namespace 3rd-party-deps'() { - config.application.namePrefix = 'abc-' - def repo = getRepo("${GitRepo.NAMESPACE_3RD_PARTY_DEPENDENCIES}/foo", scmManagerMock) - assertThat(repo.repoTarget).isEqualTo("${config.application.namePrefix}${GitRepo.NAMESPACE_3RD_PARTY_DEPENDENCIES}/foo".toString()) - } - - @Test - void 'Clones and checks out main'() { - def repo = getRepo("", scmManagerMock) - - repo.cloneRepo() - def HEAD = new File(repo.absoluteLocalRepoTmpDir, '.git/HEAD') - assertThat(HEAD.text).isEqualTo("ref: refs/heads/main\n") - assertThat(new File(repo.absoluteLocalRepoTmpDir, 'README.md')).exists() - } - - @Test - void 'pushes changes to remote directory'() { - def repo = getRepo("", scmManagerMock) - - repo.cloneRepo() - def readme = new File(repo.absoluteLocalRepoTmpDir, 'README.md') - readme.text = 'This text should be in the readme afterwards' - repo.commitAndPush("The commit message") - - def commits = Git.open(new File(repo.absoluteLocalRepoTmpDir)).log().setMaxCount(1).all().call().collect() - assertThat(commits.size()).isEqualTo(1) - assertThat(commits[0].fullMessage).isEqualTo("The commit message") - assertThat(commits[0].authorIdent.emailAddress).isEqualTo('hello@cloudogu.com') - assertThat(commits[0].authorIdent.name).isEqualTo('Cloudogu') - assertThat(commits[0].committerIdent.emailAddress).isEqualTo('hello@cloudogu.com') - assertThat(commits[0].committerIdent.name).contains("Cloudogu - GOP v") - - List tags = Git.open(new File(repo.absoluteLocalRepoTmpDir)).tagList().call() - assertThat(tags.size()).isEqualTo(0) - } - - @Test - void 'pushes changes to remote directory with tag'() { - def repo = getRepo("", scmManagerMock) - def expectedTag = '1.0' - - repo.cloneRepo() - def readme = new File(repo.absoluteLocalRepoTmpDir, 'README.md') - readme.text = 'This text should be in the readme afterwards' - // Create existing tag to test for idempotence - Git.open(new File(repo.absoluteLocalRepoTmpDir)).tag().setName(expectedTag).call() - - repo.commitAndPush("The commit message", expectedTag) - - - List tags = Git.open(new File(repo.absoluteLocalRepoTmpDir)).tagList().call() - assertThat(tags.size()).isEqualTo(1) - assertThat(tags[0].name).isEqualTo("refs/tags/$expectedTag".toString()) - // It would be a good idea to check if the git tag is set on the commit. - // However, it's extremely complicated with jgit - // The "official" example code throws an exception here: Ref peeledRef = repository.getRefDatabase().peel(ref) - // https://github.com/centic9/jgit-cookbook/blob/d923e18b2ce2e55761858fd2e8e402dd252e0766/src/main/java/org/dstadler/jgit/porcelain/ListTags.java - // 🤷 - } - - @Test - void 'creates repository and sets permission when new and username present'() { - - def repoTarget = "foo/bar" - def repo = getRepo(repoTarget, scmManagerMock) - scmManagerMock.nextCreateResults = [true] // simulate "new repo" - scmManagerMock.gitOpsUsername = 'foo-gitops' // username available - - def created = repo.createRepositoryAndSetPermission('testdescription', true) - - assertThat(created).isTrue() - - // Verify that repo was created - assertThat(scmManagerMock.createdRepos).containsExactly(repoTarget) - - // Verify permission call - assertThat(scmManagerMock.permissionCalls).hasSize(1) - def call = scmManagerMock.permissionCalls[0] - assertThat(call.repoTarget).isEqualTo(repoTarget) - assertThat(call.principal).isEqualTo('foo-gitops') - assertThat(call.role).isEqualTo(AccessRole.WRITE) - assertThat(call.scope).isEqualTo(Scope.USER) - } - - @Test - void 'does not set permission when no GitOps username is configured'() { - def repoTarget = "foo/bar" - def scmManagerMock = new ScmManagerMock() - def repo = getRepo(repoTarget, scmManagerMock) - - scmManagerMock.nextCreateResults = [true] // repo is new - scmManagerMock.gitOpsUsername = null // no username - - def created = repo.createRepositoryAndSetPermission('desc', true) - - assertThat(created).isTrue() - - // Repo created - assertThat(scmManagerMock.createdRepos).containsExactly(repoTarget) - - // No permission calls because username missing - assertThat(scmManagerMock.permissionCalls).isEmpty() - } - - - private GitRepo getRepo(String repoTarget = "${expectedNamespace}/${expectedRepo}", ScmManagerMock scmManagerMock) { - return repoProvider.getRepo(repoTarget, scmManagerMock) - } + public static final String expectedNamespace = "namespace" + public static final String expectedRepo = "repo" + Config config = Config.fromMap([application: [gitName : "Cloudogu", + gitEmail: "hello@cloudogu.com"], + scm : [scmManager: [username: "dont-care-username", + password: "dont-care-password"]]]) + + TestGitRepoFactory repoProvider = new TestGitRepoFactory(config, new FileSystemUtils()) + + @Mock + GitProvider gitProvider + + ScmManagerMock scmManagerMock + + @BeforeEach + void setup() { + scmManagerMock = new ScmManagerMock() + } + + @Test + void "writes file"() { + def repo = getRepo("", scmManagerMock) + repo.writeFile("test.txt", "the file's content") + + def expectedFile = new File("$repo.absoluteLocalRepoTmpDir/test.txt") + assertThat(expectedFile.getText()).is("the file's content") + } + + @Test + void "overwrites file"() { + def repo = getRepo("", scmManagerMock) + def tempDir = repo.absoluteLocalRepoTmpDir + + def existingFile = new File("$tempDir/already-exists.txt") + existingFile.createNewFile() + existingFile.text = "already existing content" + + repo.writeFile("already-exists.txt", "overwritten content") + + def expectedFile = new File("$tempDir/already-exists.txt") + assertThat(expectedFile.getText()).is("overwritten content") + } + + @Test + void "writes file and creates subdirectory"() { + def repo = getRepo("", scmManagerMock) + def tempDir = repo.absoluteLocalRepoTmpDir + repo.writeFile("subdirectory/test.txt", "the file's content") + + def expectedFile = new File("$tempDir/subdirectory/test.txt") + assertThat(expectedFile.getText()).is("the file's content") + } + + @Test + void "throws error when directory conflicts with existing file"() { + def repo = getRepo("", scmManagerMock) + def tempDir = repo.absoluteLocalRepoTmpDir + new File("$tempDir/test.txt").mkdir() + + shouldFail(FileNotFoundException) { + repo.writeFile("test.txt", "the file's content") + } + } + + @Test + void 'Creates repo with empty name-prefix'() { + def repo = getRepo('expectedRepoTarget', scmManagerMock) + assertThat(repo.repoTarget).isEqualTo('expectedRepoTarget') + } + + @Test + void 'Creates repo with name-prefix'() { + config.application.namePrefix = 'abc-' + def repo = getRepo('expectedRepoTarget', scmManagerMock) + assertThat(repo.repoTarget).isEqualTo('abc-expectedRepoTarget') + } + + @Test + void 'Creates repo with name-prefix when in namespace 3rd-party-deps'() { + config.application.namePrefix = 'abc-' + def repo = getRepo("${GitRepo.NAMESPACE_3RD_PARTY_DEPENDENCIES}/foo", scmManagerMock) + assertThat(repo.repoTarget).isEqualTo("${config.application.namePrefix}${GitRepo.NAMESPACE_3RD_PARTY_DEPENDENCIES}/foo".toString()) + } + + @Test + void 'Clones and checks out main'() { + def repo = getRepo("", scmManagerMock) + + repo.cloneRepo() + def HEAD = new File(repo.absoluteLocalRepoTmpDir, '.git/HEAD') + assertThat(HEAD.text).isEqualTo("ref: refs/heads/main\n") + assertThat(new File(repo.absoluteLocalRepoTmpDir, 'README.md')).exists() + } + + @Test + void 'pushes changes to remote directory'() { + def repo = getRepo("", scmManagerMock) + + repo.cloneRepo() + def readme = new File(repo.absoluteLocalRepoTmpDir, 'README.md') + readme.text = 'This text should be in the readme afterwards' + repo.commitAndPush("The commit message") + + def commits = Git.open(new File(repo.absoluteLocalRepoTmpDir)).log().setMaxCount(1).all().call().collect() + assertThat(commits.size()).isEqualTo(1) + assertThat(commits[0].fullMessage).isEqualTo("The commit message") + assertThat(commits[0].authorIdent.emailAddress).isEqualTo('hello@cloudogu.com') + assertThat(commits[0].authorIdent.name).isEqualTo('Cloudogu') + assertThat(commits[0].committerIdent.emailAddress).isEqualTo('hello@cloudogu.com') + assertThat(commits[0].committerIdent.name).contains("Cloudogu - GOP v") + + List tags = Git.open(new File(repo.absoluteLocalRepoTmpDir)).tagList().call() + assertThat(tags.size()).isEqualTo(0) + } + + @Test + void 'pushes changes to remote directory with tag'() { + def repo = getRepo("", scmManagerMock) + def expectedTag = '1.0' + + repo.cloneRepo() + def readme = new File(repo.absoluteLocalRepoTmpDir, 'README.md') + readme.text = 'This text should be in the readme afterwards' + // Create existing tag to test for idempotence + Git.open(new File(repo.absoluteLocalRepoTmpDir)).tag().setName(expectedTag).call() + + repo.commitAndPush("The commit message", expectedTag) + + List tags = Git.open(new File(repo.absoluteLocalRepoTmpDir)).tagList().call() + assertThat(tags.size()).isEqualTo(1) + assertThat(tags[0].name).isEqualTo("refs/tags/$expectedTag".toString()) + // It would be a good idea to check if the git tag is set on the commit. + // However, it's extremely complicated with jgit + // The "official" example code throws an exception here: Ref peeledRef = repository.getRefDatabase().peel(ref) + // https://github.com/centic9/jgit-cookbook/blob/d923e18b2ce2e55761858fd2e8e402dd252e0766/src/main/java/org/dstadler/jgit/porcelain/ListTags.java + // 🤷 + } + + @Test + void 'creates repository and sets permission when new and username present'() { + + def repoTarget = "foo/bar" + def repo = getRepo(repoTarget, scmManagerMock) + scmManagerMock.nextCreateResults = [true] // simulate "new repo" + scmManagerMock.gitOpsUsername = 'foo-gitops' // username available + + def created = repo.createRepositoryAndSetPermission('testdescription', true) + + assertThat(created).isTrue() + + // Verify that repo was created + assertThat(scmManagerMock.createdRepos).containsExactly(repoTarget) + + // Verify permission call + assertThat(scmManagerMock.permissionCalls).hasSize(1) + def call = scmManagerMock.permissionCalls[0] + assertThat(call.repoTarget).isEqualTo(repoTarget) + assertThat(call.principal).isEqualTo('foo-gitops') + assertThat(call.role).isEqualTo(AccessRole.WRITE) + assertThat(call.scope).isEqualTo(Scope.USER) + } + + @Test + void 'does not set permission when no GitOps username is configured'() { + def repoTarget = "foo/bar" + def scmManagerMock = new ScmManagerMock() + def repo = getRepo(repoTarget, scmManagerMock) + + scmManagerMock.nextCreateResults = [true] // repo is new + scmManagerMock.gitOpsUsername = null // no username + + def created = repo.createRepositoryAndSetPermission('desc', true) + + assertThat(created).isTrue() + + // Repo created + assertThat(scmManagerMock.createdRepos).containsExactly(repoTarget) + + // No permission calls because username missing + assertThat(scmManagerMock.permissionCalls).isEmpty() + } + + private GitRepo getRepo(String repoTarget = "${expectedNamespace}/${expectedRepo}", ScmManagerMock scmManagerMock) { + return repoProvider.getRepo(repoTarget, scmManagerMock) + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/git/jgit/helpers/InsecureCredentialProviderTest.groovy b/src/test/groovy/com/cloudogu/gitops/git/jgit/helpers/InsecureCredentialProviderTest.groovy index 1d17dec05..a183368a2 100644 --- a/src/test/groovy/com/cloudogu/gitops/git/jgit/helpers/InsecureCredentialProviderTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/git/jgit/helpers/InsecureCredentialProviderTest.groovy @@ -1,47 +1,42 @@ package com.cloudogu.gitops.git.jgit.helpers +import static org.assertj.core.api.Assertions.assertThat import org.eclipse.jgit.transport.CredentialItem import org.eclipse.jgit.transport.URIish import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat - class InsecureCredentialProviderTest { - @Test - void 'ignores irrelevant items'() { - def provider = new InsecureCredentialProvider() - - assertThat(provider.supports(new CredentialItem.Username(), new CredentialItem.Password())).isFalse() - assertThat(provider.supports( - new CredentialItem.InformationalMessage("This is not a relevant message"), - new CredentialItem.YesNoType("This prompt is irrelevant as well") - )).isFalse() - } - - @Test - void 'confirms insecure https processing'() { - def provider = new InsecureCredentialProvider() - - def message = new CredentialItem.InformationalMessage("A secure connection to https://192.168.178.37/scm/repo/argocd/cluster-resources could not be established because the server's certificate could not be validated.\n" + - "SSL reported: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target\n" + - "Do you want to skip SSL verification for this server?") - def skipSingle = new CredentialItem.YesNoType("Skip SSL verification for this single git operation") - def skipRepository = new CredentialItem.YesNoType("Skip SSL verification for git operations for repository /tmp/groovy-generated-tmpdir-2746077697650757929/.git") - def skipAlways = new CredentialItem.YesNoType("Always skip SSL verification for this server from now on") - - assertThat(provider.supports( - message, - skipSingle, - skipRepository, - skipAlways - )).isTrue() - - assertThat(provider.get(new URIish("https://192.168.178.37/scm/repo/argocd/cluster-resources"), message, skipSingle, skipRepository, skipAlways)) - .isTrue() - - assertThat(skipSingle.value).isTrue() - assertThat(skipRepository.value).isTrue() - assertThat(skipAlways.value).isFalse() - } + @Test + void 'ignores irrelevant items'() { + def provider = new InsecureCredentialProvider() + + assertThat(provider.supports(new CredentialItem.Username(), new CredentialItem.Password())).isFalse() + assertThat(provider.supports(new CredentialItem.InformationalMessage("This is not a relevant message"), + new CredentialItem.YesNoType("This prompt is irrelevant as well"))).isFalse() + } + + @Test + void 'confirms insecure https processing'() { + def provider = new InsecureCredentialProvider() + + def message = new CredentialItem.InformationalMessage("A secure connection to https://192.168.178.37/scm/repo/argocd/cluster-resources could not be established because the server's certificate could not be validated.\n" + + "SSL reported: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target\n" + + "Do you want to skip SSL verification for this server?") + def skipSingle = new CredentialItem.YesNoType("Skip SSL verification for this single git operation") + def skipRepository = new CredentialItem.YesNoType("Skip SSL verification for git operations for repository /tmp/groovy-generated-tmpdir-2746077697650757929/.git") + def skipAlways = new CredentialItem.YesNoType("Always skip SSL verification for this server from now on") + + assertThat(provider.supports(message, + skipSingle, + skipRepository, + skipAlways)).isTrue() + + assertThat(provider.get(new URIish("https://192.168.178.37/scm/repo/argocd/cluster-resources"), message, skipSingle, skipRepository, skipAlways)) + .isTrue() + + assertThat(skipSingle.value).isTrue() + assertThat(skipRepository.value).isTrue() + assertThat(skipAlways.value).isFalse() + } } \ 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 fa08f597c..9e4f54de7 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,90 +1,79 @@ 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 : [:] - ], - rootPath : "repo", - 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 : [:]], + rootPath : "repo", + 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.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.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 diff --git a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerTest.groovy b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerTest.groovy index 8d6d68ac0..9efb6f246 100644 --- a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerTest.groovy @@ -1,5 +1,9 @@ package com.cloudogu.gitops.git.providers.scmmanager +import static org.junit.jupiter.api.Assertions.* +import static org.mockito.ArgumentMatchers.* +import static org.mockito.Mockito.* + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.Credentials import com.cloudogu.gitops.features.git.config.util.ScmManagerConfig @@ -11,156 +15,153 @@ import com.cloudogu.gitops.git.providers.scmmanager.api.RepositoryApi import com.cloudogu.gitops.git.providers.scmmanager.api.ScmManagerApiClient import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.NetworkingUtils + import okhttp3.internal.http.RealResponseBody import okio.BufferedSource import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.function.Executable import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import retrofit2.Call import retrofit2.Response -import static org.junit.jupiter.api.Assertions.* -import static org.mockito.ArgumentMatchers.* -import static org.mockito.Mockito.* -import org.junit.jupiter.api.function.Executable - @ExtendWith(MockitoExtension) class ScmManagerTest { - private Config config - - @Mock ScmManagerConfig scmmCfg - @Mock K8sClient k8s - @Mock NetworkingUtils net - @Mock ScmManagerUrlResolver urls - @Mock ScmManagerApiClient apiClient - @Mock RepositoryApi repoApi - - @BeforeEach - void setup() { - config = new Config( - application: new Config.ApplicationSchema( - insecure: false, - namePrefix: "fv40-", - runningInsideK8s: true - ) - ) - - lenient().when(scmmCfg.getCredentials()).thenReturn(new Credentials("user","password")) - lenient().when(scmmCfg.getGitOpsUsername()).thenReturn("gitops-bot") - - lenient().when(urls.inClusterBase()).thenReturn(new URI("http://scmm.ns.svc.cluster.local/scm")) - lenient().when(urls.inClusterRepoPrefix()).thenReturn("http://scmm.ns.svc.cluster.local/scm/repo/fv40-") - lenient().when(urls.clientApiBase()).thenReturn(new URI("http://nodeport/scm/api/v2/")) - - lenient().when(apiClient.repositoryApi()).thenReturn(repoApi) - } - - private ScmManager newSchManager() { - return new ScmManager(config, scmmCfg, urls, apiClient) - } - - private static Call callReturningSuccess(int code) { - def call = mock(Call) - when(call.execute()).thenReturn(Response.success(code, null)) - call - } - private static Call callReturningError(int code) { - def call = mock(Call) - def body = new RealResponseBody('ignored', 0, mock(BufferedSource)) - when(call.execute()).thenReturn(Response.error(code, body)) - call - } - - - @Test - void 'createRepository returns true on 201 and false on subsequent 409 for the same repo'() { - def scmManager = newSchManager() - - def created = callReturningSuccess(201) - def conflict = callReturningError(409) - def seen = new HashSet() - - when(repoApi.create(any(Repository), anyBoolean())) - .thenAnswer(inv -> { - Repository r = inv.getArgument(0) - if (seen.contains(r.fullRepoName)) return conflict - seen.add(r.fullRepoName) - return created - }) - - assertTrue(scmManager.createRepository("team/demo", "Demo repo", true)) - assertFalse(scmManager.createRepository("team/demo", "Demo repo", true)) // 409 - assertTrue(scmManager.createRepository("team/other", null, false)) // neuer Name -> 201 - - verify(repoApi, times(3)).create(any(Repository), anyBoolean()) - } - - @Test - void 'setRepositoryPermission maps MAINTAIN to WRITE and handles 201 409'() { - def scmManager = newSchManager() - - def created = callReturningSuccess(201) - def conflict = callReturningError(409) - def seen = new HashSet() // key: ns/name - - when(repoApi.createPermission(anyString(), anyString(), any(Permission))) - .thenAnswer(inv -> { - String namespace = inv.getArgument(0) - String repoName = inv.getArgument(1) - String key = namespace + "/" + repoName - if (seen.contains(key)) return conflict - seen.add(key) - return created - }) - - assertDoesNotThrow({ -> - scmManager.setRepositoryPermission("namespace/repo1", "devs", AccessRole.MAINTAIN, Scope.GROUP) - } as Executable) - - assertDoesNotThrow({ -> - scmManager.setRepositoryPermission("namespace/repo1", "devs", AccessRole.MAINTAIN, Scope.GROUP) - } as Executable) - verify(repoApi, atLeastOnce()) - .createPermission(eq("namespace"), eq("repo1"), argThat { Permission p -> p.groupPermission && p.role == Permission.Role.WRITE }) - } - - - @Test - void 'url, repoPrefix, repoUrl variants, protocol and host come from UrlResolver'() { - when(urls.inClusterRepoUrl(anyString())).thenAnswer(a -> "http://scmm.ns.svc.cluster.local/scm/repo/" + a.getArgument(0)) - when(urls.clientRepoUrl(anyString())).thenAnswer(a -> "http://nodeport/scm/repo/" + a.getArgument(0)) - - def scmManager = newSchManager() - - assertEquals("http://scmm.ns.svc.cluster.local/scm", scmManager.url) - assertEquals("http://scmm.ns.svc.cluster.local/scm/repo/fv40-", scmManager.repoPrefix()) - - assertEquals("http://scmm.ns.svc.cluster.local/scm/repo/team/app", - scmManager.repoUrl("team/app", RepoUrlScope.IN_CLUSTER)) - assertEquals("http://nodeport/scm/repo/team/app", - scmManager.repoUrl("team/app", RepoUrlScope.CLIENT)) - - assertEquals("http", scmManager.protocol) - assertEquals("scmm.ns.svc.cluster.local", scmManager.host) - } - - @Test - void 'prometheusMetricsEndpoint is delegated to UrlResolver'() { - when(urls.prometheusEndpoint()).thenReturn(new URI("http://nodeport/scm/api/v2/metrics/prometheus")) - def scmManager = newSchManager() - assertEquals(new URI("http://nodeport/scm/api/v2/metrics/prometheus"), scmManager.prometheusMetricsEndpoint()) - } - - // Credentials & GitOps-User - @Test - void 'credentials and gitOpsUsername come from ScmManagerConfig'() { - def scmManager = newSchManager() - assertEquals("user", scmManager.credentials.username) - assertEquals("password", scmManager.credentials.password) - assertEquals("gitops-bot", scmManager.gitOpsUsername) - } + private Config config + + @Mock + ScmManagerConfig scmmCfg + @Mock + K8sClient k8s + @Mock + NetworkingUtils net + @Mock + ScmManagerUrlResolver urls + @Mock + ScmManagerApiClient apiClient + @Mock + RepositoryApi repoApi + + @BeforeEach + void setup() { + config = new Config(application: new Config.ApplicationSchema(insecure: false, + namePrefix: "fv40-", + runningInsideK8s: true)) + + lenient().when(scmmCfg.getCredentials()).thenReturn(new Credentials("user", "password")) + lenient().when(scmmCfg.getGitOpsUsername()).thenReturn("gitops-bot") + + lenient().when(urls.inClusterBase()).thenReturn(new URI("http://scmm.ns.svc.cluster.local/scm")) + lenient().when(urls.inClusterRepoPrefix()).thenReturn("http://scmm.ns.svc.cluster.local/scm/repo/fv40-") + lenient().when(urls.clientApiBase()).thenReturn(new URI("http://nodeport/scm/api/v2/")) + + lenient().when(apiClient.repositoryApi()).thenReturn(repoApi) + } + + private ScmManager newSchManager() { + return new ScmManager(config, scmmCfg, urls, apiClient) + } + + private static Call callReturningSuccess(int code) { + def call = mock(Call) + when(call.execute()).thenReturn(Response.success(code, null)) + call + } + + private static Call callReturningError(int code) { + def call = mock(Call) + def body = new RealResponseBody('ignored', 0, mock(BufferedSource)) + when(call.execute()).thenReturn(Response.error(code, body)) + call + } + + @Test + void 'createRepository returns true on 201 and false on subsequent 409 for the same repo'() { + def scmManager = newSchManager() + + def created = callReturningSuccess(201) + def conflict = callReturningError(409) + def seen = new HashSet() + + when(repoApi.create(any(Repository), anyBoolean())) + .thenAnswer(inv -> { + Repository r = inv.getArgument(0) + if (seen.contains(r.fullRepoName)) return conflict + seen.add(r.fullRepoName) + return created + }) + + assertTrue(scmManager.createRepository("team/demo", "Demo repo", true)) + assertFalse(scmManager.createRepository("team/demo", "Demo repo", true)) // 409 + assertTrue(scmManager.createRepository("team/other", null, false)) // neuer Name -> 201 + + verify(repoApi, times(3)).create(any(Repository), anyBoolean()) + } + + @Test + void 'setRepositoryPermission maps MAINTAIN to WRITE and handles 201 409'() { + def scmManager = newSchManager() + + def created = callReturningSuccess(201) + def conflict = callReturningError(409) + def seen = new HashSet() + // key: ns/name + + when(repoApi.createPermission(anyString(), anyString(), any(Permission))) + .thenAnswer(inv -> { + String namespace = inv.getArgument(0) + String repoName = inv.getArgument(1) + String key = namespace + "/" + repoName + if (seen.contains(key)) return conflict + seen.add(key) + return created + }) + + assertDoesNotThrow({ -> scmManager.setRepositoryPermission("namespace/repo1", "devs", AccessRole.MAINTAIN, Scope.GROUP) + } as Executable) + + assertDoesNotThrow({ -> scmManager.setRepositoryPermission("namespace/repo1", "devs", AccessRole.MAINTAIN, Scope.GROUP) + } as Executable) + verify(repoApi, atLeastOnce()) + .createPermission(eq("namespace"), eq("repo1"), argThat { Permission p -> p.groupPermission && p.role == Permission.Role.WRITE }) + } + + @Test + void 'url, repoPrefix, repoUrl variants, protocol and host come from UrlResolver'() { + when(urls.inClusterRepoUrl(anyString())).thenAnswer(a -> "http://scmm.ns.svc.cluster.local/scm/repo/" + a.getArgument(0)) + when(urls.clientRepoUrl(anyString())).thenAnswer(a -> "http://nodeport/scm/repo/" + a.getArgument(0)) + + def scmManager = newSchManager() + + assertEquals("http://scmm.ns.svc.cluster.local/scm", scmManager.url) + assertEquals("http://scmm.ns.svc.cluster.local/scm/repo/fv40-", scmManager.repoPrefix()) + + assertEquals("http://scmm.ns.svc.cluster.local/scm/repo/team/app", + scmManager.repoUrl("team/app", RepoUrlScope.IN_CLUSTER)) + assertEquals("http://nodeport/scm/repo/team/app", + scmManager.repoUrl("team/app", RepoUrlScope.CLIENT)) + + assertEquals("http", scmManager.protocol) + assertEquals("scmm.ns.svc.cluster.local", scmManager.host) + } + + @Test + void 'prometheusMetricsEndpoint is delegated to UrlResolver'() { + when(urls.prometheusEndpoint()).thenReturn(new URI("http://nodeport/scm/api/v2/metrics/prometheus")) + def scmManager = newSchManager() + assertEquals(new URI("http://nodeport/scm/api/v2/metrics/prometheus"), scmManager.prometheusMetricsEndpoint()) + } + + // Credentials & GitOps-User + @Test + void 'credentials and gitOpsUsername come from ScmManagerConfig'() { + def scmManager = newSchManager() + assertEquals("user", scmManager.credentials.username) + assertEquals("password", scmManager.credentials.password) + assertEquals("gitops-bot", scmManager.gitOpsUsername) + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerUrlResolverTest.groovy b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerUrlResolverTest.groovy index 2bf326981..36f493552 100644 --- a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerUrlResolverTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/ScmManagerUrlResolverTest.groovy @@ -1,166 +1,158 @@ package com.cloudogu.gitops.git.providers.scmmanager +import static org.junit.jupiter.api.Assertions.* +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.git.config.ScmTenantSchema import com.cloudogu.gitops.kubernetes.api.K8sClient import com.cloudogu.gitops.utils.NetworkingUtils + import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import static org.junit.jupiter.api.Assertions.* -import static org.mockito.ArgumentMatchers.any -import static org.mockito.ArgumentMatchers.eq -import static org.mockito.Mockito.* - @ExtendWith(MockitoExtension.class) class ScmManagerUrlResolverTest { - private Config config - - @Mock - private K8sClient k8s - @Mock - private NetworkingUtils net - - - @BeforeEach - void setUp() { - config = new Config( - application: new Config.ApplicationSchema( - namePrefix: 'fv40-', - runningInsideK8s: false - ) - ) - } - - private ScmManagerUrlResolver resolverWith(Map args = [:]) { - def scmmCofig = new ScmTenantSchema.ScmManagerTenantConfig() - scmmCofig.internal = (args.containsKey('internal') ? args.internal : true) - scmmCofig.namespace = (args.containsKey('namespace') ? args.namespace : "scm-manager") - scmmCofig.rootPath = (args.containsKey('rootPath') ? args.rootPath : "repo") - scmmCofig.url = (args.containsKey('url') ? args.url : "") - scmmCofig.ingress = (args.containsKey('ingress') ? args.ingress : "") - - return new ScmManagerUrlResolver(config, scmmCofig, k8s, net) - } - - // ---------- Client base & API ---------- - @Test - void "clientBase(): internal + outside K8s uses NodePort and appends 'scm' (no trailing slash) and only resolves NodePort once"() { - when(k8s.waitForNodePort(eq('scmm'), any())).thenReturn("30080") - when(net.findClusterBindAddress()).thenReturn("10.0.0.1") - - def r = resolverWith() - URI base1 = r.clientBase() - URI base2 = r.clientBase() - - assertEquals("http://10.0.0.1:30080/scm", base1.toString()) - assertEquals(base1, base2) - - verify(k8s, times(1)).waitForNodePort("scmm", "scm-manager") - verify(net, times(1)).findClusterBindAddress() - verifyNoMoreInteractions(k8s, net) - } - - @Test - void "clientApiBase(): appends 'api' to the client base"() { - when(k8s.waitForNodePort("scmm", "scm-manager")).thenReturn("30080") - when(net.findClusterBindAddress()).thenReturn("10.0.0.1") - - var urlResolver = resolverWith() - assertEquals("http://10.0.0.1:30080/scm/api/", urlResolver.clientApiBase().toString()) - } - - // ---------- Repo base & URLs ---------- - @Test - void "clientRepoUrl(): trims repoTarget and removes trailing slash"() { - when(k8s.waitForNodePort("scmm", "scm-manager")).thenReturn("30080") - when(net.findClusterBindAddress()).thenReturn("10.0.0.1") - - var urlResolver = resolverWith() - assertEquals("http://10.0.0.1:30080/scm/repo/ns/project", - urlResolver.clientRepoUrl(" ns/project ")) - } - - // ---------- In-cluster base & URLs ---------- - @Test - void "inClusterBase(): internal uses service DNS "() { - def r = resolverWith(namespace: "custom-ns", internal: true) - assertEquals("http://scmm.custom-ns.svc.cluster.local/scm", r.inClusterBase().toString()) - } - - - @Test - void "inClusterBase(): external uses external base + 'scm'"() { - var r = resolverWith(internal: false, url: "https://scmm.external") - assertEquals("https://scmm.external/scm", r.inClusterBase().toString()) - } - - - @Test - void "inClusterRepoUrl(): builds full in-cluster repo URL without trailing slash"() { - var urlResolver = resolverWith() - assertEquals("http://scmm.scm-manager.svc.cluster.local/scm/repo/admin/admin", - urlResolver.inClusterRepoUrl("admin/admin")) - } - - @Test - void "inClusterRepoPrefix(): includes configured namePrefix (empty prefix yields base path)"() { - // with non-empty namePrefix - config.application.namePrefix = 'fv40-' - def r1 = resolverWith() - assertEquals('http://scmm.scm-manager.svc.cluster.local/scm/repo/fv40-', r1.inClusterRepoPrefix()) - - // with empty/blank namePrefix - config.application.namePrefix = ' ' - def r2 = resolverWith() - assertEquals('http://scmm.scm-manager.svc.cluster.local/scm/repo/', r2.inClusterRepoPrefix()) - } - - // ---------- externalBase selection & error ---------- - @Test - void "externalBase(): prefers 'url' over 'ingress'"() { - def r = resolverWith(internal: false, url: 'https://scmm.external', ingress: 'ingress.example.org') - assertEquals('https://scmm.external/scm', r.inClusterBase().toString()) - } - - @Test - void "externalBase(): uses 'ingress' when 'url' is missing"() { - def r = resolverWith(internal: false, url: null, ingress: 'ingress.example.org') - assertEquals('http://ingress.example.org/scm', r.inClusterBase().toString()) - } - - @Test - void "externalBase(): throws when neither 'url' nor 'ingress' is set"() { - def r = resolverWith(internal: false, url: null, ingress: null) - def ex = assertThrows(IllegalArgumentException) { r.inClusterBase() } - assertTrue(ex.message.contains('Either scmm.url or scmm.ingress must be set when internal=false')) - } - - - @Test - void "nodePortBase(): falls back to default namespace 'scm-manager' when none provided"() { - when(k8s.waitForNodePort(eq('scmm'), eq('scm-manager'))).thenReturn("30080") - when(net.findClusterBindAddress()).thenReturn('10.0.0.1') - - def r = resolverWith(namespace: null) - assertEquals('http://10.0.0.1:30080/scm', r.clientBase().toString()) - } - - // ---------- helpers behavior ---------- - @Test - void "ensureScm(): adds 'scm' if missing and keeps it if present"() { - def r1 = resolverWith(internal: false, url: 'https://scmm.localhost') - assertEquals('https://scmm.localhost/scm', r1.clientBase().toString()) - } - - - // ---------- prometheus endpoint ---------- - @Test - void "prometheusEndpoint(): resolves "() { - def r = resolverWith(internal: false, url: 'https://scmm.localhost') - assertEquals('https://scmm.localhost/scm/api/v2/metrics/prometheus', r.prometheusEndpoint().toString()) - } + private Config config + + @Mock + private K8sClient k8s + @Mock + private NetworkingUtils net + + @BeforeEach + void setUp() { + config = new Config(application: new Config.ApplicationSchema(namePrefix: 'fv40-', + runningInsideK8s: false)) + } + + private ScmManagerUrlResolver resolverWith(Map args = [:]) { + def scmmCofig = new ScmTenantSchema.ScmManagerTenantConfig() + scmmCofig.internal = (args.containsKey('internal') ? args.internal : true) + scmmCofig.namespace = (args.containsKey('namespace') ? args.namespace : "scm-manager") + scmmCofig.rootPath = (args.containsKey('rootPath') ? args.rootPath : "repo") + scmmCofig.url = (args.containsKey('url') ? args.url : "") + scmmCofig.ingress = (args.containsKey('ingress') ? args.ingress : "") + + return new ScmManagerUrlResolver(config, scmmCofig, k8s, net) + } + + // ---------- Client base & API ---------- + @Test + void "clientBase(): internal + outside K8s uses NodePort and appends 'scm' (no trailing slash) and only resolves NodePort once"() { + when(k8s.waitForNodePort(eq('scmm'), any())).thenReturn("30080") + when(net.findClusterBindAddress()).thenReturn("10.0.0.1") + + def r = resolverWith() + URI base1 = r.clientBase() + URI base2 = r.clientBase() + + assertEquals("http://10.0.0.1:30080/scm", base1.toString()) + assertEquals(base1, base2) + + verify(k8s, times(1)).waitForNodePort("scmm", "scm-manager") + verify(net, times(1)).findClusterBindAddress() + verifyNoMoreInteractions(k8s, net) + } + + @Test + void "clientApiBase(): appends 'api' to the client base"() { + when(k8s.waitForNodePort("scmm", "scm-manager")).thenReturn("30080") + when(net.findClusterBindAddress()).thenReturn("10.0.0.1") + + var urlResolver = resolverWith() + assertEquals("http://10.0.0.1:30080/scm/api/", urlResolver.clientApiBase().toString()) + } + + // ---------- Repo base & URLs ---------- + @Test + void "clientRepoUrl(): trims repoTarget and removes trailing slash"() { + when(k8s.waitForNodePort("scmm", "scm-manager")).thenReturn("30080") + when(net.findClusterBindAddress()).thenReturn("10.0.0.1") + + var urlResolver = resolverWith() + assertEquals("http://10.0.0.1:30080/scm/repo/ns/project", + urlResolver.clientRepoUrl(" ns/project ")) + } + + // ---------- In-cluster base & URLs ---------- + @Test + void "inClusterBase(): internal uses service DNS "() { + def r = resolverWith(namespace: "custom-ns", internal: true) + assertEquals("http://scmm.custom-ns.svc.cluster.local/scm", r.inClusterBase().toString()) + } + + @Test + void "inClusterBase(): external uses external base + 'scm'"() { + var r = resolverWith(internal: false, url: "https://scmm.external") + assertEquals("https://scmm.external/scm", r.inClusterBase().toString()) + } + + @Test + void "inClusterRepoUrl(): builds full in-cluster repo URL without trailing slash"() { + var urlResolver = resolverWith() + assertEquals("http://scmm.scm-manager.svc.cluster.local/scm/repo/admin/admin", + urlResolver.inClusterRepoUrl("admin/admin")) + } + + @Test + void "inClusterRepoPrefix(): includes configured namePrefix (empty prefix yields base path)"() { + // with non-empty namePrefix + config.application.namePrefix = 'fv40-' + def r1 = resolverWith() + assertEquals('http://scmm.scm-manager.svc.cluster.local/scm/repo/fv40-', r1.inClusterRepoPrefix()) + + // with empty/blank namePrefix + config.application.namePrefix = ' ' + def r2 = resolverWith() + assertEquals('http://scmm.scm-manager.svc.cluster.local/scm/repo/', r2.inClusterRepoPrefix()) + } + + // ---------- externalBase selection & error ---------- + @Test + void "externalBase(): prefers 'url' over 'ingress'"() { + def r = resolverWith(internal: false, url: 'https://scmm.external', ingress: 'ingress.example.org') + assertEquals('https://scmm.external/scm', r.inClusterBase().toString()) + } + + @Test + void "externalBase(): uses 'ingress' when 'url' is missing"() { + def r = resolverWith(internal: false, url: null, ingress: 'ingress.example.org') + assertEquals('http://ingress.example.org/scm', r.inClusterBase().toString()) + } + + @Test + void "externalBase(): throws when neither 'url' nor 'ingress' is set"() { + def r = resolverWith(internal: false, url: null, ingress: null) + def ex = assertThrows(IllegalArgumentException) { r.inClusterBase() } + assertTrue(ex.message.contains('Either scmm.url or scmm.ingress must be set when internal=false')) + } + + @Test + void "nodePortBase(): falls back to default namespace 'scm-manager' when none provided"() { + when(k8s.waitForNodePort(eq('scmm'), eq('scm-manager'))).thenReturn("30080") + when(net.findClusterBindAddress()).thenReturn('10.0.0.1') + + def r = resolverWith(namespace: null) + assertEquals('http://10.0.0.1:30080/scm', r.clientBase().toString()) + } + + // ---------- helpers behavior ---------- + @Test + void "ensureScm(): adds 'scm' if missing and keeps it if present"() { + def r1 = resolverWith(internal: false, url: 'https://scmm.localhost') + assertEquals('https://scmm.localhost/scm', r1.clientBase().toString()) + } + + // ---------- prometheus endpoint ---------- + @Test + void "prometheusEndpoint(): resolves "() { + def r = resolverWith(internal: false, url: 'https://scmm.localhost') + assertEquals('https://scmm.localhost/scm/api/v2/metrics/prometheus', r.prometheusEndpoint().toString()) + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApiTest.groovy b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApiTest.groovy index cf35de66b..88e7530f8 100644 --- a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApiTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApiTest.groovy @@ -1,66 +1,69 @@ package com.cloudogu.gitops.git.providers.scmmanager.api +import static com.github.tomakehurst.wiremock.client.WireMock.* +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import static groovy.test.GroovyAssert.shouldFail +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.Credentials + +import javax.net.ssl.SSLHandshakeException + import com.github.tomakehurst.wiremock.junit5.WireMockExtension import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension -import javax.net.ssl.SSLHandshakeException +class UsersApiTest { -import static com.github.tomakehurst.wiremock.client.WireMock.* -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig -import static groovy.test.GroovyAssert.shouldFail -import static org.assertj.core.api.Assertions.assertThat + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig() + .dynamicPort() + .dynamicHttpsPort()) + .build() -class UsersApiTest { + private Credentials credentials = new Credentials("user", "pass") + + @Test + void 'allows self-signed certificates when using insecure option'() { + wireMock.stubFor(delete(urlPathEqualTo("/scm/api/v2/users/test-user")) + .willReturn(aResponse().withStatus(204))) + + def api = usersApi(true, true) + // insecure=true, useHttps=true + def resp = api.delete('test-user').execute() + + assertThat(resp.isSuccessful()).isTrue() + wireMock.verify(1, deleteRequestedFor(urlPathEqualTo("/scm/api/v2/users/test-user"))) + } + + @Test + void 'does not allow self-signed certificates by default'() { + wireMock.stubFor(delete(urlPathEqualTo("/scm/api/v2/users/test-user")) + .willReturn(aResponse().withStatus(204))) + + def api = usersApi(false, true) + // insecure=false, useHttps=true + + shouldFail(SSLHandshakeException) { + api.delete('test-user').execute() + } + + wireMock.verify(0, deleteRequestedFor(urlPathEqualTo("/scm/api/v2/users/test-user"))) + } + + private UsersApi usersApi(boolean insecure, boolean useHttps = false) { + def client = new ScmManagerApiClient(apiBaseUrl(useHttps), credentials, insecure) + return client.usersApi() + } - @RegisterExtension - static WireMockExtension wireMock = WireMockExtension.newInstance() - .options(wireMockConfig() - .dynamicPort() - .dynamicHttpsPort()) - .build() - - private Credentials credentials = new Credentials("user", "pass") - - @Test - void 'allows self-signed certificates when using insecure option'() { - wireMock.stubFor(delete(urlPathEqualTo("/scm/api/v2/users/test-user")) - .willReturn(aResponse().withStatus(204))) - - def api = usersApi(true, true) // insecure=true, useHttps=true - def resp = api.delete('test-user').execute() - - assertThat(resp.isSuccessful()).isTrue() - wireMock.verify(1, deleteRequestedFor(urlPathEqualTo("/scm/api/v2/users/test-user"))) - } - - @Test - void 'does not allow self-signed certificates by default'() { - wireMock.stubFor(delete(urlPathEqualTo("/scm/api/v2/users/test-user")) - .willReturn(aResponse().withStatus(204))) - - def api = usersApi(false, true) // insecure=false, useHttps=true - - shouldFail(SSLHandshakeException) { - api.delete('test-user').execute() - } - - wireMock.verify(0, deleteRequestedFor(urlPathEqualTo("/scm/api/v2/users/test-user"))) - } - - private UsersApi usersApi(boolean insecure, boolean useHttps = false) { - def client = new ScmManagerApiClient(apiBaseUrl(useHttps), credentials, insecure) - return client.usersApi() - } - - private String apiBaseUrl(boolean useHttps) { - if (useHttps) { - // Use the proper HTTPS port from WireMock - def httpsPort = wireMock.httpsPort - return "https://localhost:${httpsPort}/scm/api/" - } else { - return "${wireMock.baseUrl()}/scm/api/" - } - } + private String apiBaseUrl(boolean useHttps) { + if (useHttps) { + // Use the proper HTTPS port from WireMock + def httpsPort = wireMock.httpsPort + return "https://localhost:${httpsPort}/scm/api/" + } else { + return "${wireMock.baseUrl()}/scm/api/" + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/TestK8sHelper.groovy b/src/test/groovy/com/cloudogu/gitops/integration/TestK8sHelper.groovy index 41897b9e7..e6533101d 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/TestK8sHelper.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/TestK8sHelper.groovy @@ -1,6 +1,13 @@ package com.cloudogu.gitops.integration +import static org.assertj.core.api.Assertions.fail + +import java.nio.charset.StandardCharsets +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference import groovy.util.logging.Slf4j + import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientBuilder import io.fabric8.kubernetes.client.KubernetesClientException @@ -8,130 +15,121 @@ import io.fabric8.kubernetes.client.dsl.ExecListener import io.fabric8.kubernetes.client.dsl.ExecWatch import org.awaitility.Awaitility -import java.nio.charset.StandardCharsets -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicReference - -import static org.assertj.core.api.Assertions.fail - /** - * This class contains helper methods for k8s communication. - */ + * This class contains helper methods for k8s communication.*/ @Slf4j class TestK8sHelper { - /** - * This method logs Namespace and contining Pods to namespace. - */ - static void dumpNamespacesAndPods() { - StringBuffer sb = new StringBuffer('##### K8s Dump ##### \n') - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - def pods = client.pods().inAnyNamespace().list().getItems() - - // sort: namespace, pod-name - pods.sort { a, b -> - (a.metadata?.namespace <=> b.metadata?.namespace) ?: (a.metadata?.name <=> b.metadata?.name) - } - - // group by namespace - def podsByNs = pods.groupBy { it.metadata?.namespace ?: "" } - - podsByNs.each { ns, nsPods -> - sb.append("\n=== Namespace: ${ns} (${nsPods.size()}) ===\n") - nsPods.each { pod -> - def name = pod.metadata?.name - def phase = pod.status?.phase - def node = pod.spec?.nodeName ?: "-" - def startTime = pod.status?.startTime ?: "-" - def restarts = (pod.status?.containerStatuses ?: []).sum { it?.restartCount ?: 0 } ?: 0 - - sb.append(String.format(" %-60s phase=%-10s restarts=%-3s node=%-25s start=%s", - name, phase, restarts, node, startTime)) - sb.append("\n") - } - } - } - log.info sb.toString() - } - - /** - * Executes command on container and returns result. - * @param client - * @param ns - * @param pod - * @param container - * @param cmd - * @return - */ - static String execAndGetStdout(KubernetesClient client, - String ns, - String pod, - String container, - String... cmd) { - - ByteArrayOutputStream out = new ByteArrayOutputStream() - ByteArrayOutputStream err = new ByteArrayOutputStream() - - CountDownLatch finished = new CountDownLatch(1) - AtomicReference failure = new AtomicReference<>() - - ExecListener listener = new ExecListener() { - - @Override - void onClose(int code, String reason) { - finished.countDown() - } - } - - try (ExecWatch watch = client.pods() - .inNamespace(ns) - .withName(pod) - .inContainer(container) - .writingOutput(out) - .writingError(err) - .usingListener(listener) - .exec(cmd)) { - - Awaitility.await() - .atMost(5, TimeUnit.MINUTES) - .pollInterval(500, TimeUnit.MILLISECONDS) - .until(() -> finished.getCount() == 0) - - } catch (Exception e) { - throw new RuntimeException("Exec failed/timeout for pod " + ns + "/" + pod, e) - } - - if (failure.get() != null) { - throw new RuntimeException("Exec failure", failure.get()) - } - - String stderr = err.toString(StandardCharsets.UTF_8) - if (!stderr.isBlank()) { - log.error(stderr) - throw new RuntimeException(stderr) - } - - return out.toString(StandardCharsets.UTF_8) - } - /** - * Test defined namespace and check if all pods running or specific pod. Pod is find by name which startWith... - * @param namespace - * @param podNameStartsWith - */ - static boolean checkAllPodsRunningInNamespace(String namespace, String podNameStartsWith = "") { - String running = "Running" - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - // Check Pod - def actualPods = client.pods().inNamespace(namespace).list().getItems().findAll { it.metadata.name.startsWith(podNameStartsWith) } - assert !actualPods.isEmpty(): "No pods found in namespace: ${namespace} with name ${podNameStartsWith}" - def notRunningPods = actualPods.findAll { pod -> pod.getStatus().getPhase() != running } - - assert notRunningPods.isEmpty(): "These pods in ${namespace} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" - return true - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - return false - } - } -} + /** + * This method logs Namespace and contining Pods to namespace.*/ + static void dumpNamespacesAndPods() { + StringBuffer sb = new StringBuffer('##### K8s Dump ##### \n') + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + def pods = client.pods().inAnyNamespace().list().getItems() + + // sort: namespace, pod-name + pods.sort { a, b -> (a.metadata?.namespace <=> b.metadata?.namespace) ?: (a.metadata?.name <=> b.metadata?.name) + } + + // group by namespace + def podsByNs = pods.groupBy { it.metadata?.namespace ?: "" } + + podsByNs.each { ns, nsPods -> + sb.append("\n=== Namespace: ${ns} (${nsPods.size()}) ===\n") + nsPods.each { pod -> + def name = pod.metadata?.name + def phase = pod.status?.phase + def node = pod.spec?.nodeName ?: "-" + def startTime = pod.status?.startTime ?: "-" + def restarts = (pod.status?.containerStatuses ?: []).sum { it?.restartCount ?: 0 } ?: 0 + + sb.append(String.format(" %-60s phase=%-10s restarts=%-3s node=%-25s start=%s", + name, phase, restarts, node, startTime)) + sb.append("\n") + } + } + } + log.info sb.toString() + } + + /** + * Executes command on container and returns result. + * @param client + * @param ns + * @param pod + * @param container + * @param cmd + * @return + */ + static String execAndGetStdout(KubernetesClient client, + String ns, + String pod, + String container, + String... cmd) { + + ByteArrayOutputStream out = new ByteArrayOutputStream() + ByteArrayOutputStream err = new ByteArrayOutputStream() + + CountDownLatch finished = new CountDownLatch(1) + AtomicReference failure = new AtomicReference<>() + + ExecListener listener = new ExecListener() { + + @Override + void onClose(int code, String reason) { + finished.countDown() + } + } + + try (ExecWatch watch = client.pods() + .inNamespace(ns) + .withName(pod) + .inContainer(container) + .writingOutput(out) + .writingError(err) + .usingListener(listener) + .exec(cmd)) { + + Awaitility.await() + .atMost(5, TimeUnit.MINUTES) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> finished.getCount() == 0) + + } catch (Exception e) { + throw new RuntimeException("Exec failed/timeout for pod " + ns + "/" + pod, e) + } + + if (failure.get() != null) { + throw new RuntimeException("Exec failure", failure.get()) + } + + String stderr = err.toString(StandardCharsets.UTF_8) + if (!stderr.isBlank()) { + log.error(stderr) + throw new RuntimeException(stderr) + } + + return out.toString(StandardCharsets.UTF_8) + } + + /** + * Test defined namespace and check if all pods running or specific pod. Pod is find by name which startWith... + * @param namespace + * @param podNameStartsWith + */ + static boolean checkAllPodsRunningInNamespace(String namespace, String podNameStartsWith = "") { + String running = "Running" + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + // Check Pod + def actualPods = client.pods().inNamespace(namespace).list().getItems().findAll { it.metadata.name.startsWith(podNameStartsWith) } + assert !actualPods.isEmpty(): "No pods found in namespace: ${namespace} with name ${podNameStartsWith}" + def notRunningPods = actualPods.findAll { pod -> pod.getStatus().getPhase() != running } + + assert notRunningPods.isEmpty(): "These pods in ${namespace} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" + return true + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + return false + } + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/features/ArgoCdTestIT.groovy b/src/test/groovy/com/cloudogu/gitops/integration/features/ArgoCdTestIT.groovy index e01559869..44dbcf5b4 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/features/ArgoCdTestIT.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/features/ArgoCdTestIT.groovy @@ -1,65 +1,62 @@ package com.cloudogu.gitops.integration.features +import static org.assertj.core.api.Assertions.assertThat import io.kubernetes.client.openapi.models.V1Pod import io.kubernetes.client.openapi.models.V1PodList import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test -import org.junit.jupiter.api.condition.EnabledIfSystemProperty - -import static org.assertj.core.api.Assertions.assertThat /** * This class is for testing deployments via ArgoCD*/ @Disabled("TODO: analyse why it fails exactly.") class ArgoCdTestIT extends KubenetesApiTestSetup { - String namespace = 'argocd' - - @BeforeAll - static void labelTest() { - println "###### ARGO CD ######" - } - - @Test - void ensureNamespaceExists() { - def namespaces = api.listNamespace().execute() - assertThat(namespaces).isNotNull() - assertThat(namespaces.getItems().isEmpty()).isFalse() - def namespace = namespaces.getItems().find { namespace.equals(it.getMetadata().name) } - assertThat(namespace).isNotNull() - } - - /** - * ArgoCD uses 7 pods. All have to run*/ - @Test - void ensureArgoCDIsOnlineAndRunning() { - def expectedSumOfArgoPods = 7 - - V1PodList list = api.listNamespacedPod(namespace ) - .execute() - List argoPods = list.getItems().findAll { it.getMetadata().getName().startsWith("argo") } - assertThat(argoPods.size()).isEqualTo(expectedSumOfArgoPods) - - for (V1Pod pod : argoPods) { - assertThat(pod.status.phase).isEqualTo("Running") - } - - } - - @Override - boolean isReadyToStartTests() { - V1PodList list = api.listPodForAllNamespaces() - .execute() - if (list && !list.items.isEmpty()) { - - List argoPods = list.getItems().findAll { it.getMetadata().getName().startsWith("argo") } - if (argoPods.size() == 7) - { - return "Running".equals(argoPods.get(0).status.phase) - } - } - return false - } + String namespace = 'argocd' + + @BeforeAll + static void labelTest() { + println "###### ARGO CD ######" + } + + @Test + void ensureNamespaceExists() { + def namespaces = api.listNamespace().execute() + assertThat(namespaces).isNotNull() + assertThat(namespaces.getItems().isEmpty()).isFalse() + def namespace = namespaces.getItems().find { namespace.equals(it.getMetadata().name) } + assertThat(namespace).isNotNull() + } + + /** + * ArgoCD uses 7 pods. All have to run*/ + @Test + void ensureArgoCDIsOnlineAndRunning() { + def expectedSumOfArgoPods = 7 + + V1PodList list = api.listNamespacedPod(namespace) + .execute() + List argoPods = list.getItems().findAll { it.getMetadata().getName().startsWith("argo") } + assertThat(argoPods.size()).isEqualTo(expectedSumOfArgoPods) + + for (V1Pod pod : argoPods) { + assertThat(pod.status.phase).isEqualTo("Running") + } + + } + + @Override + boolean isReadyToStartTests() { + V1PodList list = api.listPodForAllNamespaces() + .execute() + if (list && !list.items.isEmpty()) { + + List argoPods = list.getItems().findAll { it.getMetadata().getName().startsWith("argo") } + if (argoPods.size() == 7) { + return "Running".equals(argoPods.get(0).status.phase) + } + } + return false + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/features/CertManagerTestIT.groovy b/src/test/groovy/com/cloudogu/gitops/integration/features/CertManagerTestIT.groovy index 9264acb61..03f45e233 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/features/CertManagerTestIT.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/features/CertManagerTestIT.groovy @@ -1,70 +1,71 @@ package com.cloudogu.gitops.integration.features +import static org.assertj.core.api.Assertions.assertThat + import groovy.util.logging.Slf4j + import io.kubernetes.client.openapi.models.V1Pod import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfSystemProperty -import static org.assertj.core.api.Assertions.assertThat - /** * This class checks if cert-manager is started well. - * Cert-Manager contains own namespace ('cert-manager') which owns and 3 Pods: - */ + * Cert-Manager contains own namespace ('cert-manager') which owns and 3 Pods:*/ @Slf4j -@EnabledIfSystemProperty(named = "micronaut.environments", matches = "full") //TODO: why not in ArgoCD Operator? Clearify +@EnabledIfSystemProperty(named = "micronaut.environments", matches = "full") +//TODO: why not in ArgoCD Operator? Clearify class CertManagerTestIT extends KubenetesApiTestSetup { - String namespace = 'cert-manager' - int sumOfPods = 3 - - @Override - boolean isReadyToStartTests() { - // cert-manager should has 3 running pods - def pods = api.listNamespacedPod(namespace).execute() - if (pods.items.size() != 3) { - return false - } - for (V1Pod pod : pods.getItems()) { - println("Pod ${pod.getMetadata().name} with status ${pod.status.phase}") - if (!"Running".equals(pod.status.phase)) { - return false - } - } - return true - } - - @BeforeAll - static void labelTest() { - println "###### CERT-MANAGER ######" - } - - @Test - void ensureNamespaceExists() { - def namespaces = api.listNamespace().execute() - assertThat(namespaces).isNotNull() - assertThat(namespaces.getItems().isEmpty()).isFalse() - def namespace = namespaces.getItems().find { namespace.equals(it.getMetadata().name) } - assertThat(namespace).isNotNull() - - } - - @Test - void ensureAllCertManagerPodsAreExist() { - - def pods = api.listNamespacedPod(namespace).execute() - assertThat(pods).isNotNull() - assertThat(pods.getItems().isEmpty()).isFalse() - - } - - @Test - void ensureNumberOfPodsAreEqualToSumOfPods() { - - def pods = api.listNamespacedPod(namespace).execute() - assertThat(pods.getItems().size()).isEqualTo(sumOfPods) - - } - -} + String namespace = 'cert-manager' + int sumOfPods = 3 + + @Override + boolean isReadyToStartTests() { + // cert-manager should has 3 running pods + def pods = api.listNamespacedPod(namespace).execute() + if (pods.items.size() != 3) { + return false + } + for (V1Pod pod : pods.getItems()) { + println("Pod ${pod.getMetadata().name} with status ${pod.status.phase}") + if (!"Running".equals(pod.status.phase)) { + return false + } + } + return true + } + + @BeforeAll + static void labelTest() { + println "###### CERT-MANAGER ######" + } + + @Test + void ensureNamespaceExists() { + def namespaces = api.listNamespace().execute() + assertThat(namespaces).isNotNull() + assertThat(namespaces.getItems().isEmpty()).isFalse() + def namespace = namespaces.getItems().find { namespace.equals(it.getMetadata().name) } + assertThat(namespace).isNotNull() + + } + + @Test + void ensureAllCertManagerPodsAreExist() { + + def pods = api.listNamespacedPod(namespace).execute() + assertThat(pods).isNotNull() + assertThat(pods.getItems().isEmpty()).isFalse() + + } + + @Test + void ensureNumberOfPodsAreEqualToSumOfPods() { + + def pods = api.listNamespacedPod(namespace).execute() + assertThat(pods.getItems().size()).isEqualTo(sumOfPods) + + } + +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/features/KubenetesApiTestSetup.groovy b/src/test/groovy/com/cloudogu/gitops/integration/features/KubenetesApiTestSetup.groovy index f36732135..202332365 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/features/KubenetesApiTestSetup.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/features/KubenetesApiTestSetup.groovy @@ -1,5 +1,12 @@ package com.cloudogu.gitops.integration.features +import static org.assertj.core.api.Assertions.assertThat +import static org.assertj.core.api.Assertions.fail + +import java.time.Duration +import java.time.Instant +import java.util.function.Supplier + import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.Configuration import io.kubernetes.client.openapi.apis.CoreV1Api @@ -8,82 +15,72 @@ import io.kubernetes.client.util.KubeConfig import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach -import java.time.Duration -import java.time.Instant -import java.util.function.Supplier - -import static org.assertj.core.api.Assertions.assertThat -import static org.assertj.core.api.Assertions.fail - abstract class KubenetesApiTestSetup { - static String kubeConfigPath - CoreV1Api api - int TIME_TO_WAIT = 12 - int RETRY_SECONDS = 30 + static String kubeConfigPath + CoreV1Api api + int TIME_TO_WAIT = 12 + int RETRY_SECONDS = 30 + + /** + * Gets path to kubeconfig*/ + @BeforeAll + static void setupKubeconfig() { + kubeConfigPath = System.getenv("HOME") + "/.kube/config" + if (!new File(kubeConfigPath).exists()) { + kubeConfigPath = System.getenv("KUBECONFIG") + } + assertThat(kubeConfigPath) isNotBlank() + } - /** - * Gets path to kubeconfig - */ - @BeforeAll - static void setupKubeconfig() { - kubeConfigPath = System.getenv("HOME") + "/.kube/config" - if (!new File(kubeConfigPath).exists()) { - kubeConfigPath = System.getenv("KUBECONFIG") - } - assertThat(kubeConfigPath) isNotBlank() - } + /** + * establish connection to kubernetes and create API to use.*/ + @BeforeEach + void setupConnection() { + ApiClient client = + ClientBuilder.kubeconfig(KubeConfig.loadKubeConfig(new FileReader(kubeConfigPath))).build() + // set the global default api-client to the out-of-cluster one from above + Configuration.setDefaultApiClient(client) - /** - * establish connection to kubernetes and create API to use. - */ - @BeforeEach - void setupConnection() { - ApiClient client = - ClientBuilder.kubeconfig(KubeConfig.loadKubeConfig(new FileReader(kubeConfigPath))).build() - // set the global default api-client to the out-of-cluster one from above - Configuration.setDefaultApiClient(client) + // the CoreV1Api loads default api-client from global configuration. + api = new CoreV1Api() + waitForCondition(() -> waitingCondition(), + maxWaitTimeInMinutes(TIME_TO_WAIT), + pollIntervallSeconds(RETRY_SECONDS)) + } - // the CoreV1Api loads default api-client from global configuration. - api = new CoreV1Api() - waitForCondition(() -> - waitingCondition(), - maxWaitTimeInMinutes(TIME_TO_WAIT), - pollIntervallSeconds(RETRY_SECONDS) - ) - } + static void waitForCondition(Supplier condition, Duration timeout, Duration pollInterval) { + Instant end = Instant.now().plus(timeout) + while (Instant.now().isBefore(end)) { + if (condition.get()) { + return + } + try { + Thread.sleep(pollInterval.toMillis()) + } catch (InterruptedException e) { + Thread.currentThread().interrupt() + throw new RuntimeException("break polling", e) + } + } + fail('Wait condition not fulfilled in time') + } - static void waitForCondition(Supplier condition, Duration timeout, Duration pollInterval) { - Instant end = Instant.now().plus(timeout) - while (Instant.now().isBefore(end)) { - if (condition.get()) { - return - } - try { - Thread.sleep(pollInterval.toMillis()) - } catch (InterruptedException e) { - Thread.currentThread().interrupt() - throw new RuntimeException("break polling", e) - } - } - fail('Wait condition not fulfilled in time') - } + private Duration pollIntervallSeconds(int time) { + return Duration.ofSeconds(time) + } - private Duration pollIntervallSeconds(int time) { - return Duration.ofSeconds(time) - } + private Duration maxWaitTimeInMinutes(int time) { + return Duration.ofMinutes(time) + } - private Duration maxWaitTimeInMinutes(int time) { - return Duration.ofMinutes(time) - } + boolean waitingCondition() { + println 'waiting for pods' + return isReadyToStartTests() + } - boolean waitingCondition() { - println 'waiting for pods' - return isReadyToStartTests() - } - /** - * This condition is to override, if test has to wait, i.e. ArgoCD has to do its GitOps magic. - * @return - */ + /** + * This condition is to override, if test has to wait, i.e. ArgoCD has to do its GitOps magic. + * @return + */ - abstract boolean isReadyToStartTests() + abstract boolean isReadyToStartTests() } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/features/MonitoringTestIT.groovy b/src/test/groovy/com/cloudogu/gitops/integration/features/MonitoringTestIT.groovy index 7126437cd..e0c0c60a4 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/features/MonitoringTestIT.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/features/MonitoringTestIT.groovy @@ -1,96 +1,95 @@ package com.cloudogu.gitops.integration.features +import static org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfSystemProperty -import static org.assertj.core.api.Assertions.assertThat - /** * This class checks if Prometheus is started well. * Prometheus contains own namespace ('monitoring') which owns and 3 Pods: * - Grafana * - Operator - * - prometheus-stack - */ + * - prometheus-stack*/ @EnabledIfSystemProperty(named = "micronaut.environments", matches = "full") class MonitoringTestIT extends KubenetesApiTestSetup { - String namespace = 'monitoring' - String grafanaPod = 'prometheus-stack-grafana' - String operatorPod = 'prometheus-stack-operator' - String prometheusPod = 'prometheus-stack-prometheus' - - @Override - boolean isReadyToStartTests() { - - def pods = api.listNamespacedPod(namespace).execute() - if (pods && !pods.items.isEmpty()) { - def grafanaPod = pods.items.find { it.getMetadata().name.contains(grafanaPod) } - if (grafanaPod) { - return "Running".equals(grafanaPod.status.phase) - } - } - return false; - } - - @BeforeAll - static void labelTest() { - println "###### PROMETHEUS ######" - } - - @Test - void ensureNamespaceExists() { - def namespaces = api.listNamespace().execute() - assertThat(namespaces).isNotNull() - assertThat(namespaces.getItems().isEmpty()).isFalse() - def namespace = namespaces.getItems().find { namespace.equals(it.getMetadata().name) } - assertThat(namespace).isNotNull() - - } - - @Test - void ensureGrafanaIsStarted() { - - def pods = api.listNamespacedPod(namespace).execute() - assertThat(pods).isNotNull() - assertThat(pods.getItems().isEmpty()).isFalse() - - def grafanaPod = pods.items.find { it.getMetadata().name.contains(grafanaPod) } - assertThat(grafanaPod).isNotNull() - assertThat(grafanaPod.status.phase).isEqualTo("Running") - } - - @Test - void ensureOperatorIsStarted() { - - def pods = api.listNamespacedPod(namespace).execute() - assertThat(pods).isNotNull() - assertThat(pods.getItems().isEmpty()).isFalse() - - def operator = pods.items.find { it.getMetadata().name.contains(operatorPod) } - assertThat(operator).isNotNull() - assertThat(operator.status.phase).isEqualTo("Running") - } - - @Disabled("not start on jenkins") - @Test - void ensureMonitoringIsStarted() { - - def pods = api.listNamespacedPod(namespace).execute() - assertThat(pods).isNotNull() - assertThat(pods.getItems().isEmpty()).isFalse() - - def prometheus = pods.items.find { it.getMetadata().name.contains(prometheusPod) } - assertThat(prometheus).isNotNull() - assertThat(prometheus.status.phase).isEqualTo("Running") - } - @Disabled("jenkins got only 2") - @Test - void ensureNamespaceGot3Pods() { - def pods = api.listNamespacedPod(namespace).execute() - assertThat(pods.getItems().size()).isEqualTo(3) - } + String namespace = 'monitoring' + String grafanaPod = 'prometheus-stack-grafana' + String operatorPod = 'prometheus-stack-operator' + String prometheusPod = 'prometheus-stack-prometheus' + + @Override + boolean isReadyToStartTests() { + + def pods = api.listNamespacedPod(namespace).execute() + if (pods && !pods.items.isEmpty()) { + def grafanaPod = pods.items.find { it.getMetadata().name.contains(grafanaPod) } + if (grafanaPod) { + return "Running".equals(grafanaPod.status.phase) + } + } + return false; + } + + @BeforeAll + static void labelTest() { + println "###### PROMETHEUS ######" + } + + @Test + void ensureNamespaceExists() { + def namespaces = api.listNamespace().execute() + assertThat(namespaces).isNotNull() + assertThat(namespaces.getItems().isEmpty()).isFalse() + def namespace = namespaces.getItems().find { namespace.equals(it.getMetadata().name) } + assertThat(namespace).isNotNull() + + } + + @Test + void ensureGrafanaIsStarted() { + + def pods = api.listNamespacedPod(namespace).execute() + assertThat(pods).isNotNull() + assertThat(pods.getItems().isEmpty()).isFalse() + + def grafanaPod = pods.items.find { it.getMetadata().name.contains(grafanaPod) } + assertThat(grafanaPod).isNotNull() + assertThat(grafanaPod.status.phase).isEqualTo("Running") + } + + @Test + void ensureOperatorIsStarted() { + + def pods = api.listNamespacedPod(namespace).execute() + assertThat(pods).isNotNull() + assertThat(pods.getItems().isEmpty()).isFalse() + + def operator = pods.items.find { it.getMetadata().name.contains(operatorPod) } + assertThat(operator).isNotNull() + assertThat(operator.status.phase).isEqualTo("Running") + } + + @Disabled("not start on jenkins") + @Test + void ensureMonitoringIsStarted() { + + def pods = api.listNamespacedPod(namespace).execute() + assertThat(pods).isNotNull() + assertThat(pods.getItems().isEmpty()).isFalse() + + def prometheus = pods.items.find { it.getMetadata().name.contains(prometheusPod) } + assertThat(prometheus).isNotNull() + assertThat(prometheus.status.phase).isEqualTo("Running") + } + + @Disabled("jenkins got only 2") + @Test + void ensureNamespaceGot3Pods() { + def pods = api.listNamespacedPod(namespace).execute() + assertThat(pods.getItems().size()).isEqualTo(3) + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/profiles/ArgoCDOperatorProfileTestIT.groovy b/src/test/groovy/com/cloudogu/gitops/integration/profiles/ArgoCDOperatorProfileTestIT.groovy index 241d9a7df..615d8b445 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/profiles/ArgoCDOperatorProfileTestIT.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/profiles/ArgoCDOperatorProfileTestIT.groovy @@ -1,6 +1,12 @@ package com.cloudogu.gitops.integration.profiles +import static org.assertj.core.api.Assertions.assertThat +import static org.assertj.core.api.Assertions.fail + import com.cloudogu.gitops.integration.TestK8sHelper + +import java.util.concurrent.TimeUnit + import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientBuilder import io.fabric8.kubernetes.client.KubernetesClientException @@ -10,71 +16,64 @@ import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfSystemProperty -import java.util.concurrent.TimeUnit - -import static org.assertj.core.api.Assertions.assertThat -import static org.assertj.core.api.Assertions.fail - /** * This tests can only be successfull, if one of theses profiles used. * - * To run locally: add -Dmicronaut.environments=operator-full to your execute configuration - */ + * To run locally: add -Dmicronaut.environments=operator-full to your execute configuration*/ @EnabledIfSystemProperty(named = "micronaut.environments", matches = "operator-full|operator-minimal") class ArgoCDOperatorProfileTestIT extends ProfileTestSetup { - static String namespaceOperator = 'argocd-operator-system' - static String namespaceArgocd = 'argocd' + static String namespaceOperator = 'argocd-operator-system' + static String namespaceArgocd = 'argocd' - @BeforeAll - static void labelTest() { - println "###### Integration ArgoCD Operator test ######" - try { - Awaitility.await() - .atMost(40, TimeUnit.MINUTES) - .pollInterval(5, TimeUnit.SECONDS) - .untilAsserted { - assert TestK8sHelper.checkAllPodsRunningInNamespace(namespaceOperator, 'argocd-operator-controller') && - TestK8sHelper.checkAllPodsRunningInNamespace(namespaceArgocd, 'argocd-server') - } - } catch (ConditionTimeoutException timeoutEx) { - TestK8sHelper.dumpNamespacesAndPods() - fail('Cluster not ready, sth false.') - } - } + @BeforeAll + static void labelTest() { + println "###### Integration ArgoCD Operator test ######" + try { + Awaitility.await() + .atMost(40, TimeUnit.MINUTES) + .pollInterval(5, TimeUnit.SECONDS) + .untilAsserted { + assert TestK8sHelper.checkAllPodsRunningInNamespace(namespaceOperator, 'argocd-operator-controller') && TestK8sHelper.checkAllPodsRunningInNamespace(namespaceArgocd, 'argocd-server') + } + } catch (ConditionTimeoutException timeoutEx) { + TestK8sHelper.dumpNamespacesAndPods() + fail('Cluster not ready, sth false.') + } + } - @Test - void ensureNamespaceExists() { + @Test + void ensureNamespaceExists() { - try (KubernetesClient client = new KubernetesClientBuilder().build()) { + try (KubernetesClient client = new KubernetesClientBuilder().build()) { - def argocdNamespace = client.namespaces().withName(namespaceOperator).get() + def argocdNamespace = client.namespaces().withName(namespaceOperator).get() - assertThat(argocdNamespace).isNotNull() - assert namespaceOperator.startsWith(argocdNamespace.metadata.name) + assertThat(argocdNamespace).isNotNull() + assert namespaceOperator.startsWith(argocdNamespace.metadata.name) - } catch (KubernetesClientException ex) { - // Handle exception - assert fail("not expected exception was thrown. ", ex) - } + } catch (KubernetesClientException ex) { + // Handle exception + assert fail("not expected exception was thrown. ", ex) + } - } + } - @Test - void ensureOperatorNamespaceExists() { + @Test + void ensureOperatorNamespaceExists() { - try (KubernetesClient client = new KubernetesClientBuilder().build()) { + try (KubernetesClient client = new KubernetesClientBuilder().build()) { - def argocdNamespace = client.namespaces().withName(namespaceArgocd).get() + def argocdNamespace = client.namespaces().withName(namespaceArgocd).get() - assertThat(argocdNamespace).isNotNull() + assertThat(argocdNamespace).isNotNull() - } catch (KubernetesClientException ex) { - // Handle exception - assert fail("not expected exception was thrown. ", ex) - } + } catch (KubernetesClientException ex) { + // Handle exception + assert fail("not expected exception was thrown. ", ex) + } - } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/profiles/ArgoCDProfileTestIT.groovy b/src/test/groovy/com/cloudogu/gitops/integration/profiles/ArgoCDProfileTestIT.groovy index be0ac608f..893d830ad 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/profiles/ArgoCDProfileTestIT.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/profiles/ArgoCDProfileTestIT.groovy @@ -1,5 +1,9 @@ package com.cloudogu.gitops.integration.profiles +import static org.assertj.core.api.Assertions.assertThat +import static org.assertj.core.api.Assertions.fail + +import java.util.concurrent.TimeUnit import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientBuilder @@ -9,78 +13,68 @@ import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfSystemProperty -import java.util.concurrent.TimeUnit - -import static org.assertj.core.api.Assertions.assertThat -import static org.assertj.core.api.Assertions.fail - /** * This tests can only be successfull, if one of theses profiles used. * - * To run locally: add -Dmicronaut.environments=full to your execute configuration - */ + * To run locally: add -Dmicronaut.environments=full to your execute configuration*/ @EnabledIfSystemProperty(named = "micronaut.environments", matches = "full|minimal|operator-full|content-examples|operator-minimal|operator-content-examples") -class ArgoCDProfileTestIT extends ProfileTestSetup{ - - String namespace = 'argocd' +class ArgoCDProfileTestIT extends ProfileTestSetup { - @BeforeAll - static void labelTest() { - println "###### Integration ArgoCD test ######" - } + String namespace = 'argocd' - @Test - void ensureNamespaceExists() { + @BeforeAll + static void labelTest() { + println "###### Integration ArgoCD test ######" + } - try (KubernetesClient client = new KubernetesClientBuilder().build()) { + @Test + void ensureNamespaceExists() { - def argocdNamespace = client.namespaces().withName(namespace).get() + try (KubernetesClient client = new KubernetesClientBuilder().build()) { - assertThat(argocdNamespace).isNotNull() + def argocdNamespace = client.namespaces().withName(namespace).get() - } catch (KubernetesClientException ex) { - // Handle exception - assert fail("not expected exception was thrown. ", ex) - } + assertThat(argocdNamespace).isNotNull() - } + } catch (KubernetesClientException ex) { + // Handle exception + assert fail("not expected exception was thrown. ", ex) + } - /** - * chechs that ArgoCD pods running **/ - @Test - void ensureArgoCDIsOnlineAndPodsAreRunning() { - String expectedPod1 = "argocd-application-controller" - String expectedPod2 = "argocd-applicationset-controller" -// String expectedPod3 = "argocd-notifications-controller" // not stable - String expectedPod4 = "argocd-redis" - String expectedPod5 = "argocd-repo-server" - String expectedPod6 = "argocd-server" + } - List expectedPods = [expectedPod1, expectedPod2, /* expectedPod3,*/ expectedPod4, expectedPod5, expectedPod6,] + /** + * chechs that ArgoCD pods running **/ + @Test + void ensureArgoCDIsOnlineAndPodsAreRunning() { + String expectedPod1 = "argocd-application-controller" + String expectedPod2 = "argocd-applicationset-controller" + // String expectedPod3 = "argocd-notifications-controller" // not stable + String expectedPod4 = "argocd-redis" + String expectedPod5 = "argocd-repo-server" + String expectedPod6 = "argocd-server" + List expectedPods = [expectedPod1, expectedPod2, /* expectedPod3,*/ expectedPod4, expectedPod5, expectedPod6,] - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - Awaitility.await().atMost(40, TimeUnit.MINUTES).untilAsserted { - def actualPods = client.pods().inNamespace(namespace).list().getItems() + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + Awaitility.await().atMost(40, TimeUnit.MINUTES).untilAsserted { + def actualPods = client.pods().inNamespace(namespace).list().getItems() - // 1. Verify all expected pods are present - def missingPods = expectedPods.findAll { prefix -> - !actualPods.any { it.getMetadata().getName().startsWith(prefix) } - } - assert missingPods.isEmpty() : "Missing these pods in argocd: ${missingPods}" + // 1. Verify all expected pods are present + def missingPods = expectedPods.findAll { prefix -> !actualPods.any { it.getMetadata().getName().startsWith(prefix) } + } + assert missingPods.isEmpty(): "Missing these pods in argocd: ${missingPods}" - // 2. Verify all relevant pods are in 'Running' phase - def notRunningPods = actualPods.findAll { pod -> - expectedPods.any { prefix -> pod.getMetadata().getName().startsWith(prefix) } - }.findAll { pod -> - pod.getStatus().getPhase() != "Running" - } + // 2. Verify all relevant pods are in 'Running' phase + def notRunningPods = actualPods.findAll { pod -> expectedPods.any { prefix -> pod.getMetadata().getName().startsWith(prefix) } + }.findAll { pod -> pod.getStatus().getPhase() != "Running" + } - assert notRunningPods.isEmpty() : "These pods are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" - } - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } + assert notRunningPods.isEmpty(): "These pods are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" + } + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/profiles/FullProfileTestIT.groovy b/src/test/groovy/com/cloudogu/gitops/integration/profiles/FullProfileTestIT.groovy index 935643a56..ced57d60a 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/profiles/FullProfileTestIT.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/profiles/FullProfileTestIT.groovy @@ -1,7 +1,12 @@ package com.cloudogu.gitops.integration.profiles +import static org.assertj.core.api.Assertions.fail + import com.cloudogu.gitops.integration.TestK8sHelper + +import java.util.concurrent.TimeUnit import groovy.util.logging.Slf4j + import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientBuilder import io.fabric8.kubernetes.client.KubernetesClientException @@ -10,10 +15,6 @@ import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfSystemProperty -import java.util.concurrent.TimeUnit - -import static org.assertj.core.api.Assertions.fail - /** * This test ensures all Pods and Namespaces are available, runnning at a startet GOP with - more or less - defaulöt values. * @@ -24,158 +25,152 @@ import static org.assertj.core.api.Assertions.fail // operator can not load nginx class FullProfileTestIT extends ProfileTestSetup { - /** - * Gets path to kubeconfig */ - static final String RUNNING = "Running" - static final String EXAMPLE_APPS_NAMESPACE = 'example-apps-staging' - static final String NGINX_POD = 'nginx-helm-jenkins' - - @BeforeAll - static void labelMyTest() { - log.info '########### K8S SMOKE TESTS PROFILE full ###########' - waitUntilAllPodsRunning() - } - - - private static void waitUntilAllPodsRunning() { - // if cert-manager is online, argocd is online, too! - Awaitility.await().atMost(40, TimeUnit.MINUTES).untilAsserted { - TestK8sHelper.checkAllPodsRunningInNamespace(EXAMPLE_APPS_NAMESPACE, NGINX_POD) - } - } - - @Test - void ensureJenkinsPodIsStarted() { - TestK8sHelper.checkAllPodsRunningInNamespace('jenkins', 'jenkins') - } - - @Test - void ensureArgoCDIsOnlineAndPodsAreRunning() { - String expectedPod1 = "argocd-application-controller" - String expectedPod2 = "argocd-applicationset-controller" -// String expectedPod3 = "argocd-notifications-controller" // not stable - String expectedPod4 = "argocd-redis" - String expectedPod5 = "argocd-repo-server" - String expectedPod6 = "argocd-server" - - List expectedPods = [expectedPod1, expectedPod2, /* expectedPod3,*/ expectedPod4, expectedPod5, expectedPod6,] - - - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - - def actualPods = client.pods().inNamespace('argocd').list().getItems() - - // 1. Verify all expected pods are present - def missingPods = expectedPods.findAll { prefix -> !actualPods.any { it.getMetadata().getName().startsWith(prefix) } - } - assert missingPods.isEmpty(): "Missing these pods in argocd: ${missingPods}" - - // 2. Verify all relevant pods are in 'Running' phase - def notRunningPods = actualPods.findAll { pod -> expectedPods.any { prefix -> pod.getMetadata().getName().startsWith(prefix) } - }.findAll { pod -> pod.getStatus().getPhase() != RUNNING - } - - assert notRunningPods.isEmpty(): "These pods are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" - - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } - - @Test - void ensureScmmPodIsStarted() { - - TestK8sHelper.checkAllPodsRunningInNamespace('scm-manager') - } - - @Test - void ensureNamespacesExists() { - List expectedNamespaces = ["argocd", - "cert-manager", - "jenkins", - "registry", - "scm-manager", - "default", - "example-apps-production", - "example-apps-staging", - "ingress", - "kube-node-lease", - "kube-public", - "kube-system", - "monitoring", - "secrets"] as List - - - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - - def currentNames = client.namespaces().list().getItems() - - // 1. Verify all expected pods are present - def missingNamespace = expectedNamespaces.findAll { prefix -> !currentNames.any { it.getMetadata().getName().startsWith(prefix) } - } - assert missingNamespace.isEmpty(): "Missing these Namespace: ${missingNamespace}" - - - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - - - } - -/** - * tests searches for ingress services and ensure ingress is used as loadbalancer*/ - @Test - void ensureNginxIsOnline() { - TestK8sHelper.checkAllPodsRunningInNamespace('ingress', 'traefik') - } - - @Test - void ensureCertManagerIsOnline() { - TestK8sHelper.checkAllPodsRunningInNamespace('cert-manager') - } - - @Test - void ensureVaultIsOnline() { - TestK8sHelper.checkAllPodsRunningInNamespace('secrets', 'vault-0') - } - - @Test - void ensureRegistryIsOnline() { - TestK8sHelper.checkAllPodsRunningInNamespace('registry', 'docker-registry') - } - - @Test - void ensureExternalSecretsPodsRunning() { - - String expectedPod1 = "external-secrets-webhook" - String expectedPod2 = "external-secrets-cert-controller" - - List expectedPods = [expectedPod1, expectedPod2] - - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - - def actualPods = client.pods().inNamespace('secrets').list().getItems() - - // 1. Verify all expected pods are present - def missingPods = expectedPods.findAll { prefix -> !actualPods.any { it.getMetadata().getName().startsWith(prefix) } - } - assert missingPods.isEmpty(): "Missing these pods in secrets: ${missingPods}" - - // 2. Verify all relevant pods are in 'Running' phase - def notRunningPods = actualPods.findAll { pod -> expectedPods.any { prefix -> pod.getMetadata().getName().startsWith(prefix) } - }.findAll { pod -> pod.getStatus().getPhase() != RUNNING - } - - assert notRunningPods.isEmpty(): "These pods are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" - - // vault-0, external-secrets-webhook, external-secrets-, external-secrets-cert-controller - assert actualPods.size() == 4 - - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } + /** + * Gets path to kubeconfig */ + static final String RUNNING = "Running" + static final String EXAMPLE_APPS_NAMESPACE = 'example-apps-staging' + static final String NGINX_POD = 'nginx-helm-jenkins' + + @BeforeAll + static void labelMyTest() { + log.info '########### K8S SMOKE TESTS PROFILE full ###########' + waitUntilAllPodsRunning() + } + + private static void waitUntilAllPodsRunning() { + // if cert-manager is online, argocd is online, too! + Awaitility.await().atMost(40, TimeUnit.MINUTES).untilAsserted { + TestK8sHelper.checkAllPodsRunningInNamespace(EXAMPLE_APPS_NAMESPACE, NGINX_POD) + } + } + + @Test + void ensureJenkinsPodIsStarted() { + TestK8sHelper.checkAllPodsRunningInNamespace('jenkins', 'jenkins') + } + + @Test + void ensureArgoCDIsOnlineAndPodsAreRunning() { + String expectedPod1 = "argocd-application-controller" + String expectedPod2 = "argocd-applicationset-controller" + // String expectedPod3 = "argocd-notifications-controller" // not stable + String expectedPod4 = "argocd-redis" + String expectedPod5 = "argocd-repo-server" + String expectedPod6 = "argocd-server" + + List expectedPods = [expectedPod1, expectedPod2, /* expectedPod3,*/ expectedPod4, expectedPod5, expectedPod6,] + + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + + def actualPods = client.pods().inNamespace('argocd').list().getItems() + + // 1. Verify all expected pods are present + def missingPods = expectedPods.findAll { prefix -> !actualPods.any { it.getMetadata().getName().startsWith(prefix) } + } + assert missingPods.isEmpty(): "Missing these pods in argocd: ${missingPods}" + + // 2. Verify all relevant pods are in 'Running' phase + def notRunningPods = actualPods.findAll { pod -> expectedPods.any { prefix -> pod.getMetadata().getName().startsWith(prefix) } + }.findAll { pod -> pod.getStatus().getPhase() != RUNNING + } + + assert notRunningPods.isEmpty(): "These pods are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" + + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } + + @Test + void ensureScmmPodIsStarted() { + + TestK8sHelper.checkAllPodsRunningInNamespace('scm-manager') + } + + @Test + void ensureNamespacesExists() { + List expectedNamespaces = ["argocd", + "cert-manager", + "jenkins", + "registry", + "scm-manager", + "default", + "example-apps-production", + "example-apps-staging", + "ingress", + "kube-node-lease", + "kube-public", + "kube-system", + "monitoring", + "secrets"] as List + + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + + def currentNames = client.namespaces().list().getItems() + + // 1. Verify all expected pods are present + def missingNamespace = expectedNamespaces.findAll { prefix -> !currentNames.any { it.getMetadata().getName().startsWith(prefix) } + } + assert missingNamespace.isEmpty(): "Missing these Namespace: ${missingNamespace}" + + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + + } + + /** + * tests searches for ingress services and ensure ingress is used as loadbalancer*/ + @Test + void ensureNginxIsOnline() { + TestK8sHelper.checkAllPodsRunningInNamespace('ingress', 'traefik') + } + + @Test + void ensureCertManagerIsOnline() { + TestK8sHelper.checkAllPodsRunningInNamespace('cert-manager') + } + + @Test + void ensureVaultIsOnline() { + TestK8sHelper.checkAllPodsRunningInNamespace('secrets', 'vault-0') + } + + @Test + void ensureRegistryIsOnline() { + TestK8sHelper.checkAllPodsRunningInNamespace('registry', 'docker-registry') + } + + @Test + void ensureExternalSecretsPodsRunning() { + + String expectedPod1 = "external-secrets-webhook" + String expectedPod2 = "external-secrets-cert-controller" + + List expectedPods = [expectedPod1, expectedPod2] + + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + + def actualPods = client.pods().inNamespace('secrets').list().getItems() + + // 1. Verify all expected pods are present + def missingPods = expectedPods.findAll { prefix -> !actualPods.any { it.getMetadata().getName().startsWith(prefix) } + } + assert missingPods.isEmpty(): "Missing these pods in secrets: ${missingPods}" + // 2. Verify all relevant pods are in 'Running' phase + def notRunningPods = actualPods.findAll { pod -> expectedPods.any { prefix -> pod.getMetadata().getName().startsWith(prefix) } + }.findAll { pod -> pod.getStatus().getPhase() != RUNNING + } + + assert notRunningPods.isEmpty(): "These pods are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" + + // vault-0, external-secrets-webhook, external-secrets-, external-secrets-cert-controller + assert actualPods.size() == 4 + + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/profiles/MandantProfileTestIT.groovy b/src/test/groovy/com/cloudogu/gitops/integration/profiles/MandantProfileTestIT.groovy index 342006fc4..0f7ba0ec2 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/profiles/MandantProfileTestIT.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/profiles/MandantProfileTestIT.groovy @@ -1,7 +1,5 @@ package com.cloudogu.gitops.integration.profiles -import com.cloudogu.gitops.integration.TestK8sHelper -import groovy.util.logging.Slf4j import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientBuilder import io.fabric8.kubernetes.client.KubernetesClientException @@ -11,7 +9,10 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.DisabledIfSystemProperty import org.junit.jupiter.api.condition.EnabledIfSystemProperty +import com.cloudogu.gitops.integration.TestK8sHelper + import java.util.concurrent.TimeUnit +import groovy.util.logging.Slf4j import static org.assertj.core.api.Assertions.fail @@ -25,101 +26,98 @@ import static org.assertj.core.api.Assertions.fail // operator can not load nginx class MandantProfileTestIT extends ProfileTestSetup { - /** - * Gets path to kubeconfig */ - static final String RUNNING = "Running" - static final String TENANT_POD_FOR_CONDITION = 'argocd-application-controller' - static final String TENANT_NAMESPACE_ARGOCD = 'tenant1-argocd' - static final String TENANT_NAMESPACE_REGISTRY = 'tenant1-registry' - static final String TENANT_NAMESPACE_SCM = 'tenant1-scm-manager' - - @BeforeAll - static void labelMyTest() { - log.info '########### PROFILE Operator-Mandants ###########' - waitUntilTenantIsReady() - } - - private static void waitUntilTenantIsReady() { - // tenant is created very late after running GOP twice! - Awaitility.await().atMost(40, TimeUnit.MINUTES).untilAsserted { - assert TestK8sHelper.checkAllPodsRunningInNamespace(TENANT_NAMESPACE_REGISTRY, "docker-registry") && - TestK8sHelper.checkAllPodsRunningInNamespace(TENANT_NAMESPACE_SCM, 'scmm-') - } - } - - @DisabledIfSystemProperty(named = "micronaut.environments", matches = "operator-mandants") - // just local - @Test - void ensureJenkinsPodIsStartedOnTenant() { - TestK8sHelper.checkAllPodsRunningInNamespace('tenant1-jenkins', 'jenkins') - } - - @Test - void ensureRegistryPodIsStartedOnTenant() { - TestK8sHelper.checkAllPodsRunningInNamespace('tenant1-registry', 'docker-registry') - } - - @DisabledIfSystemProperty(named = "micronaut.environments", matches = "operator-mandants") - // just local - @Test - void ensureArgocdPodsAreStartedOnTenant() { - def argocdNamespace = TENANT_NAMESPACE_ARGOCD - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-application-controller') - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-applicationset-controller') - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-redis') - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-repo-server') - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-server') - } - - @DisabledIfSystemProperty(named = "micronaut.environments", matches = "operator-mandants") - // just local - @Test - void ensureArgocdPodsAreStartedOnCentral() { - def argocdNamespace = 'argocd' - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-application-controller') - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-applicationset-controller') - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-redis') - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-repo-server') - TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-server') - } - - @Test - void ensureScmmPodIsStarted() { - - TestK8sHelper.checkAllPodsRunningInNamespace('scm-manager') - } - - @DisabledIfSystemProperty(named = "micronaut.environments", matches = "operator-mandants") - // just local - @Test - void ensureNamespacesExists() { - List expectedNamespaces = ["argocd", - "argocd-operator-system", - "scm-manager", - "default", - "tenant1-argocd", - "tenant1-jenkins", - "tenant1-registry", - "tenant1-example-apps-staging", - "tenant1-example-apps-staging", - "tenant1-scm-manager", - "kube-node-lease", - "kube-public", - "kube-system"] as List - - - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - - def currentNames = client.namespaces().list().getItems() - - // 1. Verify all expected pods are present - def missingNamespace = expectedNamespaces.findAll { prefix -> !currentNames.any { it.getMetadata().getName().startsWith(prefix) } - } - assert missingNamespace.isEmpty(): "Missing these Namespace: ${missingNamespace}" - - - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } + /** + * Gets path to kubeconfig */ + static final String RUNNING = "Running" + static final String TENANT_POD_FOR_CONDITION = 'argocd-application-controller' + static final String TENANT_NAMESPACE_ARGOCD = 'tenant1-argocd' + static final String TENANT_NAMESPACE_REGISTRY = 'tenant1-registry' + static final String TENANT_NAMESPACE_SCM = 'tenant1-scm-manager' + + @BeforeAll + static void labelMyTest() { + log.info '########### PROFILE Operator-Mandants ###########' + waitUntilTenantIsReady() + } + + private static void waitUntilTenantIsReady() { + // tenant is created very late after running GOP twice! + Awaitility.await().atMost(40, TimeUnit.MINUTES).untilAsserted { + assert TestK8sHelper.checkAllPodsRunningInNamespace(TENANT_NAMESPACE_REGISTRY, "docker-registry") && TestK8sHelper.checkAllPodsRunningInNamespace(TENANT_NAMESPACE_SCM, 'scmm-') + } + } + + @DisabledIfSystemProperty(named = "micronaut.environments", matches = "operator-mandants") + // just local + @Test + void ensureJenkinsPodIsStartedOnTenant() { + TestK8sHelper.checkAllPodsRunningInNamespace('tenant1-jenkins', 'jenkins') + } + + @Test + void ensureRegistryPodIsStartedOnTenant() { + TestK8sHelper.checkAllPodsRunningInNamespace('tenant1-registry', 'docker-registry') + } + + @DisabledIfSystemProperty(named = "micronaut.environments", matches = "operator-mandants") + // just local + @Test + void ensureArgocdPodsAreStartedOnTenant() { + def argocdNamespace = TENANT_NAMESPACE_ARGOCD + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-application-controller') + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-applicationset-controller') + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-redis') + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-repo-server') + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-server') + } + + @DisabledIfSystemProperty(named = "micronaut.environments", matches = "operator-mandants") + // just local + @Test + void ensureArgocdPodsAreStartedOnCentral() { + def argocdNamespace = 'argocd' + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-application-controller') + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-applicationset-controller') + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-redis') + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-repo-server') + TestK8sHelper.checkAllPodsRunningInNamespace(argocdNamespace, 'argocd-server') + } + + @Test + void ensureScmmPodIsStarted() { + + TestK8sHelper.checkAllPodsRunningInNamespace('scm-manager') + } + + @DisabledIfSystemProperty(named = "micronaut.environments", matches = "operator-mandants") + // just local + @Test + void ensureNamespacesExists() { + List expectedNamespaces = ["argocd", + "argocd-operator-system", + "scm-manager", + "default", + "tenant1-argocd", + "tenant1-jenkins", + "tenant1-registry", + "tenant1-example-apps-staging", + "tenant1-example-apps-staging", + "tenant1-scm-manager", + "kube-node-lease", + "kube-public", + "kube-system"] as List + + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + + def currentNames = client.namespaces().list().getItems() + + // 1. Verify all expected pods are present + def missingNamespace = expectedNamespaces.findAll { prefix -> !currentNames.any { it.getMetadata().getName().startsWith(prefix) } + } + assert missingNamespace.isEmpty(): "Missing these Namespace: ${missingNamespace}" + + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/profiles/PetclinicProfileTestIT.groovy b/src/test/groovy/com/cloudogu/gitops/integration/profiles/PetclinicProfileTestIT.groovy index 4c4b5e313..22b93c4dc 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/profiles/PetclinicProfileTestIT.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/profiles/PetclinicProfileTestIT.groovy @@ -1,135 +1,127 @@ package com.cloudogu.gitops.integration.profiles +import static org.assertj.core.api.Assertions.assertThat +import static org.assertj.core.api.Assertions.fail + import com.cloudogu.gitops.integration.TestK8sHelper + +import java.util.concurrent.TimeUnit import groovy.util.logging.Slf4j + import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientBuilder import io.fabric8.kubernetes.client.KubernetesClientException import org.awaitility.Awaitility import org.awaitility.core.ConditionTimeoutException -import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.DisabledIfSystemProperty import org.junit.jupiter.api.condition.EnabledIfSystemProperty -import org.junit.jupiter.api.extension.ExtensionContext -import org.junit.jupiter.api.extension.RegisterExtension -import org.junit.jupiter.api.extension.TestWatcher - -import java.util.concurrent.TimeUnit - -import static org.assertj.core.api.Assertions.assertThat -import static org.assertj.core.api.Assertions.fail /** * This tests can only be successfull, if one of theses profiles used. * - * * To run locally: add -Dmicronaut.environments=content-examples to your execute configuration - */ + * * To run locally: add -Dmicronaut.environments=content-examples to your execute configuration*/ @Slf4j @EnabledIfSystemProperty(named = "micronaut.environments", matches = "full|operator-full|content-examples") class PetclinicProfileTestIT extends ProfileTestSetup { - static String exampleStagingNs = 'example-apps-staging' - - @BeforeAll - static void labelTest() { - println "###### Testing Petclinic ######" - // petclinic need most of time to run. If online, we can start all tests. - try { - Awaitility.await() - .atMost(40, TimeUnit.MINUTES) - .pollInterval(5, TimeUnit.SECONDS) - .untilAsserted { - waitUntilPetclinicIsRunning() - } - } catch (ConditionTimeoutException timeoutEx) { - TestK8sHelper.dumpNamespacesAndPods() - fail('Cluster not ready, sth false.', timeoutEx) - } - } - // Start condition - private static void waitUntilPetclinicIsRunning() { - // Check Pod - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - def actualPods = client.pods().inNamespace(exampleStagingNs).list().getItems() - assert !actualPods.isEmpty(): "No pods found in petclinc - namespace: ${exampleStagingNs}" - def notRunningPods = actualPods.findAll { pod -> - pod.getStatus().getPhase() != "Running" - } - assert !actualPods.isEmpty() && notRunningPods.isEmpty(): "These pods in ${exampleStagingNs} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" - } - catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } - - - @Test - void ensurePetclinicIsRunningOnStages() { - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - - // Check Pod - def actualPods = client.pods().inNamespace(exampleStagingNs).list().getItems() - - assert !actualPods.isEmpty(): "No pods found in petclinc - namespace: ${exampleStagingNs}" - - def notRunningPods = actualPods.findAll { pod -> - pod.getStatus().getPhase() != "Running" - } - - assert notRunningPods.isEmpty(): "These pods in ${exampleStagingNs} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" - - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } - - @DisabledIfSystemProperty(named = "micronaut.environments", matches = "full|operator-full|content-examples") -// operator can not install nginx - @Test - void ensurePetclinicIngressIsOnline() { - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - def nameOfServiceAndIngress = "spring-petclinic-plain" - // check Ingress - def ingress = client.network() - .v1() - .ingresses() - .inNamespace(exampleStagingNs) - .withName(nameOfServiceAndIngress) - .get() - - assert ingress != null: "Ingress '${nameOfServiceAndIngress}' not found in '${exampleStagingNs}'" - - def hosts = (ingress.spec?.rules ?: []) - .collect { it?.host } - .findAll { it } - - assert hosts.get(0).contains("petclinic") // in this case, petclinic do not care about prefix - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } - - @DisabledIfSystemProperty(named = "micronaut.environments", matches = "full|operator-full|content-examples") -// operator can not install nginx - @Test - void ensurePetclinicServidsdsdceIsOnline() { - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - - // Check Service - def nameOfServiceAndIngress = "spring-petclinic-plain" - def service = client.services() - .inNamespace(exampleStagingNs) - .withName(nameOfServiceAndIngress) - .get() - - assertThat(service).isNotNull() - - - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } + static String exampleStagingNs = 'example-apps-staging' + + @BeforeAll + static void labelTest() { + println "###### Testing Petclinic ######" + // petclinic need most of time to run. If online, we can start all tests. + try { + Awaitility.await() + .atMost(40, TimeUnit.MINUTES) + .pollInterval(5, TimeUnit.SECONDS) + .untilAsserted { + waitUntilPetclinicIsRunning() + } + } catch (ConditionTimeoutException timeoutEx) { + TestK8sHelper.dumpNamespacesAndPods() + fail('Cluster not ready, sth false.', timeoutEx) + } + } + + // Start condition + private static void waitUntilPetclinicIsRunning() { + // Check Pod + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + def actualPods = client.pods().inNamespace(exampleStagingNs).list().getItems() + assert !actualPods.isEmpty(): "No pods found in petclinc - namespace: ${exampleStagingNs}" + def notRunningPods = actualPods.findAll { pod -> pod.getStatus().getPhase() != "Running" + } + assert !actualPods.isEmpty() && notRunningPods.isEmpty(): "These pods in ${exampleStagingNs} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } + + @Test + void ensurePetclinicIsRunningOnStages() { + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + + // Check Pod + def actualPods = client.pods().inNamespace(exampleStagingNs).list().getItems() + + assert !actualPods.isEmpty(): "No pods found in petclinc - namespace: ${exampleStagingNs}" + + def notRunningPods = actualPods.findAll { pod -> pod.getStatus().getPhase() != "Running" + } + + assert notRunningPods.isEmpty(): "These pods in ${exampleStagingNs} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" + + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } + + @DisabledIfSystemProperty(named = "micronaut.environments", matches = "full|operator-full|content-examples") + // operator can not install nginx + @Test + void ensurePetclinicIngressIsOnline() { + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + def nameOfServiceAndIngress = "spring-petclinic-plain" + // check Ingress + def ingress = client.network() + .v1() + .ingresses() + .inNamespace(exampleStagingNs) + .withName(nameOfServiceAndIngress) + .get() + + assert ingress != null: "Ingress '${nameOfServiceAndIngress}' not found in '${exampleStagingNs}'" + + def hosts = (ingress.spec?.rules ?: []) + .collect { it?.host } + .findAll { it } + + assert hosts.get(0).contains("petclinic") // in this case, petclinic do not care about prefix + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } + + @DisabledIfSystemProperty(named = "micronaut.environments", matches = "full|operator-full|content-examples") + // operator can not install nginx + @Test + void ensurePetclinicServidsdsdceIsOnline() { + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + + // Check Service + def nameOfServiceAndIngress = "spring-petclinic-plain" + def service = client.services() + .inNamespace(exampleStagingNs) + .withName(nameOfServiceAndIngress) + .get() + + assertThat(service).isNotNull() + + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/profiles/PrefixProfileTestIT.groovy b/src/test/groovy/com/cloudogu/gitops/integration/profiles/PrefixProfileTestIT.groovy index 2ee24b711..f4fdbd646 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/profiles/PrefixProfileTestIT.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/profiles/PrefixProfileTestIT.groovy @@ -1,7 +1,12 @@ package com.cloudogu.gitops.integration.profiles +import static org.assertj.core.api.Assertions.fail + import com.cloudogu.gitops.integration.TestK8sHelper + +import java.util.concurrent.TimeUnit import groovy.util.logging.Slf4j + import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientBuilder import io.fabric8.kubernetes.client.KubernetesClientException @@ -11,112 +16,99 @@ import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfSystemProperty -import java.util.concurrent.TimeUnit - -import static org.assertj.core.api.Assertions.fail - /** * This tests can only be successfull, if one of theses profiles used. - * * To run locally: add -Dmicronaut.environments=full-prefix to your execute configuration - */ + * * To run locally: add -Dmicronaut.environments=full-prefix to your execute configuration*/ @Slf4j @EnabledIfSystemProperty(named = "micronaut.environments", matches = "full-prefix") class PrefixProfileTestIT extends ProfileTestSetup { - // is used for pre-condition - static String exampleStagingNs = 'my-prefix-example-apps-staging' - static String argocdNs = 'my-prefix-argocd' - String scmManagerNs = 'my-prefix-scm-manager' - String registryNs = 'my-prefix-registry' - String ingressNs = 'my-prefix-ingress' /* Jenking can not start ingress*/ - static String certManagerNs = 'my-prefix-cert-manager' - String jenkinsNs = 'my-prefix-jenkins' - static String monitoringNs = 'my-prefix-monitoring' - String secretsNs = 'my-prefix-secrets' - String exampleProductionNs = 'my-prefix-example-apps-production' - - @BeforeAll - static void labelTest() { - log.info "###### Integration test for Prefix ######" + // is used for pre-condition + static String exampleStagingNs = 'my-prefix-example-apps-staging' + static String argocdNs = 'my-prefix-argocd' + String scmManagerNs = 'my-prefix-scm-manager' + String registryNs = 'my-prefix-registry' + String ingressNs = 'my-prefix-ingress' + /* Jenking can not start ingress*/ + static String certManagerNs = 'my-prefix-cert-manager' + String jenkinsNs = 'my-prefix-jenkins' + static String monitoringNs = 'my-prefix-monitoring' + String secretsNs = 'my-prefix-secrets' + String exampleProductionNs = 'my-prefix-example-apps-production' - try { - Awaitility.await() - .atMost(40, TimeUnit.MINUTES) - .pollInterval(5, TimeUnit.SECONDS) - .untilAsserted { - waitUntilPetclinicIsRunning() - } - } catch (ConditionTimeoutException timeoutEx) { - TestK8sHelper.dumpNamespacesAndPods() - fail('Cluster not ready, sth false.') - } - } + @BeforeAll + static void labelTest() { + log.info "###### Integration test for Prefix ######" - // Start condition - private static void waitUntilPetclinicIsRunning() { - // Check Pod - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - def actualPods = client.pods().inNamespace(exampleStagingNs).list().getItems() - assert !actualPods.isEmpty(): "No pods found in petclinc - namespace: ${exampleStagingNs}" - def notRunningPods = actualPods.findAll { pod -> - pod.getStatus().getPhase() != "Running" - } - assert !actualPods.isEmpty() && notRunningPods.isEmpty(): "These pods in ${exampleStagingNs} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" - } - catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } + try { + Awaitility.await() + .atMost(40, TimeUnit.MINUTES) + .pollInterval(5, TimeUnit.SECONDS) + .untilAsserted { + waitUntilPetclinicIsRunning() + } + } catch (ConditionTimeoutException timeoutEx) { + TestK8sHelper.dumpNamespacesAndPods() + fail('Cluster not ready, sth false.') + } + } + // Start condition + private static void waitUntilPetclinicIsRunning() { + // Check Pod + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + def actualPods = client.pods().inNamespace(exampleStagingNs).list().getItems() + assert !actualPods.isEmpty(): "No pods found in petclinc - namespace: ${exampleStagingNs}" + def notRunningPods = actualPods.findAll { pod -> pod.getStatus().getPhase() != "Running" + } + assert !actualPods.isEmpty() && notRunningPods.isEmpty(): "These pods in ${exampleStagingNs} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } - @Test - void ensureNamespacesExistWithPrefix() { - List expectedNamespaces = [ - argocdNs, - scmManagerNs, - registryNs, - ingressNs, - certManagerNs, - jenkinsNs, - monitoringNs, - secretsNs, - exampleProductionNs, - exampleStagingNs - ] + @Test + void ensureNamespacesExistWithPrefix() { + List expectedNamespaces = [argocdNs, + scmManagerNs, + registryNs, + ingressNs, + certManagerNs, + jenkinsNs, + monitoringNs, + secretsNs, + exampleProductionNs, + exampleStagingNs] - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - def currentNames = client.namespaces().list().getItems() + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + def currentNames = client.namespaces().list().getItems() - // 1. Verify all expected pods are present - def missingNamespace = expectedNamespaces.findAll { prefix -> - !currentNames.any { it.getMetadata().getName().startsWith(prefix) } - } - assert missingNamespace.isEmpty(): "Missing these Namespace: ${missingNamespace}" - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } + // 1. Verify all expected pods are present + def missingNamespace = expectedNamespaces.findAll { prefix -> !currentNames.any { it.getMetadata().getName().startsWith(prefix) } + } + assert missingNamespace.isEmpty(): "Missing these Namespace: ${missingNamespace}" + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } - } + } - @Test - void ensurePodsAreRunningInPrefixedNamespaces() { - List namespacesToCheck = [ - argocdNs, - scmManagerNs, - registryNs, - certManagerNs, - monitoringNs - ] - try (KubernetesClient client = new KubernetesClientBuilder().build()) { - namespacesToCheck.each { ns -> - def actualPods = client.pods().inNamespace(ns).list().getItems() - assert !actualPods.isEmpty(): "No pods found in namespace: ${ns}" - def notRunningPods = actualPods.findAll { pod -> - pod.getStatus().getPhase() != "Running" - } - assert notRunningPods.isEmpty(): "These pods in ${ns} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" - } - } catch (KubernetesClientException ex) { - fail("Unexpected Kubernetes exception", ex) - } - } + @Test + void ensurePodsAreRunningInPrefixedNamespaces() { + List namespacesToCheck = [argocdNs, + scmManagerNs, + registryNs, + certManagerNs, + monitoringNs] + try (KubernetesClient client = new KubernetesClientBuilder().build()) { + namespacesToCheck.each { ns -> + def actualPods = client.pods().inNamespace(ns).list().getItems() + assert !actualPods.isEmpty(): "No pods found in namespace: ${ns}" + def notRunningPods = actualPods.findAll { pod -> pod.getStatus().getPhase() != "Running" + } + assert notRunningPods.isEmpty(): "These pods in ${ns} are not yet running: ${notRunningPods.collect { it.getMetadata().getName() + ':' + it.getStatus().getPhase() }}" + } + } catch (KubernetesClientException ex) { + fail("Unexpected Kubernetes exception", ex) + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/integration/profiles/ProfileTestSetup.groovy b/src/test/groovy/com/cloudogu/gitops/integration/profiles/ProfileTestSetup.groovy index 2464ab8e7..8e6e2e43b 100644 --- a/src/test/groovy/com/cloudogu/gitops/integration/profiles/ProfileTestSetup.groovy +++ b/src/test/groovy/com/cloudogu/gitops/integration/profiles/ProfileTestSetup.groovy @@ -1,34 +1,35 @@ package com.cloudogu.gitops.integration.profiles import com.cloudogu.gitops.integration.TestK8sHelper + import groovy.util.logging.Slf4j + import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.extension.ExtensionContext import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.extension.TestWatcher /** - * Common setup to dump K88s content after failing tests. - */ + * Common setup to dump K88s content after failing tests.*/ @Slf4j -class ProfileTestSetup implements TestWatcher{ - - private static boolean anyTestFailed = false - @RegisterExtension - final TestWatcher watcher = this - - @Override - void testFailed(ExtensionContext context, Throwable cause) { - anyTestFailed = true - } - - @AfterAll - static void afterAllOnlyOnFailure() { - // if one test fails, logging is necessary - if (anyTestFailed) { - log.info "############## K8s dump ##############" - TestK8sHelper.dumpNamespacesAndPods() - } - } - -} +class ProfileTestSetup implements TestWatcher { + + private static boolean anyTestFailed = false + @RegisterExtension + final TestWatcher watcher = this + + @Override + void testFailed(ExtensionContext context, Throwable cause) { + anyTestFailed = true + } + + @AfterAll + static void afterAllOnlyOnFailure() { + // if one test fails, logging is necessary + if (anyTestFailed) { + log.info "############## K8s dump ##############" + TestK8sHelper.dumpNamespacesAndPods() + } + } + +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/jenkins/GlobalPropertyManagerTest.groovy b/src/test/groovy/com/cloudogu/gitops/jenkins/GlobalPropertyManagerTest.groovy index 533f68be5..d95250664 100644 --- a/src/test/groovy/com/cloudogu/gitops/jenkins/GlobalPropertyManagerTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/jenkins/GlobalPropertyManagerTest.groovy @@ -1,22 +1,21 @@ package com.cloudogu.gitops.jenkins - -import org.junit.jupiter.api.Test - import static groovy.test.GroovyAssert.shouldFail import static org.mockito.ArgumentMatchers.anyString import static org.mockito.Mockito.* +import org.junit.jupiter.api.Test + class GlobalPropertyManagerTest { - @Test - void 'sets global property'() { - def client = mock(JenkinsApiClient) - def propertyManager = new GlobalPropertyManager(client) + @Test + void 'sets global property'() { + def client = mock(JenkinsApiClient) + def propertyManager = new GlobalPropertyManager(client) - when(client.runScript(anyString())).thenReturn("Done") - propertyManager.setGlobalProperty('the-key', 'the-value') + when(client.runScript(anyString())).thenReturn("Done") + propertyManager.setGlobalProperty('the-key', 'the-value') - verify(client).runScript(""" + verify(client).runScript(""" instance = Jenkins.getInstance() globalNodeProperties = instance.getGlobalNodeProperties() envVarsNodePropertyList = globalNodeProperties.getAll(hudson.slaves.EnvironmentVariablesNodeProperty.class) @@ -38,27 +37,27 @@ class GlobalPropertyManagerTest { instance.save() print("Done") """) - } + } - @Test - void 'throws when there was an error when creating global property'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") + @Test + void 'throws when there was an error when creating global property'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") - shouldFail(RuntimeException) { - new GlobalPropertyManager(client).setGlobalProperty("the-key", "the-value") - } - } + shouldFail(RuntimeException) { + new GlobalPropertyManager(client).setGlobalProperty("the-key", "the-value") + } + } - @Test - void 'deletes global property'() { - def client = mock(JenkinsApiClient) - def propertyManager = new GlobalPropertyManager(client) + @Test + void 'deletes global property'() { + def client = mock(JenkinsApiClient) + def propertyManager = new GlobalPropertyManager(client) - when(client.runScript(anyString())).thenReturn("Nothing to do") - propertyManager.deleteGlobalProperty('the-key') + when(client.runScript(anyString())).thenReturn("Nothing to do") + propertyManager.deleteGlobalProperty('the-key') - verify(client).runScript(""" + verify(client).runScript(""" def instance = Jenkins.getInstance() def globalNodeProperties = instance.getGlobalNodeProperties() def envVarsNodePropertyList = globalNodeProperties.getAll(hudson.slaves.EnvironmentVariablesNodeProperty.class) @@ -72,15 +71,15 @@ class GlobalPropertyManagerTest { envVars.remove("the-key") print("Done") """) - } + } - @Test - void 'throws when there was an error when deleting global property'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") + @Test + void 'throws when there was an error when deleting global property'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") - shouldFail(RuntimeException) { - new GlobalPropertyManager(client).deleteGlobalProperty("the-key") - } - } -} + shouldFail(RuntimeException) { + new GlobalPropertyManager(client).deleteGlobalProperty("the-key") + } + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClientTest.groovy b/src/test/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClientTest.groovy index 21a7b2a15..da29ec948 100644 --- a/src/test/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClientTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClientTest.groovy @@ -1,13 +1,13 @@ package com.cloudogu.gitops.jenkins +import static com.github.tomakehurst.wiremock.client.WireMock.* +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import static groovy.test.GroovyAssert.shouldFail +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.Config -import com.github.tomakehurst.wiremock.junit5.WireMockExtension + import io.micronaut.context.ApplicationContext -import okhttp3.FormBody -import okhttp3.JavaNetCookieJar -import okhttp3.OkHttpClient -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory @@ -16,261 +16,253 @@ import javax.net.ssl.X509TrustManager import java.security.SecureRandom import java.security.cert.X509Certificate -import static com.github.tomakehurst.wiremock.client.WireMock.* -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig -import static groovy.test.GroovyAssert.shouldFail -import static org.assertj.core.api.Assertions.assertThat +import com.github.tomakehurst.wiremock.junit5.WireMockExtension +import okhttp3.FormBody +import okhttp3.JavaNetCookieJar +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension class JenkinsApiClientTest { - @RegisterExtension - static WireMockExtension wireMock = WireMockExtension.newInstance() - .options(wireMockConfig() - .dynamicPort() - .dynamicHttpsPort()) - .build() - - @Test - void 'runs script with crumb'() { - wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(aResponse() - .withStatus(200) - .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) - - wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) - .willReturn(aResponse() - .withStatus(200) - .withBody("ok"))) - - def httpClient = getUnsafeOkHttpClient().newBuilder().cookieJar(new JavaNetCookieJar(new CookieManager())).build() - def apiClient = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), - httpClient) - - def result = apiClient.runScript("println('ok')") - assertThat(result).isEqualTo("ok") - - wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .withHeader("Authorization", matching("Basic .*"))) - - wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/scriptText")) - .withHeader("Authorization", matching("Basic .*")) - .withHeader("Jenkins-Crumb", equalTo("the-crumb"))) - } - - @Test - void 'adds crumb to sendRequest'() { - wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(aResponse() - .withStatus(200) - .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) - - wireMock.stubFor(post(urlPathEqualTo("/jenkins/foobar")) - .willReturn(aResponse().withStatus(200))) - - def client = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), - getUnsafeOkHttpClient()) - client.postRequestWithCrumb("foobar") - - wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) - wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/foobar")) - .withHeader("Jenkins-Crumb", equalTo("the-crumb"))) - } - - @Test - void 'adds crumb and post data to sendRequest'() { - wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(aResponse() - .withStatus(200) - .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) - - wireMock.stubFor(post(urlPathEqualTo("/jenkins/foobar")) - .willReturn(aResponse().withStatus(200))) - - def client = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), - getUnsafeOkHttpClient()) - client.postRequestWithCrumb("foobar", new FormBody.Builder().add('key', 'value with spaces').build()) - - wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) - wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/foobar")) - .withHeader("Jenkins-Crumb", equalTo("the-crumb")) - .withFormParam("key", equalTo("value with spaces"))) + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig() + .dynamicPort() + .dynamicHttpsPort()) + .build() + + @Test + void 'runs script with crumb'() { + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .willReturn(aResponse() + .withStatus(200) + .withBody("ok"))) + + def httpClient = getUnsafeOkHttpClient().newBuilder().cookieJar(new JavaNetCookieJar(new CookieManager())).build() + def apiClient = new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), + httpClient) + + def result = apiClient.runScript("println('ok')") + assertThat(result).isEqualTo("ok") + + wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .withHeader("Authorization", matching("Basic .*"))) + + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/scriptText")) + .withHeader("Authorization", matching("Basic .*")) + .withHeader("Jenkins-Crumb", equalTo("the-crumb"))) + } + + @Test + void 'adds crumb to sendRequest'() { + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/foobar")) + .willReturn(aResponse().withStatus(200))) + + def client = new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), + getUnsafeOkHttpClient()) + client.postRequestWithCrumb("foobar") + + wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/foobar")) + .withHeader("Jenkins-Crumb", equalTo("the-crumb"))) + } + + @Test + void 'adds crumb and post data to sendRequest'() { + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/foobar")) + .willReturn(aResponse().withStatus(200))) + + def client = new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), + getUnsafeOkHttpClient()) + client.postRequestWithCrumb("foobar", new FormBody.Builder().add('key', 'value with spaces').build()) + + wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/foobar")) + .withHeader("Jenkins-Crumb", equalTo("the-crumb")) + .withFormParam("key", equalTo("value with spaces"))) } - @Test - void 'allows self-signed certificates when using insecure'() { - wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(aResponse() - .withStatus(200) - .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) - - wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) - .willReturn(aResponse() - .withStatus(200) - .withBody("ok"))) - - def apiClient = ApplicationContext.run() - .registerSingleton(new Config( - application: new Config.ApplicationSchema( - insecure: true), - jenkins: new Config.JenkinsSchema( - url: "${wireMock.baseUrl().replace('http://', 'https://')}/jenkins") - )) - .getBean(JenkinsApiClient) - - def result = apiClient.runScript("println('ok')") - assertThat(result).isEqualTo("ok") - - wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .withHeader("Authorization", matching("Basic .*"))) - - wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/scriptText")) - .withHeader("Authorization", matching("Basic .*")) - .withHeader("Jenkins-Crumb", equalTo("the-crumb"))) - } - - @Test - void 'retries on invalid crumb'() { - wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .inScenario("Invalid Crumb Retry") - .whenScenarioStateIs("Started") - .willReturn(aResponse() - .withStatus(200) - .withBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}')) - .willSetStateTo("First Crumb")) - - wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) - .inScenario("Invalid Crumb Retry") - .whenScenarioStateIs("First Crumb") - .withHeader("Jenkins-Crumb", equalTo("the-invalid-crumb")) - .willReturn(aResponse() - .withStatus(403) - .withBody('{"servlet":"Stapler", "message":"No valid crumb was included in the request", "url":"/scriptText", "status":"403"}')) - .willSetStateTo("Invalid Crumb Response")) - - wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .inScenario("Invalid Crumb Retry") - .whenScenarioStateIs("Invalid Crumb Response") - .willReturn(aResponse() - .withStatus(200) - .withBody('{"crumb": "the-second-crumb", "crumbRequestField": "Jenkins-Crumb"}')) - .willSetStateTo("Second Crumb")) - - wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) - .inScenario("Invalid Crumb Retry") - .whenScenarioStateIs("Second Crumb") - .withHeader("Jenkins-Crumb", equalTo("the-second-crumb")) - .willReturn(aResponse() - .withStatus(200) - .withBody("ok"))) - - def httpClient = getUnsafeOkHttpClient() - def apiClient = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), - httpClient) - apiClient.setMaxRetries(3) - apiClient.setWaitPeriodInMs(0) - - def result = apiClient.runScript("println('ok')") - assertThat(result).isEqualTo("ok") - - wireMock.verify(2, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) - wireMock.verify(2, postRequestedFor(urlPathEqualTo("/jenkins/scriptText"))) - } - - @Test - void 'retries on invalid crumb are limited'() { - wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(aResponse() - .withStatus(200) - .withBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) - - wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) - .willReturn(aResponse() - .withStatus(403) - .withBody('{"servlet":"Stapler", "message":"No valid crumb was included in the request", "url":"/scriptText", "status":"403"}'))) - - def httpClient = getUnsafeOkHttpClient() - def apiClient = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), - httpClient) - apiClient.setMaxRetries(3) - apiClient.setWaitPeriodInMs(0) - - shouldFail(RuntimeException) { - apiClient.runScript("println('ok')") - } - - wireMock.verify(3, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) - wireMock.verify(3, postRequestedFor(urlPathEqualTo("/jenkins/scriptText"))) - } - - @Test - void 'retries when fetching crumb fails'() { - wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .inScenario("Crumb Fetch Retry") - .whenScenarioStateIs("Started") - .willReturn(aResponse() - .withStatus(401) - .withBody("error")) - .willSetStateTo("First Attempt Failed")) - - wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .inScenario("Crumb Fetch Retry") - .whenScenarioStateIs("First Attempt Failed") - .willReturn(aResponse() - .withStatus(200) - .withBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) - - wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) - .willReturn(aResponse() - .withStatus(200) - .withBody("ok"))) - - def httpClient = getUnsafeOkHttpClient() - def apiClient = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), - httpClient) - apiClient.setMaxRetries(3) - apiClient.setWaitPeriodInMs(0) - - def result = apiClient.runScript("println('ok')") - assertThat(result).isEqualTo("ok") - - wireMock.verify(2, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) - wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/scriptText"))) - } - - private static OkHttpClient getUnsafeOkHttpClient() { - try { - // Create a trust manager that does not validate certificate chains - final TrustManager[] trustAllCerts = [ - new X509TrustManager() { - @Override - void checkClientTrusted(X509Certificate[] chain, String authType) {} - @Override - void checkServerTrusted(X509Certificate[] chain, String authType) {} - @Override - X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0] - } - } - ] as TrustManager[] - - // Install the all-trusting trust manager - final SSLContext sslContext = SSLContext.getInstance("SSL") - sslContext.init(null, trustAllCerts, new SecureRandom()) - final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory() - - return new OkHttpClient.Builder() - .sslSocketFactory(sslSocketFactory, (X509TrustManager)trustAllCerts[0]) - .hostnameVerifier { hostname, session -> true } - .build() - } catch (Exception e) { - throw new RuntimeException(e) - } - } + @Test + void 'allows self-signed certificates when using insecure'() { + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .willReturn(aResponse() + .withStatus(200) + .withBody("ok"))) + + def apiClient = ApplicationContext.run() + .registerSingleton(new Config(application: new Config.ApplicationSchema(insecure: true), + jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl().replace('http://', 'https://')}/jenkins"))) + .getBean(JenkinsApiClient) + + def result = apiClient.runScript("println('ok')") + assertThat(result).isEqualTo("ok") + + wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .withHeader("Authorization", matching("Basic .*"))) + + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/scriptText")) + .withHeader("Authorization", matching("Basic .*")) + .withHeader("Jenkins-Crumb", equalTo("the-crumb"))) + } + + @Test + void 'retries on invalid crumb'() { + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .inScenario("Invalid Crumb Retry") + .whenScenarioStateIs("Started") + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}')) + .willSetStateTo("First Crumb")) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .inScenario("Invalid Crumb Retry") + .whenScenarioStateIs("First Crumb") + .withHeader("Jenkins-Crumb", equalTo("the-invalid-crumb")) + .willReturn(aResponse() + .withStatus(403) + .withBody('{"servlet":"Stapler", "message":"No valid crumb was included in the request", "url":"/scriptText", "status":"403"}')) + .willSetStateTo("Invalid Crumb Response")) + + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .inScenario("Invalid Crumb Retry") + .whenScenarioStateIs("Invalid Crumb Response") + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-second-crumb", "crumbRequestField": "Jenkins-Crumb"}')) + .willSetStateTo("Second Crumb")) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .inScenario("Invalid Crumb Retry") + .whenScenarioStateIs("Second Crumb") + .withHeader("Jenkins-Crumb", equalTo("the-second-crumb")) + .willReturn(aResponse() + .withStatus(200) + .withBody("ok"))) + + def httpClient = getUnsafeOkHttpClient() + def apiClient = new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), + httpClient) + apiClient.setMaxRetries(3) + apiClient.setWaitPeriodInMs(0) + + def result = apiClient.runScript("println('ok')") + assertThat(result).isEqualTo("ok") + + wireMock.verify(2, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(2, postRequestedFor(urlPathEqualTo("/jenkins/scriptText"))) + } + + @Test + void 'retries on invalid crumb are limited'() { + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .willReturn(aResponse() + .withStatus(403) + .withBody('{"servlet":"Stapler", "message":"No valid crumb was included in the request", "url":"/scriptText", "status":"403"}'))) + + def httpClient = getUnsafeOkHttpClient() + def apiClient = new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), + httpClient) + apiClient.setMaxRetries(3) + apiClient.setWaitPeriodInMs(0) + + shouldFail(RuntimeException) { + apiClient.runScript("println('ok')") + } + + wireMock.verify(3, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(3, postRequestedFor(urlPathEqualTo("/jenkins/scriptText"))) + } + + @Test + void 'retries when fetching crumb fails'() { + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .inScenario("Crumb Fetch Retry") + .whenScenarioStateIs("Started") + .willReturn(aResponse() + .withStatus(401) + .withBody("error")) + .willSetStateTo("First Attempt Failed")) + + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .inScenario("Crumb Fetch Retry") + .whenScenarioStateIs("First Attempt Failed") + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .willReturn(aResponse() + .withStatus(200) + .withBody("ok"))) + + def httpClient = getUnsafeOkHttpClient() + def apiClient = new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), + httpClient) + apiClient.setMaxRetries(3) + apiClient.setWaitPeriodInMs(0) + + def result = apiClient.runScript("println('ok')") + assertThat(result).isEqualTo("ok") + + wireMock.verify(2, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/scriptText"))) + } + + private static OkHttpClient getUnsafeOkHttpClient() { + try { + // Create a trust manager that does not validate certificate chains + final TrustManager[] trustAllCerts = [new X509TrustManager() { + @Override + void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0] + } + }] as TrustManager[] + + // Install the all-trusting trust manager + final SSLContext sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, new SecureRandom()) + final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory() + + return new OkHttpClient.Builder() + .sslSocketFactory(sslSocketFactory, (X509TrustManager) trustAllCerts[0]) + .hostnameVerifier { hostname, session -> true } + .build() + } catch (Exception e) { + throw new RuntimeException(e) + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/jenkins/JobManagerTest.groovy b/src/test/groovy/com/cloudogu/gitops/jenkins/JobManagerTest.groovy index 76027b657..c97d6cd4f 100644 --- a/src/test/groovy/com/cloudogu/gitops/jenkins/JobManagerTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/jenkins/JobManagerTest.groovy @@ -1,265 +1,259 @@ package com.cloudogu.gitops.jenkins -import com.cloudogu.gitops.config.Config -import com.github.tomakehurst.wiremock.WireMockServer -import okhttp3.OkHttpClient -import org.junit.jupiter.api.Test - import static com.github.tomakehurst.wiremock.client.WireMock.* import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options import static groovy.test.GroovyAssert.shouldFail import static org.assertj.core.api.Assertions.assertThat import static org.mockito.ArgumentMatchers.anyString -import static org.mockito.Mockito.* +import static org.mockito.Mockito.mock +import static org.mockito.Mockito.when + +import com.cloudogu.gitops.config.Config + +import com.github.tomakehurst.wiremock.WireMockServer +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Test class JobManagerTest { - @Test - void 'creates credential'() { - def wireMockServer = new WireMockServer(options().dynamicPort()) - wireMockServer.start() + @Test + void 'creates credential'() { + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + + try { + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathMatching(".*createCredentials.*")) + .willReturn(ok())) + + def jobManager = new JobManager(new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), + new OkHttpClient())) + + jobManager.createCredential('the-jobname', 'the-id', 'the-username', 'the-password', 'some description') + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname/credentials/store/folder/domain/_/createCredentials"))) + + def requests = wireMockServer.findAll(postRequestedFor(urlPathMatching(".*createCredentials.*"))) + assertThat(requests).hasSize(1) - try { - wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(okJson('{"crumb":"the-crumb"}'))) + def requestBody = requests[0].bodyAsString + assertThat(URLDecoder.decode(requestBody, "utf-8")) + .isEqualTo('json={"credentials":{"scope":"GLOBAL","id":"the-id","username":"the-username","password":"the-password","description":"some description","$class":"com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl"}}') - wireMockServer.stubFor(post(urlPathMatching(".*createCredentials.*")) - .willReturn(ok())) - - def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), - new OkHttpClient())) - - jobManager.createCredential('the-jobname', 'the-id', 'the-username', 'the-password', 'some description') - - wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname/credentials/store/folder/domain/_/createCredentials"))) - - def requests = wireMockServer.findAll(postRequestedFor(urlPathMatching(".*createCredentials.*"))) - assertThat(requests).hasSize(1) - - def requestBody = requests[0].bodyAsString - assertThat(URLDecoder.decode(requestBody, "utf-8")) - .isEqualTo('json={"credentials":{"scope":"GLOBAL","id":"the-id","username":"the-username","password":"the-password","description":"some description","$class":"com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl"}}') - - } finally { - wireMockServer.stop() - } - } - - @Test - void 'throw when creating credential fails'() { - def wireMockServer = new WireMockServer(options().dynamicPort()) - wireMockServer.start() - - try { - wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(okJson('{"crumb":"the-crumb"}'))) - - wireMockServer.stubFor(post(urlPathMatching(".*createCredentials.*")) - .willReturn(aResponse().withStatus(404))) - - def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), - new OkHttpClient())) - - def exception = shouldFail(RuntimeException) { - jobManager.createCredential('the-jobname', 'the-id', 'the-username', 'the-password', 'some description') - } - assertThat(exception.getMessage()).isEqualTo('Could not create credential id=the-id,job=the-jobname. StatusCode: 404') - } finally { - wireMockServer.stop() - } - } - - @Test - void 'starts job'() { - def wireMockServer = new WireMockServer(options().dynamicPort()) - wireMockServer.start() - - try { - wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(okJson('{"crumb":"the-crumb"}'))) - - wireMockServer.stubFor(post(urlPathMatching("/jenkins/job/the-jobname/build.*")) - .willReturn(ok())) - - def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), - new OkHttpClient())) - - jobManager.startJob('the-jobname') - - wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname/build")) - .withQueryParam("delay", equalTo("0sec"))) - - } finally { - wireMockServer.stop() - } - } - - @Test - void 'throw when starting job fails'() { - def wireMockServer = new WireMockServer(options().dynamicPort()) - wireMockServer.start() - - try { - wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(okJson('{"crumb":"the-crumb"}'))) - - wireMockServer.stubFor(post(urlPathMatching("/jenkins/job/the-jobname/build.*")) - .willReturn(aResponse().withStatus(400))) - - def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), - new OkHttpClient())) - - def exception = shouldFail(RuntimeException) { - jobManager.startJob('the-jobname') - } - assertThat(exception.getMessage()).isEqualTo('Could not trigger build of Jenkins job: the-jobname. StatusCode: 400') - } finally { - wireMockServer.stop() - } - } - - @Test - void 'throws when job contains invalid characters'() { - def client = mock(JenkinsApiClient) - def jobManager = new JobManager(client) - - def exception = shouldFail(RuntimeException) { - jobManager.deleteJob("foo'foo") - } - assertThat(exception.getMessage()).isEqualTo('Job name cannot contain quotes.') - } - - @Test - void 'throws when job deletion fails'() { - def client = mock(JenkinsApiClient) - def jobManager = new JobManager(client) - - def exception = shouldFail(RuntimeException) { - jobManager.deleteJob("foo-foo") - } - assertThat(exception.getMessage()).isEqualTo('Could not delete job foo-foo') - } - - @Test - void 'deletes job'() { - def client = mock(JenkinsApiClient) - def jobManager = new JobManager(client) - - when(client.runScript(anyString())).thenReturn("null") - jobManager.deleteJob("foo") - org.mockito.Mockito.verify(client).runScript("print(Jenkins.instance.getItem('foo')?.delete())") - } - - @Test - void 'checks existing Job'() { - def wireMockServer = new WireMockServer(options().dynamicPort()) - wireMockServer.start() - - try { - wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(okJson('{"crumb":"the-crumb"}'))) - - wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) - .willReturn(ok())) - - def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), - new OkHttpClient())) - - def exists = jobManager.jobExists('the-jobname') - - assertThat(exists).isEqualTo(true) - wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) - } finally { - wireMockServer.stop() - } - } - - @Test - void 'checks non-existing Job'() { - def wireMockServer = new WireMockServer(options().dynamicPort()) - wireMockServer.start() - - try { - wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(okJson('{"crumb":"the-crumb"}'))) - - wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) - .willReturn(aResponse().withStatus(404))) - - def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), - new OkHttpClient())) - - def exists = jobManager.jobExists('the-jobname') - assertThat(exists).isEqualTo(false) - wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) - } finally { - wireMockServer.stop() - } - } - - @Test - void 'creates Job'() { - def wireMockServer = new WireMockServer(options().dynamicPort()) - wireMockServer.start() - - try { - wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(okJson('{"crumb":"the-crumb"}'))) - wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) - .willReturn(aResponse().withStatus(404))) - wireMockServer.stubFor(post(urlPathMatching("/jenkins/createItem.*")) - .willReturn(ok())) - - def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), - new OkHttpClient())) - - def created = jobManager.createJob('the-jobname', 'http://scm', 'ns', 'creds') - - assertThat(created).isEqualTo(true) - - wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) - wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/createItem")) - .withQueryParam("name", equalTo("the-jobname")) - .withRequestBody(containing('http://scm')) - .withRequestBody(containing('ns')) - .withRequestBody(containing('creds'))) - - } finally { - wireMockServer.stop() - } - } - - @Test - void 'ignores existing Job'() { - def wireMockServer = new WireMockServer(options().dynamicPort()) - wireMockServer.start() - - try { - wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) - .willReturn(okJson('{"crumb":"the-crumb"}'))) - - wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) - .willReturn(ok())) // 200 OK means "Job Exists" - - def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), - new OkHttpClient())) - - def created = jobManager.createJob('the-jobname', 'http://scm', 'ns', 'creds') - - assertThat(created).isEqualTo(false) - wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) - wireMockServer.verify(0, postRequestedFor(urlPathEqualTo("/jenkins/createItem"))) - - } finally { - wireMockServer.stop() - } - } + } finally { + wireMockServer.stop() + } + } + + @Test + void 'throw when creating credential fails'() { + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + + try { + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathMatching(".*createCredentials.*")) + .willReturn(aResponse().withStatus(404))) + + def jobManager = new JobManager(new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), + new OkHttpClient())) + + def exception = shouldFail(RuntimeException) { + jobManager.createCredential('the-jobname', 'the-id', 'the-username', 'the-password', 'some description') + } + assertThat(exception.getMessage()).isEqualTo('Could not create credential id=the-id,job=the-jobname. StatusCode: 404') + } finally { + wireMockServer.stop() + } + } + + @Test + void 'starts job'() { + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + + try { + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathMatching("/jenkins/job/the-jobname/build.*")) + .willReturn(ok())) + + def jobManager = new JobManager(new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), + new OkHttpClient())) + + jobManager.startJob('the-jobname') + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname/build")) + .withQueryParam("delay", equalTo("0sec"))) + + } finally { + wireMockServer.stop() + } + } + + @Test + void 'throw when starting job fails'() { + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + + try { + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathMatching("/jenkins/job/the-jobname/build.*")) + .willReturn(aResponse().withStatus(400))) + + def jobManager = new JobManager(new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), + new OkHttpClient())) + + def exception = shouldFail(RuntimeException) { + jobManager.startJob('the-jobname') + } + assertThat(exception.getMessage()).isEqualTo('Could not trigger build of Jenkins job: the-jobname. StatusCode: 400') + } finally { + wireMockServer.stop() + } + } + + @Test + void 'throws when job contains invalid characters'() { + def client = mock(JenkinsApiClient) + def jobManager = new JobManager(client) + + def exception = shouldFail(RuntimeException) { + jobManager.deleteJob("foo'foo") + } + assertThat(exception.getMessage()).isEqualTo('Job name cannot contain quotes.') + } + + @Test + void 'throws when job deletion fails'() { + def client = mock(JenkinsApiClient) + def jobManager = new JobManager(client) + + def exception = shouldFail(RuntimeException) { + jobManager.deleteJob("foo-foo") + } + assertThat(exception.getMessage()).isEqualTo('Could not delete job foo-foo') + } + + @Test + void 'deletes job'() { + def client = mock(JenkinsApiClient) + def jobManager = new JobManager(client) + + when(client.runScript(anyString())).thenReturn("null") + jobManager.deleteJob("foo") + org.mockito.Mockito.verify(client).runScript("print(Jenkins.instance.getItem('foo')?.delete())") + } + + @Test + void 'checks existing Job'() { + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + + try { + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) + .willReturn(ok())) + + def jobManager = new JobManager(new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), + new OkHttpClient())) + + def exists = jobManager.jobExists('the-jobname') + + assertThat(exists).isEqualTo(true) + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) + } finally { + wireMockServer.stop() + } + } + + @Test + void 'checks non-existing Job'() { + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + + try { + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) + .willReturn(aResponse().withStatus(404))) + + def jobManager = new JobManager(new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), + new OkHttpClient())) + + def exists = jobManager.jobExists('the-jobname') + assertThat(exists).isEqualTo(false) + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) + } finally { + wireMockServer.stop() + } + } + + @Test + void 'creates Job'() { + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + + try { + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) + .willReturn(aResponse().withStatus(404))) + wireMockServer.stubFor(post(urlPathMatching("/jenkins/createItem.*")) + .willReturn(ok())) + + def jobManager = new JobManager(new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), + new OkHttpClient())) + + def created = jobManager.createJob('the-jobname', 'http://scm', 'ns', 'creds') + + assertThat(created).isEqualTo(true) + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/createItem")) + .withQueryParam("name", equalTo("the-jobname")) + .withRequestBody(containing('http://scm')) + .withRequestBody(containing('ns')) + .withRequestBody(containing('creds'))) + + } finally { + wireMockServer.stop() + } + } + + @Test + void 'ignores existing Job'() { + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + + try { + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) + .willReturn(ok())) // 200 OK means "Job Exists" + + def jobManager = new JobManager(new JenkinsApiClient(new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), + new OkHttpClient())) + + def created = jobManager.createJob('the-jobname', 'http://scm', 'ns', 'creds') + + assertThat(created).isEqualTo(false) + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo("/jenkins/createItem"))) + + } finally { + wireMockServer.stop() + } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/jenkins/UserManagerTest.groovy b/src/test/groovy/com/cloudogu/gitops/jenkins/UserManagerTest.groovy index 79c87510e..ce3ddf467 100644 --- a/src/test/groovy/com/cloudogu/gitops/jenkins/UserManagerTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/jenkins/UserManagerTest.groovy @@ -1,65 +1,64 @@ package com.cloudogu.gitops.jenkins - -import org.junit.jupiter.api.Test - import static groovy.test.GroovyAssert.shouldFail import static org.assertj.core.api.Assertions.assertThat import static org.mockito.ArgumentMatchers.anyString import static org.mockito.Mockito.* +import org.junit.jupiter.api.Test + class UserManagerTest { - @Test - void 'creates user successfully'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("the-user") - - new UserManager(client).createUser("the-user", "hunter2") - verify(client).runScript(anyString()) - } - - @Test - void 'creates user with quotes successfully'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("the-'user") - - new UserManager(client).createUser("the-'user", "code''injection") - verify(client).runScript(""" + @Test + void 'creates user successfully'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("the-user") + + new UserManager(client).createUser("the-user", "hunter2") + verify(client).runScript(anyString()) + } + + @Test + void 'creates user with quotes successfully'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("the-'user") + + new UserManager(client).createUser("the-'user", "code''injection") + verify(client).runScript(""" def realm = Jenkins.getInstance().getSecurityRealm() def user = realm.createAccount('the-\\'user', 'code\\'\\'injection') print(user) """) - } - - @Test - void 'throws when backslashes are passed'() { - def client = mock(JenkinsApiClient) - shouldFail(IllegalArgumentException) { - new UserManager(client).createUser("the-\\'user", "hunter2") - } - } - - @Test - void 'throws when there was an error'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") - - shouldFail(RuntimeException) { - new UserManager(client).createUser("the-user", "hunter2") - } - } - - @Test - void 'grants permission for user'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("true") - when(client.runScript("print(Jenkins.getInstance().getAuthorizationStrategy().class)")).thenReturn("class hudson.security.GlobalMatrixAuthorizationStrategy") - - new UserManager(client).grantPermission("the-'user", UserManager.Permissions.METRICS_VIEW) - - verify(client).runScript("""print(Jenkins.getInstance().getAuthorizationStrategy().class)""") - verify(client).runScript(""" + } + + @Test + void 'throws when backslashes are passed'() { + def client = mock(JenkinsApiClient) + shouldFail(IllegalArgumentException) { + new UserManager(client).createUser("the-\\'user", "hunter2") + } + } + + @Test + void 'throws when there was an error'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") + + shouldFail(RuntimeException) { + new UserManager(client).createUser("the-user", "hunter2") + } + } + + @Test + void 'grants permission for user'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("true") + when(client.runScript("print(Jenkins.getInstance().getAuthorizationStrategy().class)")).thenReturn("class hudson.security.GlobalMatrixAuthorizationStrategy") + + new UserManager(client).grantPermission("the-'user", UserManager.Permissions.METRICS_VIEW) + + verify(client).runScript("""print(Jenkins.getInstance().getAuthorizationStrategy().class)""") + verify(client).runScript(""" import org.jenkinsci.plugins.matrixauth.PermissionEntry import org.jenkinsci.plugins.matrixauth.AuthorizationType @@ -69,57 +68,57 @@ class UserManagerTest { } print(permissions[jenkins.metrics.api.Metrics.VIEW].add(new PermissionEntry(AuthorizationType.USER, 'the-\\'user'))) """) - } - - @Test - void 'throws when granting permission failed'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") - - shouldFail(RuntimeException) { - new UserManager(client).grantPermission("the-'user", UserManager.Permissions.METRICS_VIEW) - } - } - - @Test - void 'checks whether matrix based authorization is enabled'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("class hudson.security.GlobalMatrixAuthorizationStrategy") - - assertThat(new UserManager(client).isUsingMatrixBasedPermissions()).isTrue() - } - - @Test - void 'checks whether matrix based authorization is disabled'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("class hudson.security.FullControlOnceLoggedInAuthorizationStrategy") - - assertThat(new UserManager(client).isUsingMatrixBasedPermissions()).isFalse() - } - - @Test - void 'checks whether cas security realm is used'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("class org.jenkinsci.plugins.cas.CasSecurityRealm") - - assertThat(new UserManager(client).isUsingCasSecurityRealm()).isTrue() - } - - @Test - void 'checks whether cas security realm is not used'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("class hudson.security.HudsonPrivateSecurityRealm") - - assertThat(new UserManager(client).isUsingCasSecurityRealm()).isFalse() - } - - @Test - void 'throws when determining security realm errors'() { - def client = mock(JenkinsApiClient) - when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") - - shouldFail(RuntimeException) { - new UserManager(client).isUsingCasSecurityRealm() - } - } -} + } + + @Test + void 'throws when granting permission failed'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") + + shouldFail(RuntimeException) { + new UserManager(client).grantPermission("the-'user", UserManager.Permissions.METRICS_VIEW) + } + } + + @Test + void 'checks whether matrix based authorization is enabled'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("class hudson.security.GlobalMatrixAuthorizationStrategy") + + assertThat(new UserManager(client).isUsingMatrixBasedPermissions()).isTrue() + } + + @Test + void 'checks whether matrix based authorization is disabled'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("class hudson.security.FullControlOnceLoggedInAuthorizationStrategy") + + assertThat(new UserManager(client).isUsingMatrixBasedPermissions()).isFalse() + } + + @Test + void 'checks whether cas security realm is used'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("class org.jenkinsci.plugins.cas.CasSecurityRealm") + + assertThat(new UserManager(client).isUsingCasSecurityRealm()).isTrue() + } + + @Test + void 'checks whether cas security realm is not used'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("class hudson.security.HudsonPrivateSecurityRealm") + + assertThat(new UserManager(client).isUsingCasSecurityRealm()).isFalse() + } + + @Test + void 'throws when determining security realm errors'() { + def client = mock(JenkinsApiClient) + when(client.runScript(anyString())).thenReturn("groovy.lang.MissingPropertyException: No such property: asd for class: Script1[...]") + + shouldFail(RuntimeException) { + new UserManager(client).isUsingCasSecurityRealm() + } + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/kubernetes/api/K8sJavaApiTest.groovy b/src/test/groovy/com/cloudogu/gitops/kubernetes/api/K8sJavaApiTest.groovy index 9b2e44779..0020e8578 100644 --- a/src/test/groovy/com/cloudogu/gitops/kubernetes/api/K8sJavaApiTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/kubernetes/api/K8sJavaApiTest.groovy @@ -1,6 +1,7 @@ package com.cloudogu.gitops.kubernetes.api import com.cloudogu.gitops.config.Credentials + import io.fabric8.kubernetes.api.model.Secret import io.fabric8.kubernetes.api.model.SecretBuilder import io.fabric8.kubernetes.client.KubernetesClient @@ -9,46 +10,45 @@ import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test - @EnableKubernetesMockClient(crud = true) class K8sJavaApiTest { - //https://github.com/fabric8io/kubernetes-client?tab=readme-ov-file#mocking-kubernetes - KubernetesClient client //Client to set mock data, gets injected by annotation - K8sJavaApiClient k8sJavaApiClient - KubernetesMockServer server //Use server for non CRUD - - @BeforeEach - void init(){ - k8sJavaApiClient = new K8sJavaApiClient() - k8sJavaApiClient.client = client - } - - @Test - void 'getCredentialsFromSecret'() { - generateSecret() - Credentials credentials = k8sJavaApiClient.getCredentialsFromSecret('test-secret', 'test') - assert (credentials.password) == 's3cr3t' - assert (credentials.username) == 'admin' - } - - private generateSecret() { - - Secret secret = new SecretBuilder() - .withNewMetadata() - .withName("test-secret") - .withNamespace('test') - .endMetadata() - .withType("Opaque") - .withData(Map.of( - "username", "YWRtaW4=", - "password", "czNjcjN0" - )) - .build() - - client.secrets() - .inNamespace('test') - .create(secret) - - } + //https://github.com/fabric8io/kubernetes-client?tab=readme-ov-file#mocking-kubernetes + KubernetesClient client + //Client to set mock data, gets injected by annotation + K8sJavaApiClient k8sJavaApiClient + KubernetesMockServer server + //Use server for non CRUD + + @BeforeEach + void init() { + k8sJavaApiClient = new K8sJavaApiClient() + k8sJavaApiClient.client = client + } + + @Test + void 'getCredentialsFromSecret'() { + generateSecret() + Credentials credentials = k8sJavaApiClient.getCredentialsFromSecret('test-secret', 'test') + assert (credentials.password) == 's3cr3t' + assert (credentials.username) == 'admin' + } + + private generateSecret() { + + Secret secret = new SecretBuilder() + .withNewMetadata() + .withName("test-secret") + .withNamespace('test') + .endMetadata() + .withType("Opaque") + .withData(Map.of("username", "YWRtaW4=", + "password", "czNjcjN0")) + .build() + + client.secrets() + .inNamespace('test') + .create(secret) + + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/kubernetes/rbac/ArgocdApplicationTest.groovy b/src/test/groovy/com/cloudogu/gitops/kubernetes/rbac/ArgocdApplicationTest.groovy index 7252b73b0..f16e7ec93 100644 --- a/src/test/groovy/com/cloudogu/gitops/kubernetes/rbac/ArgocdApplicationTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/kubernetes/rbac/ArgocdApplicationTest.groovy @@ -1,60 +1,52 @@ package com.cloudogu.gitops.kubernetes.rbac +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.kubernetes.argocd.ArgoApplication import com.cloudogu.gitops.utils.FileSystemUtils + import groovy.yaml.YamlSlurper -import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test class ArgocdApplicationTest { - - Config config = Config.fromMap([ - scm : [ - scmManager: [username: 'user', - password: 'pass', - host : 'localhost', - rootPath: 'scm' - ], - gitlab : [username: 'user', - password: 'pass', - - ] - ], - application: [ - namePrefix: '', - insecure : false, - gitName : 'Test User', - gitEmail : 'test@example.com' - ] - ]) - - @Test - void 'simple ArgoCD Application with common values'() { - - GitRepo repo = new GitRepo(config, null, "my-repo", new FileSystemUtils()) - - new ArgoApplication( - 'example-apps', - 'testurl.com/argocd/example-apps', - 'testprefix-argocd', - 'testnamespace', - 'argocd/') - .generate(repo, 'testsubfolder/test') - - - File file = new File(repo.getAbsoluteLocalRepoTmpDir(), "testsubfolder/test/argocd-application-example-apps-testprefix-argocd.yaml") - assertThat(file).exists() - Map yaml = new YamlSlurper().parse(file) as Map - - assertThat(yaml["metadata"]["name"]).isEqualTo('example-apps') - assertThat(yaml["metadata"]["namespace"]).isEqualTo('testprefix-argocd') - assertThat(yaml["spec"]["destination"]["namespace"]).isEqualTo('testnamespace') - - assertThat(yaml["spec"]["source"]["path"]).isEqualTo('argocd/') - assertThat(yaml["spec"]["source"]["repoURL"]).isEqualTo('testurl.com/argocd/example-apps') - } + Config config = Config.fromMap([scm : [scmManager: [username: 'user', + password: 'pass', + host : 'localhost', + rootPath: 'scm'], + gitlab : [username: 'user', + password: 'pass', + + ]], + application: [namePrefix: '', + insecure : false, + gitName : 'Test User', + gitEmail : 'test@example.com']]) + + @Test + void 'simple ArgoCD Application with common values'() { + + GitRepo repo = new GitRepo(config, null, "my-repo", new FileSystemUtils()) + + new ArgoApplication('example-apps', + 'testurl.com/argocd/example-apps', + 'testprefix-argocd', + 'testnamespace', + 'argocd/') + .generate(repo, 'testsubfolder/test') + + File file = new File(repo.getAbsoluteLocalRepoTmpDir(), "testsubfolder/test/argocd-application-example-apps-testprefix-argocd.yaml") + assertThat(file).exists() + Map yaml = new YamlSlurper().parse(file) as Map + + assertThat(yaml["metadata"]["name"]).isEqualTo('example-apps') + assertThat(yaml["metadata"]["namespace"]).isEqualTo('testprefix-argocd') + assertThat(yaml["spec"]["destination"]["namespace"]).isEqualTo('testnamespace') + + assertThat(yaml["spec"]["source"]["path"]).isEqualTo('argocd/') + assertThat(yaml["spec"]["source"]["repoURL"]).isEqualTo('testurl.com/argocd/example-apps') + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/kubernetes/rbac/RbacDefinitionTest.groovy b/src/test/groovy/com/cloudogu/gitops/kubernetes/rbac/RbacDefinitionTest.groovy index db1a4cf5a..f746092ee 100644 --- a/src/test/groovy/com/cloudogu/gitops/kubernetes/rbac/RbacDefinitionTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/kubernetes/rbac/RbacDefinitionTest.groovy @@ -1,310 +1,302 @@ package com.cloudogu.gitops.kubernetes.rbac +import static org.assertj.core.api.Assertions.assertThat +import static org.junit.jupiter.api.Assertions.assertThrows + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.utils.FileSystemUtils + import groovy.yaml.YamlSlurper -import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat -import static org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test class RbacDefinitionTest { - private final Config config = Config.fromMap([ - scm : [ - scmManager: [ - username: 'user', - password: 'pass', - protocol: 'http', - host : 'localhost', - rootPath: 'scm' - ], - ], - application: [ - namePrefix: '', - insecure : false, - gitName : 'Test User', - gitEmail : 'test@example.com' - ] - ]) - - private final GitRepo repo = new GitRepo(config, null, "my-repo", new FileSystemUtils()) - - @Test - void 'generates at least one RBAC YAML file'() { - new RbacDefinition(Role.Variant.ARGOCD) - .withName("access") - .withNamespace("testing") - .withServiceAccountsFrom("testing", ["reader"]) - .withRepo(repo) - .withConfig(config) - .generate() - - File outputDir = new File(repo.getAbsoluteLocalRepoTmpDir(), "rbac") - File[] yamlFiles = outputDir.listFiles({ file -> file.name.endsWith(".yaml") } as FileFilter) - List fileNames = yamlFiles.collect { it.name } - - assertThat(yamlFiles).isNotEmpty() - assertThat(fileNames).anyMatch { it.contains("role") || it.contains("rolebinding") } - } - - @Test - void 'fails if name is missing'() { - def ex = assertThrows(IllegalArgumentException) { - new RbacDefinition(Role.Variant.ARGOCD) - .withNamespace("testing") - .withServiceAccountsFrom("testing", ["reader"]) - .withRepo(repo) - .withConfig(config) - .generate() - } - - assertThat(ex.message).contains("name must not be blank") - } - - - @Test - void 'fails if namespace is missing'() { - def ex = assertThrows(IllegalArgumentException) { - new RbacDefinition(Role.Variant.ARGOCD) - .withName("access") - .withServiceAccountsFrom("testing", ["reader"]) - .withRepo(repo) - .withConfig(config) - .generate() - } - - assertThat(ex.message).contains("namespace must not be blank") - } - - @Test - void 'fails if service accounts are empty'() { - def ex = assertThrows(IllegalArgumentException) { - new RbacDefinition(Role.Variant.ARGOCD) - .withName("access") - .withNamespace("testing") - .withRepo(repo) - .withConfig(config) - .withServiceAccounts([]) // leer übergeben - .generate() - } - assertThat(ex.message).contains("At least one service account") - } - - @Test - void 'accepts service accounts via withServiceAccounts directly'() { - def sa = new ServiceAccountRef("myns", "mysa") - - new RbacDefinition(Role.Variant.ARGOCD) - .withName("direct") - .withNamespace("myns") - .withServiceAccounts([sa]) - .withRepo(repo) - .withConfig(config) - .generate() - - File f = new File(repo.getAbsoluteLocalRepoTmpDir(), "rbac/rolebinding-direct-myns.yaml") - assertThat(f).exists() - } - - @Test - void 'custom subfolder is respected'() { - String custom = "custom-dir" - new RbacDefinition(Role.Variant.ARGOCD) - .withName("custom") - .withNamespace("testing") - .withSubfolder(custom) - .withServiceAccountsFrom("testing", ["reader"]) - .withRepo(repo) - .withConfig(config) - .generate() - - File out = new File(repo.getAbsoluteLocalRepoTmpDir(), custom) - File[] yamlFiles = out.listFiles({ file -> file.name.endsWith(".yaml") } as FileFilter) - List fileNames = yamlFiles.collect { it.name } - - assertThat(yamlFiles).isNotEmpty() - assertThat(fileNames).anyMatch { it.contains("role") || it.contains("rolebinding") } - } - - @Test - void 'multiple service accounts are rendered correctly'() { - new RbacDefinition(Role.Variant.ARGOCD) - .withName("multi") - .withNamespace("testing") - .withServiceAccountsFrom("testing", ["reader", "writer", "admin"]) - .withRepo(repo) - .withConfig(config) - .generate() - - File[] files = new File(repo.getAbsoluteLocalRepoTmpDir(), "rbac").listFiles() - List fileNames = files.collect { it.name } - assertThat(fileNames).anyMatch { it.contains("role") } - } - - @Test - void 'custom role and binding file names are rendered'() { - new RbacDefinition(Role.Variant.ARGOCD) - .withName("myrole") - .withNamespace("custom-ns") - .withServiceAccountsFrom("custom-ns", ["sa1"]) - .withRepo(repo) - .withConfig(config) - .generate() - - File outputDir = new File(repo.getAbsoluteLocalRepoTmpDir(), "rbac") - List fileNames = outputDir.listFiles().collect { it.name } - - assertThat(fileNames).contains("role-myrole-custom-ns.yaml", "rolebinding-myrole-custom-ns.yaml") - } - - @Test - void 'subfolder can be nested'() { - String nested = "some/nested/path" - new RbacDefinition(Role.Variant.ARGOCD) - .withName("nestedtest") - .withNamespace("ns") - .withServiceAccountsFrom("ns", ["sa1"]) - .withSubfolder(nested) - .withRepo(repo) - .withConfig(config) - .generate() - - File outputDir = new File(repo.getAbsoluteLocalRepoTmpDir(), nested) - List fileNames = outputDir.listFiles().collect { it.name } - - assertThat(fileNames).contains("role-nestedtest-ns.yaml", "rolebinding-nestedtest-ns.yaml") - } - - @Test - void 'fails if repo is not set'() { - IllegalStateException ex = assertThrows(IllegalStateException) { - new RbacDefinition(Role.Variant.ARGOCD) - .withName("failtest") - .withNamespace("ns") - .withServiceAccountsFrom("ns", ["sa1"]) - .withConfig(config) - .generate() - } - - assertThat(ex.message).contains("SCMM repo must be set using withRepo() before calling generate()") - } - - @Test - void 'rendered rolebinding yaml contains correct service accounts'() { - List saList = ["reader", "writer"] - String ns = "rbac-test" - - new RbacDefinition(Role.Variant.ARGOCD) - .withName("test") - .withNamespace(ns) - .withServiceAccountsFrom(ns, saList) - .withRepo(repo) - .withConfig(config) - .generate() - - String path = "rbac/rolebinding-test-${ns}.yaml".toString() - File file = new File(repo.getAbsoluteLocalRepoTmpDir(), path) - Map yaml = new YamlSlurper().parse(file) as Map - - assertThat(yaml["metadata"]["name"]).isEqualTo("test") - assertThat(yaml["metadata"]["namespace"]).isEqualTo(ns) - - List names = yaml["subjects"].collect { it['name'] as String } - assertThat(names).containsExactlyInAnyOrderElementsOf(saList) - - List namespaces = yaml["subjects"].collect { it['namespace'] as String } - assertThat(namespaces).containsOnly(ns) - - assertThat(yaml["roleRef"]["name"]).isEqualTo("test") - assertThat(yaml["roleRef"]["kind"]).isEqualTo("Role") - } - - @Test - void 'rendered role yaml contains correct metadata'() { - String name = "myrole" - String ns = "custom-ns" - - new RbacDefinition(Role.Variant.ARGOCD) - .withName(name) - .withNamespace(ns) - .withServiceAccountsFrom(ns, ["sa1"]) - .withRepo(repo) - .withConfig(config) - .generate() - - String path = "rbac/role-${name}-${ns}.yaml".toString() - File file = new File(repo.getAbsoluteLocalRepoTmpDir(), path) - Map yaml = new YamlSlurper().parse(file) as Map - - assertThat(yaml["metadata"]["name"]).isEqualTo(name) - assertThat(yaml["metadata"]["namespace"]).isEqualTo(ns) - } - - @Test - void 'renders node access rules in argocd-role only when not on OpenShift'() { - config.application.openshift = false - - GitRepo tempRepo = new GitRepo(config, null, "rbac-test", new FileSystemUtils()) - - new RbacDefinition(Role.Variant.ARGOCD) - .withName("nodecheck") - .withNamespace("monitoring") - .withServiceAccountsFrom("monitoring", ["sa1"]) - .withRepo(tempRepo) - .withConfig(config) - .generate() - - File roleFile = new File(tempRepo.getAbsoluteLocalRepoTmpDir(), "rbac/role-nodecheck-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 - List verbs = rule["verbs"] as List - resources.containsAll(["nodes", "nodes/metrics"]) && - verbs.containsAll(["get", "list", "watch"]) - } - } - - @Test - void 'does not render node access rules in argocd-role when on OpenShift'() { - config.application.openshift = true - - GitRepo tempRepo = new GitRepo(config, null, "rbac-test", new FileSystemUtils()) - - new RbacDefinition(Role.Variant.ARGOCD) - .withName("nodecheck") - .withNamespace("monitoring") - .withServiceAccountsFrom("monitoring", ["sa1"]) - .withRepo(tempRepo) - .withConfig(config) - .generate() - - File roleFile = new File(tempRepo.getAbsoluteLocalRepoTmpDir(), "rbac/role-nodecheck-monitoring.yaml") - 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 'fails if config is not set'() { - def ex = assertThrows(IllegalArgumentException) { - new RbacDefinition(Role.Variant.ARGOCD) - .withName("failtest") - .withNamespace("ns") - .withServiceAccountsFrom("ns", ["sa"]) - .withRepo(repo) - .generate() - } - - assertThat(ex.message).contains("Config must not be null") - // oder je nach deiner tatsächlichen Exception-Message - } + private final Config config = Config.fromMap([scm : [scmManager: [username: 'user', + password: 'pass', + protocol: 'http', + host : 'localhost', + rootPath: 'scm'],], + application: [namePrefix: '', + insecure : false, + gitName : 'Test User', + gitEmail : 'test@example.com']]) + + private final GitRepo repo = new GitRepo(config, null, "my-repo", new FileSystemUtils()) + + @Test + void 'generates at least one RBAC YAML file'() { + new RbacDefinition(Role.Variant.ARGOCD) + .withName("access") + .withNamespace("testing") + .withServiceAccountsFrom("testing", ["reader"]) + .withRepo(repo) + .withConfig(config) + .generate() + + File outputDir = new File(repo.getAbsoluteLocalRepoTmpDir(), "rbac") + File[] yamlFiles = outputDir.listFiles({ file -> file.name.endsWith(".yaml") } as FileFilter) + List fileNames = yamlFiles.collect { it.name } + + assertThat(yamlFiles).isNotEmpty() + assertThat(fileNames).anyMatch { it.contains("role") || it.contains("rolebinding") } + } + + @Test + void 'fails if name is missing'() { + def ex = assertThrows(IllegalArgumentException) { + new RbacDefinition(Role.Variant.ARGOCD) + .withNamespace("testing") + .withServiceAccountsFrom("testing", ["reader"]) + .withRepo(repo) + .withConfig(config) + .generate() + } + + assertThat(ex.message).contains("name must not be blank") + } + + @Test + void 'fails if namespace is missing'() { + def ex = assertThrows(IllegalArgumentException) { + new RbacDefinition(Role.Variant.ARGOCD) + .withName("access") + .withServiceAccountsFrom("testing", ["reader"]) + .withRepo(repo) + .withConfig(config) + .generate() + } + + assertThat(ex.message).contains("namespace must not be blank") + } + + @Test + void 'fails if service accounts are empty'() { + def ex = assertThrows(IllegalArgumentException) { + new RbacDefinition(Role.Variant.ARGOCD) + .withName("access") + .withNamespace("testing") + .withRepo(repo) + .withConfig(config) + .withServiceAccounts([]) // leer übergeben + .generate() + } + assertThat(ex.message).contains("At least one service account") + } + + @Test + void 'accepts service accounts via withServiceAccounts directly'() { + def sa = new ServiceAccountRef("myns", "mysa") + + new RbacDefinition(Role.Variant.ARGOCD) + .withName("direct") + .withNamespace("myns") + .withServiceAccounts([sa]) + .withRepo(repo) + .withConfig(config) + .generate() + + File f = new File(repo.getAbsoluteLocalRepoTmpDir(), "rbac/rolebinding-direct-myns.yaml") + assertThat(f).exists() + } + + @Test + void 'custom subfolder is respected'() { + String custom = "custom-dir" + new RbacDefinition(Role.Variant.ARGOCD) + .withName("custom") + .withNamespace("testing") + .withSubfolder(custom) + .withServiceAccountsFrom("testing", ["reader"]) + .withRepo(repo) + .withConfig(config) + .generate() + + File out = new File(repo.getAbsoluteLocalRepoTmpDir(), custom) + File[] yamlFiles = out.listFiles({ file -> file.name.endsWith(".yaml") } as FileFilter) + List fileNames = yamlFiles.collect { it.name } + + assertThat(yamlFiles).isNotEmpty() + assertThat(fileNames).anyMatch { it.contains("role") || it.contains("rolebinding") } + } + + @Test + void 'multiple service accounts are rendered correctly'() { + new RbacDefinition(Role.Variant.ARGOCD) + .withName("multi") + .withNamespace("testing") + .withServiceAccountsFrom("testing", ["reader", "writer", "admin"]) + .withRepo(repo) + .withConfig(config) + .generate() + + File[] files = new File(repo.getAbsoluteLocalRepoTmpDir(), "rbac").listFiles() + List fileNames = files.collect { it.name } + assertThat(fileNames).anyMatch { it.contains("role") } + } + + @Test + void 'custom role and binding file names are rendered'() { + new RbacDefinition(Role.Variant.ARGOCD) + .withName("myrole") + .withNamespace("custom-ns") + .withServiceAccountsFrom("custom-ns", ["sa1"]) + .withRepo(repo) + .withConfig(config) + .generate() + + File outputDir = new File(repo.getAbsoluteLocalRepoTmpDir(), "rbac") + List fileNames = outputDir.listFiles().collect { it.name } + + assertThat(fileNames).contains("role-myrole-custom-ns.yaml", "rolebinding-myrole-custom-ns.yaml") + } + + @Test + void 'subfolder can be nested'() { + String nested = "some/nested/path" + new RbacDefinition(Role.Variant.ARGOCD) + .withName("nestedtest") + .withNamespace("ns") + .withServiceAccountsFrom("ns", ["sa1"]) + .withSubfolder(nested) + .withRepo(repo) + .withConfig(config) + .generate() + + File outputDir = new File(repo.getAbsoluteLocalRepoTmpDir(), nested) + List fileNames = outputDir.listFiles().collect { it.name } + + assertThat(fileNames).contains("role-nestedtest-ns.yaml", "rolebinding-nestedtest-ns.yaml") + } + + @Test + void 'fails if repo is not set'() { + IllegalStateException ex = assertThrows(IllegalStateException) { + new RbacDefinition(Role.Variant.ARGOCD) + .withName("failtest") + .withNamespace("ns") + .withServiceAccountsFrom("ns", ["sa1"]) + .withConfig(config) + .generate() + } + + assertThat(ex.message).contains("SCMM repo must be set using withRepo() before calling generate()") + } + + @Test + void 'rendered rolebinding yaml contains correct service accounts'() { + List saList = ["reader", "writer"] + String ns = "rbac-test" + + new RbacDefinition(Role.Variant.ARGOCD) + .withName("test") + .withNamespace(ns) + .withServiceAccountsFrom(ns, saList) + .withRepo(repo) + .withConfig(config) + .generate() + + String path = "rbac/rolebinding-test-${ns}.yaml".toString() + File file = new File(repo.getAbsoluteLocalRepoTmpDir(), path) + Map yaml = new YamlSlurper().parse(file) as Map + + assertThat(yaml["metadata"]["name"]).isEqualTo("test") + assertThat(yaml["metadata"]["namespace"]).isEqualTo(ns) + + List names = yaml["subjects"].collect { it['name'] as String } + assertThat(names).containsExactlyInAnyOrderElementsOf(saList) + + List namespaces = yaml["subjects"].collect { it['namespace'] as String } + assertThat(namespaces).containsOnly(ns) + + assertThat(yaml["roleRef"]["name"]).isEqualTo("test") + assertThat(yaml["roleRef"]["kind"]).isEqualTo("Role") + } + + @Test + void 'rendered role yaml contains correct metadata'() { + String name = "myrole" + String ns = "custom-ns" + + new RbacDefinition(Role.Variant.ARGOCD) + .withName(name) + .withNamespace(ns) + .withServiceAccountsFrom(ns, ["sa1"]) + .withRepo(repo) + .withConfig(config) + .generate() + + String path = "rbac/role-${name}-${ns}.yaml".toString() + File file = new File(repo.getAbsoluteLocalRepoTmpDir(), path) + Map yaml = new YamlSlurper().parse(file) as Map + + assertThat(yaml["metadata"]["name"]).isEqualTo(name) + assertThat(yaml["metadata"]["namespace"]).isEqualTo(ns) + } + + @Test + void 'renders node access rules in argocd-role only when not on OpenShift'() { + config.application.openshift = false + + GitRepo tempRepo = new GitRepo(config, null, "rbac-test", new FileSystemUtils()) + + new RbacDefinition(Role.Variant.ARGOCD) + .withName("nodecheck") + .withNamespace("monitoring") + .withServiceAccountsFrom("monitoring", ["sa1"]) + .withRepo(tempRepo) + .withConfig(config) + .generate() + + File roleFile = new File(tempRepo.getAbsoluteLocalRepoTmpDir(), "rbac/role-nodecheck-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 + List verbs = rule["verbs"] as List + resources.containsAll(["nodes", "nodes/metrics"]) && verbs.containsAll(["get", "list", "watch"]) + } + } + + @Test + void 'does not render node access rules in argocd-role when on OpenShift'() { + config.application.openshift = true + + GitRepo tempRepo = new GitRepo(config, null, "rbac-test", new FileSystemUtils()) + + new RbacDefinition(Role.Variant.ARGOCD) + .withName("nodecheck") + .withNamespace("monitoring") + .withServiceAccountsFrom("monitoring", ["sa1"]) + .withRepo(tempRepo) + .withConfig(config) + .generate() + + File roleFile = new File(tempRepo.getAbsoluteLocalRepoTmpDir(), "rbac/role-nodecheck-monitoring.yaml") + 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 'fails if config is not set'() { + def ex = assertThrows(IllegalArgumentException) { + new RbacDefinition(Role.Variant.ARGOCD) + .withName("failtest") + .withNamespace("ns") + .withServiceAccountsFrom("ns", ["sa"]) + .withRepo(repo) + .generate() + } + + assertThat(ex.message).contains("Config must not be null") + // oder je nach deiner tatsächlichen Exception-Message + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/okhttp/RetryInterceptorTest.groovy b/src/test/groovy/com/cloudogu/gitops/okhttp/RetryInterceptorTest.groovy index ca06ce95b..0df8ad9e0 100644 --- a/src/test/groovy/com/cloudogu/gitops/okhttp/RetryInterceptorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/okhttp/RetryInterceptorTest.groovy @@ -1,11 +1,8 @@ package com.cloudogu.gitops.okhttp -import com.github.tomakehurst.wiremock.junit5.WireMockExtension -import okhttp3.OkHttpClient -import okhttp3.Request -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension +import static com.github.tomakehurst.wiremock.client.WireMock.* +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import static org.assertj.core.api.Assertions.assertThat import javax.net.ssl.HostnameVerifier import javax.net.ssl.SSLContext @@ -15,145 +12,148 @@ import java.security.SecureRandom import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit -import static com.github.tomakehurst.wiremock.client.WireMock.* -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig -import static org.assertj.core.api.Assertions.assertThat +import com.github.tomakehurst.wiremock.junit5.WireMockExtension +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension class RetryInterceptorTest { - public static final int OKHTTPCLIENT_TIMEOUT = 1000 - - @RegisterExtension - static WireMockExtension wireMock = WireMockExtension.newInstance() - .options(wireMockConfig() - .dynamicPort() - .dynamicHttpsPort()) - .build() - - @BeforeEach - void 'resetWireMock'() { - wireMock.resetAll() - } - - @Test - void 'retries three times on 500'() { - def path = "/retry-500" - - wireMock.stubFor(get(urlEqualTo(path)) - .inScenario("Retry Scenario") - .whenScenarioStateIs("Started") - .willReturn(aResponse().withStatus(500)) - .willSetStateTo("First Retry")) - - wireMock.stubFor(get(urlEqualTo(path)) - .inScenario("Retry Scenario") - .whenScenarioStateIs("First Retry") - .willReturn(aResponse().withStatus(500)) - .willSetStateTo("Second Retry")) - - wireMock.stubFor(get(urlEqualTo(path)) - .inScenario("Retry Scenario") - .whenScenarioStateIs("Second Retry") - .willReturn(aResponse() - .withStatus(200) - .withBody("Successful Result"))) - - def client = createClient() - def response = client.newCall(new Request.Builder().url(wireMock.baseUrl() + path) .build()).execute() - - assertThat(response.body().string()).isEqualTo("Successful Result") - wireMock.verify(3, getRequestedFor(urlEqualTo(path))) - } - - @Test - void 'retries three times on 500 with HTTPS'() { - def path = "/retry-500" - - wireMock.stubFor(get(urlEqualTo(path)) - .inScenario("HTTPS Retry Scenario") - .whenScenarioStateIs("Started") - .willReturn(aResponse().withStatus(500)) - .willSetStateTo("First Retry")) - - wireMock.stubFor(get(urlEqualTo(path)) - .inScenario("HTTPS Retry Scenario") - .whenScenarioStateIs("First Retry") - .willReturn(aResponse().withStatus(500)) - .willSetStateTo("Second Retry")) - - wireMock.stubFor(get(urlEqualTo(path)) - .inScenario("HTTPS Retry Scenario") - .whenScenarioStateIs("Second Retry") - .willReturn(aResponse() - .withStatus(200) - .withBody("Successful Result"))) - - def client = createClient() - def response = client.newCall(new Request.Builder().url(wireMock.baseUrl() + path).build()).execute() - - assertThat(response.body().string()).isEqualTo("Successful Result") - wireMock.verify(3, getRequestedFor(urlEqualTo(path))) - } - - @Test - void 'retries on timeout'() { - def path = "/timeout-test" - - wireMock.stubFor(get(urlEqualTo(path)) - .inScenario("Timeout Scenario") - .whenScenarioStateIs("Started") - .willReturn(aResponse() - .withStatus(200) - .withFixedDelay(100)) // Delay longer than read timeout - .willSetStateTo("After Timeout")) - - wireMock.stubFor(get(urlEqualTo(path)) - .inScenario("Timeout Scenario") - .whenScenarioStateIs("After Timeout") - .willReturn(aResponse() - .withStatus(200) - .withBody("Successful Result"))) - - def client = createClient(100) - def response = client.newCall(new Request.Builder().url(wireMock.baseUrl() + path).build()).execute() - - assertThat(response.body().string()).isEqualTo("Successful Result") - wireMock.verify(2, getRequestedFor(urlEqualTo(path))) - } - - @Test - void 'fails after third retry'() { - def path = "/always-fail" - - wireMock.stubFor(get(urlEqualTo(path)) - .willReturn(aResponse().withStatus(500))) - - def client = createClient() - def response = client.newCall(new Request.Builder().url(wireMock.baseUrl() + path).build()).execute() - - assertThat(response.code()).isEqualTo(500) - wireMock.verify(4, getRequestedFor(urlEqualTo(path))) // Initial request + 3 retries - } - - private OkHttpClient createClient(int timeout = OKHTTPCLIENT_TIMEOUT) { - // 1. Create a TrustManager that trusts everyone - def trustAllCerts = [ - new X509TrustManager() { - void checkClientTrusted(X509Certificate[] chain, String authType) {} - void checkServerTrusted(X509Certificate[] chain, String authType) {} - X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0] } - } - ] as TrustManager[] - - def sslContext = SSLContext.getInstance("TLS") - sslContext.init(null, trustAllCerts, new SecureRandom()) - - new OkHttpClient.Builder() - .addInterceptor(new RetryInterceptor(retries: 3, waitPeriodInMs: 0)) - .connectTimeout(timeout, TimeUnit.MILLISECONDS) - .readTimeout(timeout, TimeUnit.MILLISECONDS) - .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) - .hostnameVerifier({ hostname, session -> true } as HostnameVerifier) - .build() - } + public static final int OKHTTPCLIENT_TIMEOUT = 1000 + + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig() + .dynamicPort() + .dynamicHttpsPort()) + .build() + + @BeforeEach + void 'resetWireMock'() { + wireMock.resetAll() + } + + @Test + void 'retries three times on 500'() { + def path = "/retry-500" + + wireMock.stubFor(get(urlEqualTo(path)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("Started") + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("First Retry")) + + wireMock.stubFor(get(urlEqualTo(path)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("First Retry") + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("Second Retry")) + + wireMock.stubFor(get(urlEqualTo(path)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("Second Retry") + .willReturn(aResponse() + .withStatus(200) + .withBody("Successful Result"))) + + def client = createClient() + def response = client.newCall(new Request.Builder().url(wireMock.baseUrl() + path).build()).execute() + + assertThat(response.body().string()).isEqualTo("Successful Result") + wireMock.verify(3, getRequestedFor(urlEqualTo(path))) + } + + @Test + void 'retries three times on 500 with HTTPS'() { + def path = "/retry-500" + + wireMock.stubFor(get(urlEqualTo(path)) + .inScenario("HTTPS Retry Scenario") + .whenScenarioStateIs("Started") + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("First Retry")) + + wireMock.stubFor(get(urlEqualTo(path)) + .inScenario("HTTPS Retry Scenario") + .whenScenarioStateIs("First Retry") + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("Second Retry")) + + wireMock.stubFor(get(urlEqualTo(path)) + .inScenario("HTTPS Retry Scenario") + .whenScenarioStateIs("Second Retry") + .willReturn(aResponse() + .withStatus(200) + .withBody("Successful Result"))) + + def client = createClient() + def response = client.newCall(new Request.Builder().url(wireMock.baseUrl() + path).build()).execute() + + assertThat(response.body().string()).isEqualTo("Successful Result") + wireMock.verify(3, getRequestedFor(urlEqualTo(path))) + } + + @Test + void 'retries on timeout'() { + def path = "/timeout-test" + + wireMock.stubFor(get(urlEqualTo(path)) + .inScenario("Timeout Scenario") + .whenScenarioStateIs("Started") + .willReturn(aResponse() + .withStatus(200) + .withFixedDelay(100)) // Delay longer than read timeout + .willSetStateTo("After Timeout")) + + wireMock.stubFor(get(urlEqualTo(path)) + .inScenario("Timeout Scenario") + .whenScenarioStateIs("After Timeout") + .willReturn(aResponse() + .withStatus(200) + .withBody("Successful Result"))) + + def client = createClient(100) + def response = client.newCall(new Request.Builder().url(wireMock.baseUrl() + path).build()).execute() + + assertThat(response.body().string()).isEqualTo("Successful Result") + wireMock.verify(2, getRequestedFor(urlEqualTo(path))) + } + + @Test + void 'fails after third retry'() { + def path = "/always-fail" + + wireMock.stubFor(get(urlEqualTo(path)) + .willReturn(aResponse().withStatus(500))) + + def client = createClient() + def response = client.newCall(new Request.Builder().url(wireMock.baseUrl() + path).build()).execute() + + assertThat(response.code()).isEqualTo(500) + wireMock.verify(4, getRequestedFor(urlEqualTo(path))) // Initial request + 3 retries + } + + private OkHttpClient createClient(int timeout = OKHTTPCLIENT_TIMEOUT) { + // 1. Create a TrustManager that trusts everyone + def trustAllCerts = [new X509TrustManager() { + void checkClientTrusted(X509Certificate[] chain, String authType) {} + + void checkServerTrusted(X509Certificate[] chain, String authType) {} + + X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0] } + }] as TrustManager[] + + def sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, new SecureRandom()) + + new OkHttpClient.Builder() + .addInterceptor(new RetryInterceptor(retries: 3, waitPeriodInMs: 0)) + .connectTimeout(timeout, TimeUnit.MILLISECONDS) + .readTimeout(timeout, TimeUnit.MILLISECONDS) + .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier({ hostname, session -> true } as HostnameVerifier) + .build() + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/AirGappedUtilsTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/AirGappedUtilsTest.groovy index bccf52dcb..82bebc1da 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/AirGappedUtilsTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/AirGappedUtilsTest.groovy @@ -1,207 +1,177 @@ package com.cloudogu.gitops.utils +import static groovy.test.GroovyAssert.shouldFail +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.git.GitHandler import com.cloudogu.gitops.git.GitRepo +import com.cloudogu.gitops.git.providers.scmmanager.Permission +import com.cloudogu.gitops.git.providers.scmmanager.api.Repository import com.cloudogu.gitops.kubernetes.api.HelmClient import com.cloudogu.gitops.utils.git.GitHandlerForTests -import com.cloudogu.gitops.utils.git.TestGitRepoFactory -import com.cloudogu.gitops.git.providers.scmmanager.Permission import com.cloudogu.gitops.utils.git.ScmManagerMock -import com.cloudogu.gitops.git.providers.scmmanager.api.Repository +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.yaml.YamlSlurper + import org.eclipse.jgit.api.Git import org.eclipse.jgit.lib.Ref import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import java.nio.file.Files -import java.nio.file.Path - -import static groovy.test.GroovyAssert.shouldFail -import static org.assertj.core.api.Assertions.assertThat -import static org.mockito.ArgumentMatchers.* -import static org.mockito.Mockito.* - class AirGappedUtilsTest { - Config config = Config.fromMap([ - application: [ - localHelmChartFolder: '', - gitName : 'Cloudogu', - gitEmail : 'hello@cloudogu.com'], - scm : [ - scmManager: [ - url: ''] - ] - ]) - - Config.HelmConfig helmConfig = new Config.HelmConfig([ - chart : 'kube-prometheus-stack', - repoURL: 'https://kube-prometheus-stack-repo-url', - version: '58.2.1' - ]) - - Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) - TestGitRepoFactory gitRepoFactory = new TestGitRepoFactory(config, new FileSystemUtils()) - FileSystemUtils fileSystemUtils = new FileSystemUtils() - TestScmManagerApiClient scmmApiClient = new TestScmManagerApiClient(config) - HelmClient helmClient = mock(HelmClient) - GitHandler gitHandler = new GitHandlerForTests(config, new ScmManagerMock()) - - @BeforeEach - void setUp() { - def response = scmmApiClient.mockSuccessfulResponse(201) - when(scmmApiClient.repositoryApi.create(any(Repository), anyBoolean())).thenReturn(response) - when(scmmApiClient.repositoryApi.createPermission(anyString(), anyString(), any(Permission))).thenReturn(response) - - } - - @Test - void 'Prepares repos for air-gapped use'() { - setupForAirgappedUse() - - def actualRepoNamespaceAndName = createAirGappedUtils().mirrorHelmRepoToGit(helmConfig) - - assertThat(actualRepoNamespaceAndName).isEqualTo( - "${GitRepo.NAMESPACE_3RD_PARTY_DEPENDENCIES}/kube-prometheus-stack".toString()) - assertAirGapped() - } - - @Test - void 'Fails when unable to resolve version of dependencies'() { - setupForAirgappedUse([:]) - def exception = shouldFail(RuntimeException) { - createAirGappedUtils().mirrorHelmRepoToGit(helmConfig) - } - - assertThat(exception.message).isEqualTo( - 'Unable to determine proper version for dependency grafana (version: 7.3.*) ' + - 'from repo 3rd-party-dependencies/kube-prometheus-stack' - ) - } - - @Test - void 'Also works for charts without dependencies'() { - setupForAirgappedUse(null, []) - createAirGappedUtils().mirrorHelmRepoToGit(helmConfig) - - GitRepo prometheusRepo = gitRepoFactory.repos['3rd-party-dependencies/kube-prometheus-stack'] - def actualPrometheusChartYaml = new YamlSlurper().parse(Path.of(prometheusRepo.absoluteLocalRepoTmpDir, 'Chart.yaml')) - - def dependencies = actualPrometheusChartYaml['dependencies'] - assertThat(dependencies).isNull() - } - - @Test - void 'Fails for invalid helm charts'() { - setupForAirgappedUse() - - def expectedException = new RuntimeException() - doThrow(expectedException).when(helmClient).template(anyString(), anyString()) - - def exception = shouldFail(RuntimeException) { - createAirGappedUtils().mirrorHelmRepoToGit(helmConfig) - } - - assertThat(exception.getMessage()).isEqualTo( - "Helm chart in folder ${rootChartsFolder}/kube-prometheus-stack seems invalid.".toString()) - assertThat(exception.getCause()).isSameAs(expectedException) - } - - protected void setupForAirgappedUse(Map chartLock = null, List dependencies = null) { - Path sourceChart = rootChartsFolder.resolve('kube-prometheus-stack') - Files.createDirectories(sourceChart) - Map prometheusChartYaml = [ - version : '1.2.3', - name : 'kube-prometheus-stack-chart', - dependencies: [ - [ - condition : 'crds.enabled', - name : 'crds', - repository: '', - version : '0.0.0' - ], - [ - condition : 'grafana.enabled', - name : 'grafana', - repository: 'https://grafana-repo-url', - version : '7.3.*', - ] - ] - ] - - if (dependencies != null) { - if (dependencies.isEmpty()) { - prometheusChartYaml.remove('dependencies') - } else { - prometheusChartYaml.dependencies = dependencies - } - } - - fileSystemUtils.writeYaml(prometheusChartYaml, sourceChart.resolve('Chart.yaml').toFile()) - - if (chartLock == null) { - chartLock = [ - dependencies: [ - [ - name : 'crds', - repository: "", - version : '0.0.0' - ], - [ - name : 'grafana', - repository: 'https://grafana.github.io/helm-charts', - version : '7.3.9' - ] - ] - ] - } - fileSystemUtils.writeYaml(chartLock, sourceChart.resolve('Chart.lock').toFile()) - - config.application.localHelmChartFolder = rootChartsFolder.toString() - } - - protected void assertAirGapped() { - GitRepo prometheusRepo = gitRepoFactory.repos['3rd-party-dependencies/kube-prometheus-stack'] - assertThat(prometheusRepo).isNotNull() - assertThat(Path.of(prometheusRepo.absoluteLocalRepoTmpDir, 'Chart.lock')).doesNotExist() - - def ys = new YamlSlurper() - def actualPrometheusChartYaml = ys.parse(Path.of(prometheusRepo.absoluteLocalRepoTmpDir, 'Chart.yaml')) - assertThat(actualPrometheusChartYaml['name']).isEqualTo('kube-prometheus-stack-chart') - - def dependencies = actualPrometheusChartYaml['dependencies'] as List - assertThat(dependencies).hasSize(2) - assertThat(dependencies[0]['name']).isEqualTo('crds') - assertThat(dependencies[0]['version']).isEqualTo('0.0.0') - assertThat(dependencies[0]['repository']).isEqualTo('') - assertThat(dependencies[1]['name']).isEqualTo('grafana') - assertThat(dependencies[1]['version']).isEqualTo('7.3.9') - assertThat(dependencies[1]['repository']).isEqualTo('') - - assertHelmRepoCommits(prometheusRepo, '1.2.3', 'Chart kube-prometheus-stack-chart, version: 1.2.3\n\n' + - 'Source: https://kube-prometheus-stack-repo-url\nDependencies localized to run in air-gapped environments') - - verify(prometheusRepo).createRepositoryAndSetPermission( - eq("Mirror of Helm chart kube-prometheus-stack from https://kube-prometheus-stack-repo-url"), - eq(false) - ) - } - - - void assertHelmRepoCommits(GitRepo repo, String expectedTag, String expectedCommitMessage) { - def commits = Git.open(new File(repo.absoluteLocalRepoTmpDir)).log().setMaxCount(1).all().call().collect() - assertThat(commits.size()).isEqualTo(1) - assertThat(commits[0].fullMessage).isEqualTo(expectedCommitMessage) - - List tags = Git.open(new File(repo.absoluteLocalRepoTmpDir)).tagList().call() - assertThat(tags.size()).isEqualTo(1) - assertThat(tags[0].name).isEqualTo("refs/tags/${expectedTag}".toString()) - } - - AirGappedUtils createAirGappedUtils() { - new AirGappedUtils(config, gitRepoFactory, fileSystemUtils, helmClient, gitHandler) - } + Config config = Config.fromMap([application: [localHelmChartFolder: '', + gitName : 'Cloudogu', + gitEmail : 'hello@cloudogu.com'], + scm : [scmManager: [url: '']]]) + + Config.HelmConfig helmConfig = new Config.HelmConfig([chart : 'kube-prometheus-stack', + repoURL: 'https://kube-prometheus-stack-repo-url', + version: '58.2.1']) + + Path rootChartsFolder = Files.createTempDirectory(this.class.getSimpleName()) + TestGitRepoFactory gitRepoFactory = new TestGitRepoFactory(config, new FileSystemUtils()) + FileSystemUtils fileSystemUtils = new FileSystemUtils() + TestScmManagerApiClient scmmApiClient = new TestScmManagerApiClient(config) + HelmClient helmClient = mock(HelmClient) + GitHandler gitHandler = new GitHandlerForTests(config, new ScmManagerMock()) + + @BeforeEach + void setUp() { + def response = scmmApiClient.mockSuccessfulResponse(201) + when(scmmApiClient.repositoryApi.create(any(Repository), anyBoolean())).thenReturn(response) + when(scmmApiClient.repositoryApi.createPermission(anyString(), anyString(), any(Permission))).thenReturn(response) + + } + + @Test + void 'Prepares repos for air-gapped use'() { + setupForAirgappedUse() + + def actualRepoNamespaceAndName = createAirGappedUtils().mirrorHelmRepoToGit(helmConfig) + + assertThat(actualRepoNamespaceAndName).isEqualTo("${GitRepo.NAMESPACE_3RD_PARTY_DEPENDENCIES}/kube-prometheus-stack".toString()) + assertAirGapped() + } + + @Test + void 'Fails when unable to resolve version of dependencies'() { + setupForAirgappedUse([:]) + def exception = shouldFail(RuntimeException) { + createAirGappedUtils().mirrorHelmRepoToGit(helmConfig) + } + + assertThat(exception.message).isEqualTo('Unable to determine proper version for dependency grafana (version: 7.3.*) ' + + 'from repo 3rd-party-dependencies/kube-prometheus-stack') + } + + @Test + void 'Also works for charts without dependencies'() { + setupForAirgappedUse(null, []) + createAirGappedUtils().mirrorHelmRepoToGit(helmConfig) + + GitRepo prometheusRepo = gitRepoFactory.repos['3rd-party-dependencies/kube-prometheus-stack'] + def actualPrometheusChartYaml = new YamlSlurper().parse(Path.of(prometheusRepo.absoluteLocalRepoTmpDir, 'Chart.yaml')) + + def dependencies = actualPrometheusChartYaml['dependencies'] + assertThat(dependencies).isNull() + } + + @Test + void 'Fails for invalid helm charts'() { + setupForAirgappedUse() + + def expectedException = new RuntimeException() + doThrow(expectedException).when(helmClient).template(anyString(), anyString()) + + def exception = shouldFail(RuntimeException) { + createAirGappedUtils().mirrorHelmRepoToGit(helmConfig) + } + + assertThat(exception.getMessage()).isEqualTo("Helm chart in folder ${rootChartsFolder}/kube-prometheus-stack seems invalid.".toString()) + assertThat(exception.getCause()).isSameAs(expectedException) + } + + protected void setupForAirgappedUse(Map chartLock = null, List dependencies = null) { + Path sourceChart = rootChartsFolder.resolve('kube-prometheus-stack') + Files.createDirectories(sourceChart) + Map prometheusChartYaml = [version : '1.2.3', + name : 'kube-prometheus-stack-chart', + dependencies: [[condition : 'crds.enabled', + name : 'crds', + repository: '', + version : '0.0.0'], + [condition : 'grafana.enabled', + name : 'grafana', + repository: 'https://grafana-repo-url', + version : '7.3.*',]]] + + if (dependencies != null) { + if (dependencies.isEmpty()) { + prometheusChartYaml.remove('dependencies') + } else { + prometheusChartYaml.dependencies = dependencies + } + } + + fileSystemUtils.writeYaml(prometheusChartYaml, sourceChart.resolve('Chart.yaml').toFile()) + + if (chartLock == null) { + chartLock = [dependencies: [[name : 'crds', + repository: "", + version : '0.0.0'], + [name : 'grafana', + repository: 'https://grafana.github.io/helm-charts', + version : '7.3.9']]] + } + fileSystemUtils.writeYaml(chartLock, sourceChart.resolve('Chart.lock').toFile()) + + config.application.localHelmChartFolder = rootChartsFolder.toString() + } + + protected void assertAirGapped() { + GitRepo prometheusRepo = gitRepoFactory.repos['3rd-party-dependencies/kube-prometheus-stack'] + assertThat(prometheusRepo).isNotNull() + assertThat(Path.of(prometheusRepo.absoluteLocalRepoTmpDir, 'Chart.lock')).doesNotExist() + + def ys = new YamlSlurper() + def actualPrometheusChartYaml = ys.parse(Path.of(prometheusRepo.absoluteLocalRepoTmpDir, 'Chart.yaml')) + assertThat(actualPrometheusChartYaml['name']).isEqualTo('kube-prometheus-stack-chart') + + def dependencies = actualPrometheusChartYaml['dependencies'] as List + assertThat(dependencies).hasSize(2) + assertThat(dependencies[0]['name']).isEqualTo('crds') + assertThat(dependencies[0]['version']).isEqualTo('0.0.0') + assertThat(dependencies[0]['repository']).isEqualTo('') + assertThat(dependencies[1]['name']).isEqualTo('grafana') + assertThat(dependencies[1]['version']).isEqualTo('7.3.9') + assertThat(dependencies[1]['repository']).isEqualTo('') + + assertHelmRepoCommits(prometheusRepo, '1.2.3', 'Chart kube-prometheus-stack-chart, version: 1.2.3\n\n' + + 'Source: https://kube-prometheus-stack-repo-url\nDependencies localized to run in air-gapped environments') + + verify(prometheusRepo).createRepositoryAndSetPermission(eq("Mirror of Helm chart kube-prometheus-stack from https://kube-prometheus-stack-repo-url"), + eq(false)) + } + + void assertHelmRepoCommits(GitRepo repo, String expectedTag, String expectedCommitMessage) { + def commits = Git.open(new File(repo.absoluteLocalRepoTmpDir)).log().setMaxCount(1).all().call().collect() + assertThat(commits.size()).isEqualTo(1) + assertThat(commits[0].fullMessage).isEqualTo(expectedCommitMessage) + + List tags = Git.open(new File(repo.absoluteLocalRepoTmpDir)).tagList().call() + assertThat(tags.size()).isEqualTo(1) + assertThat(tags[0].name).isEqualTo("refs/tags/${expectedTag}".toString()) + } + + AirGappedUtils createAirGappedUtils() { + new AirGappedUtils(config, gitRepoFactory, fileSystemUtils, helmClient, gitHandler) + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/AllowlistFreemarkerObjectWrapperTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/AllowlistFreemarkerObjectWrapperTest.groovy index 112647ac5..0bf113c44 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/AllowlistFreemarkerObjectWrapperTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/AllowlistFreemarkerObjectWrapperTest.groovy @@ -1,81 +1,76 @@ package com.cloudogu.gitops.utils +import static org.junit.jupiter.api.Assertions.* + import freemarker.template.Configuration import org.junit.jupiter.api.Test -import static org.junit.jupiter.api.Assertions.* - class AllowlistFreemarkerObjectWrapperTest { - - @Test - void 'should allow access to whitelisted static models'() { - def wrapper = new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, ["com.cloudogu.gitops.utils.DockerImageParser"] as Set) - def staticModels = wrapper.getStaticModels() - - assertNotNull(staticModels.get("com.cloudogu.gitops.utils.DockerImageParser")) - assertNull(staticModels.get("java.lang.Integer")) - assertNull(staticModels.get("java.lang.String")) - } - - @Test - void 'should deny access to non-whitelisted static models'() { - def wrapper = new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, ["java.lang.String"] as Set) - def staticModels = wrapper.getStaticModels() - - assertNull(staticModels.get("java.lang.Integer")) - assertNotNull(staticModels.get("java.lang.String")) - assertNull(staticModels.get("com.cloudogu.gitops.utils.DockerImageParser")) - } - - @Test - void 'should return true for isEmpty when allowlist is empty'() { - def wrapper = new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, [] as Set) - def staticModels = wrapper.getStaticModels() - - assertTrue(staticModels.isEmpty()) - } - - @Test - void 'templating only works for whitelisted statics'() { - def templateText = ''' + @Test + void 'should allow access to whitelisted static models'() { + def wrapper = new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, ["com.cloudogu.gitops.utils.DockerImageParser"] as Set) + def staticModels = wrapper.getStaticModels() + + assertNotNull(staticModels.get("com.cloudogu.gitops.utils.DockerImageParser")) + assertNull(staticModels.get("java.lang.Integer")) + assertNull(staticModels.get("java.lang.String")) + } + + @Test + void 'should deny access to non-whitelisted static models'() { + def wrapper = new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, ["java.lang.String"] as Set) + def staticModels = wrapper.getStaticModels() + + assertNull(staticModels.get("java.lang.Integer")) + assertNotNull(staticModels.get("java.lang.String")) + assertNull(staticModels.get("com.cloudogu.gitops.utils.DockerImageParser")) + } + + @Test + void 'should return true for isEmpty when allowlist is empty'() { + def wrapper = new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, [] as Set) + def staticModels = wrapper.getStaticModels() + + assertTrue(staticModels.isEmpty()) + } + + @Test + void 'templating only works for whitelisted statics'() { + def templateText = ''' <#assign DockerImageParser=statics['com.cloudogu.gitops.utils.DockerImageParser']> <#assign imageObject = DockerImageParser.parse('test:latest')> <#assign staticsTests=statics['System']> <#assign imageObject = staticsTests.exit()> '''.stripIndent() - def model = [ - statics: new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, ['com.cloudogu.gitops.utils.DockerImageParser'] as Set).getStaticModels() - ] as Map - // create a temporary file to simulate an actual file input - def tempInputFile = File.createTempFile("test", ".ftl.yaml") - tempInputFile.text = templateText + def model = [statics: new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, ['com.cloudogu.gitops.utils.DockerImageParser'] as Set).getStaticModels()] as Map + // create a temporary file to simulate an actual file input + def tempInputFile = File.createTempFile("test", ".ftl.yaml") + tempInputFile.text = templateText - def exception = assertThrows(freemarker.core.InvalidReferenceException) { - new TemplatingEngine().replaceTemplates(tempInputFile, model) - } + def exception = assertThrows(freemarker.core.InvalidReferenceException) { + new TemplatingEngine().replaceTemplates(tempInputFile, model) + } - assert exception.message.contains("System") : "Exception message should mention 'System'" - } + assert exception.message.contains("System"): "Exception message should mention 'System'" + } - @Test - void 'templating in ftl files works correctly with whitelisted static models'() { - def templateText = ''' + @Test + void 'templating in ftl files works correctly with whitelisted static models'() { + def templateText = ''' <#assign DockerImageParser=statics['com.cloudogu.gitops.utils.DockerImageParser']> <#assign imageObject = DockerImageParser.parse('test:latest')> <#assign staticsTests=statics['java.lang.Math']> <#assign number = staticsTests.round(3.14)> '''.stripIndent() - def model = [ - statics: new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, ['java.lang.Math', 'com.cloudogu.gitops.utils.DockerImageParser'] as Set).getStaticModels() - ] as Map - // create a temporary file to simulate an actual file input - def tempInputFile = File.createTempFile("test", ".ftl.yaml") - tempInputFile.text = templateText + def model = [statics: new AllowListFreemarkerObjectWrapper(Configuration.VERSION_2_3_32, ['java.lang.Math', 'com.cloudogu.gitops.utils.DockerImageParser'] as Set).getStaticModels()] as Map + // create a temporary file to simulate an actual file input + def tempInputFile = File.createTempFile("test", ".ftl.yaml") + tempInputFile.text = templateText - new TemplatingEngine().replaceTemplates(tempInputFile, model) + new TemplatingEngine().replaceTemplates(tempInputFile, model) - } + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/CommandExecutorForTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/CommandExecutorForTest.groovy index f52d8ae6a..cc30202df 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/CommandExecutorForTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/CommandExecutorForTest.groovy @@ -4,64 +4,63 @@ import static org.assertj.core.api.Assertions.assertThat import static org.mockito.Mockito.mock class CommandExecutorForTest extends CommandExecutor { - List actualCommands = [] + List actualCommands = [] - Queue outputs = new LinkedList() + Queue outputs = new LinkedList() - void enqueueOutput(Output output) { - outputs.add(output) - } + void enqueueOutput(Output output) { + outputs.add(output) + } - void enqueueOutputs(Queue outputsQueue) { - outputs.addAll(outputsQueue) - } - - // This is actually only set when an env is passed to CommandExecutor - List environment = [] - - @Override - protected Output getOutput(Process proc, String command, boolean failOnError) { - actualCommands += command - Output output = outputs.poll() ?: new Output('', '', 0) + void enqueueOutputs(Queue outputsQueue) { + outputs.addAll(outputsQueue) + } - if (failOnError && output.exitCode > 0) { - throw new RuntimeException("Executing command failed: ${command}") - } + // This is actually only set when an env is passed to CommandExecutor + List environment = [] - return output - } + @Override + protected Output getOutput(Process proc, String command, boolean failOnError) { + actualCommands += command + Output output = outputs.poll() ?: new Output('', '', 0) - @Override - protected Process doExecute(String command) { - return mock(Process) - } + if (failOnError && output.exitCode > 0) { + throw new RuntimeException("Executing command failed: ${command}") + } - @Override - protected Process doExecute(String[] command) { - return mock(Process) - } + return output + } - @Override - protected Process doExecute(String command, List envp) { - environment = envp - return mock(Process) - } + @Override + protected Process doExecute(String command) { + return mock(Process) + } - String assertExecuted(String commandStartsWith) { - def actualCommand = actualCommands.find { - it.startsWith(commandStartsWith) - } - assertThat(actualCommand).as("Expected command to have been executed, but was not:\n${commandStartsWith}.\n" + - "Actual commands:\n${actualCommands.join('\n')}") - .isNotNull() - return actualCommand - } + @Override + protected Process doExecute(String[] command) { + return mock(Process) + } - void assertNotExecuted(String commandStartsWith) { - def actualCommand = actualCommands.find { - it.startsWith(commandStartsWith) - } - assertThat(actualCommand).as("Expected command to have been executed, but was not: ${commandStartsWith}") - .isNull() - } -} + @Override + protected Process doExecute(String command, List envp) { + environment = envp + return mock(Process) + } + + String assertExecuted(String commandStartsWith) { + def actualCommand = actualCommands.find { + it.startsWith(commandStartsWith) + } + assertThat(actualCommand).as("Expected command to have been executed, but was not:\n${commandStartsWith}.\n" + "Actual commands:\n${actualCommands.join('\n')}") + .isNotNull() + return actualCommand + } + + void assertNotExecuted(String commandStartsWith) { + def actualCommand = actualCommands.find { + it.startsWith(commandStartsWith) + } + assertThat(actualCommand).as("Expected command to have been executed, but was not: ${commandStartsWith}") + .isNull() + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/CommandExecutorTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/CommandExecutorTest.groovy index 09f399386..e993f83e3 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/CommandExecutorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/CommandExecutorTest.groovy @@ -1,21 +1,21 @@ package com.cloudogu.gitops.utils -import org.junit.jupiter.api.Test - import static org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + class CommandExecutorTest { - CommandExecutorForTest commandExecutor = new CommandExecutorForTest() - - @Test - void aggregatesEnvironment() { - def additionalEnv = [someKey: 'someValue'] - commandExecutor.execute('command', additionalEnv) + CommandExecutorForTest commandExecutor = new CommandExecutorForTest() + + @Test + void aggregatesEnvironment() { + def additionalEnv = [someKey: 'someValue'] + commandExecutor.execute('command', additionalEnv) - assertThat(commandExecutor.actualCommands[0] as String).isEqualTo('command') - assertThat(commandExecutor.environment.toString()).contains('someKey=someValue') - // Make sure there are other env vars present and not solely the one we passed - assertThat(commandExecutor.environment.size()).isGreaterThan(additionalEnv.size()) - } -} + assertThat(commandExecutor.actualCommands[0] as String).isEqualTo('command') + assertThat(commandExecutor.environment.toString()).contains('someKey=someValue') + // Make sure there are other env vars present and not solely the one we passed + assertThat(commandExecutor.environment.size()).isGreaterThan(additionalEnv.size()) + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/DockerImageParserTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/DockerImageParserTest.groovy index 69bddac5a..43da66f1c 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/DockerImageParserTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/DockerImageParserTest.groovy @@ -1,35 +1,35 @@ package com.cloudogu.gitops.utils -import org.junit.jupiter.api.Test - import static groovy.test.GroovyAssert.shouldFail import static org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + class DockerImageParserTest { - @Test - void 'parses simple image string'() { - def result = DockerImageParser.parse('grafana/grafana:latest') + @Test + void 'parses simple image string'() { + def result = DockerImageParser.parse('grafana/grafana:latest') - assertThat(result.registry).isEqualTo('') - assertThat(result.repository).isEqualTo('grafana/grafana') - assertThat(result.getRegistryAndRepositoryAsString()).isEqualTo('grafana/grafana') - assertThat(result.tag).isEqualTo('latest') - } + assertThat(result.registry).isEqualTo('') + assertThat(result.repository).isEqualTo('grafana/grafana') + assertThat(result.getRegistryAndRepositoryAsString()).isEqualTo('grafana/grafana') + assertThat(result.tag).isEqualTo('latest') + } - @Test - void 'parses image string with port'() { - def result = DockerImageParser.parse('localhost:5000/grafana/grafana:latest') + @Test + void 'parses image string with port'() { + def result = DockerImageParser.parse('localhost:5000/grafana/grafana:latest') - assertThat(result.registry).isEqualTo('localhost:5000') - assertThat(result.repository).isEqualTo('grafana/grafana') - assertThat(result.getRegistryAndRepositoryAsString()).isEqualTo('localhost:5000/grafana/grafana') - assertThat(result.tag).isEqualTo('latest') - } + assertThat(result.registry).isEqualTo('localhost:5000') + assertThat(result.repository).isEqualTo('grafana/grafana') + assertThat(result.getRegistryAndRepositoryAsString()).isEqualTo('localhost:5000/grafana/grafana') + assertThat(result.tag).isEqualTo('latest') + } - @Test - void 'throws when there is no colon'() { - shouldFail(RuntimeException) { - DockerImageParser.parse('grafana/grafana') - } - } -} + @Test + void 'throws when there is no colon'() { + shouldFail(RuntimeException) { + DockerImageParser.parse('grafana/grafana') + } + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/FileSystemUtilsTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/FileSystemUtilsTest.groovy index 0a3c8f7e8..fcd31e467 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/FileSystemUtilsTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/FileSystemUtilsTest.groovy @@ -1,85 +1,87 @@ package com.cloudogu.gitops.utils -import org.junit.jupiter.api.Test +import static org.assertj.core.api.Assertions.assertThat import java.nio.file.Files import java.nio.file.Path import java.util.stream.Collectors -import static org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test class FileSystemUtilsTest { - FileSystemUtils fileSystemUtils = new FileSystemUtils() - - @Test - void copiesToTempDir() { - def expectedText = 'someText' - - File someFile = File.createTempFile(getClass().getSimpleName(), '') - someFile.withWriter { { - it.println expectedText - }} - Path tmpFile = fileSystemUtils.copyToTempDir(someFile.absolutePath) - - assertThat(tmpFile.toAbsolutePath().toString()).isNotEqualTo(someFile.getAbsoluteFile()) - assertThat(tmpFile.toFile().getText().trim()).isEqualTo(expectedText) - } - - @Test - void 'makes read-only folders writable recursively'() { - // Create temporary directory with nested structure - Path parentDir = Files.createTempDirectory(this.class.getSimpleName()) - - // Create some regular files - File regularFile = new File(parentDir.toFile(), "regularFile.txt") - regularFile.createNewFile() - - // Create nested directory - File nestedDir = new File(parentDir.toFile(), "nestedDir") - nestedDir.mkdir() - - // Create read-only file in nested directory - File readOnlyFile = new File(nestedDir, "readOnlyFile.txt") - readOnlyFile.createNewFile() - readOnlyFile.setWritable(false) - - // Create another read-only file in parent directory - File anotherReadOnlyFile = new File(parentDir.toFile(), "anotherReadOnlyFile.txt") - anotherReadOnlyFile.createNewFile() - anotherReadOnlyFile.setWritable(false) - - // Verify files are indeed read-only - assertThat(readOnlyFile.canWrite()).isFalse() - assertThat(anotherReadOnlyFile.canWrite()).isFalse() - - FileSystemUtils.makeWritable(parentDir.toFile()) - - // Verify all files are now writable - assertThat(regularFile.canWrite()).isTrue() - assertThat(readOnlyFile.canWrite()).isTrue() - assertThat(anotherReadOnlyFile.canWrite()).isTrue() - - // Clean up - parentDir.toFile().deleteDir() - } - - @Test - void 'deletes files except'() { - Path parentDir = Files.createTempDirectory(this.class.getSimpleName()) - for (i in 0..<3) { - def filePath = parentDir.resolve i.toString() - Files.write(filePath, i.toString().getBytes()) - } - for (i in 3..<7) { - Path dirPath = parentDir.resolve(i.toString()) - Files.createDirectories(dirPath) - } - - fileSystemUtils.deleteFilesExcept(parentDir.toFile(), '0', '3') - - List chartSubFolders = Files.list(parentDir).collect(Collectors.toList()) - assertThat(chartSubFolders).hasSize(2) - assertThat(chartSubFolders).contains(parentDir.resolve('0'), parentDir.resolve('3')) - } -} + FileSystemUtils fileSystemUtils = new FileSystemUtils() + + @Test + void copiesToTempDir() { + def expectedText = 'someText' + + File someFile = File.createTempFile(getClass().getSimpleName(), '') + someFile.withWriter { + { + it.println expectedText + } + } + Path tmpFile = fileSystemUtils.copyToTempDir(someFile.absolutePath) + + assertThat(tmpFile.toAbsolutePath().toString()).isNotEqualTo(someFile.getAbsoluteFile()) + assertThat(tmpFile.toFile().getText().trim()).isEqualTo(expectedText) + } + + @Test + void 'makes read-only folders writable recursively'() { + // Create temporary directory with nested structure + Path parentDir = Files.createTempDirectory(this.class.getSimpleName()) + + // Create some regular files + File regularFile = new File(parentDir.toFile(), "regularFile.txt") + regularFile.createNewFile() + + // Create nested directory + File nestedDir = new File(parentDir.toFile(), "nestedDir") + nestedDir.mkdir() + + // Create read-only file in nested directory + File readOnlyFile = new File(nestedDir, "readOnlyFile.txt") + readOnlyFile.createNewFile() + readOnlyFile.setWritable(false) + + // Create another read-only file in parent directory + File anotherReadOnlyFile = new File(parentDir.toFile(), "anotherReadOnlyFile.txt") + anotherReadOnlyFile.createNewFile() + anotherReadOnlyFile.setWritable(false) + + // Verify files are indeed read-only + assertThat(readOnlyFile.canWrite()).isFalse() + assertThat(anotherReadOnlyFile.canWrite()).isFalse() + + FileSystemUtils.makeWritable(parentDir.toFile()) + + // Verify all files are now writable + assertThat(regularFile.canWrite()).isTrue() + assertThat(readOnlyFile.canWrite()).isTrue() + assertThat(anotherReadOnlyFile.canWrite()).isTrue() + + // Clean up + parentDir.toFile().deleteDir() + } + + @Test + void 'deletes files except'() { + Path parentDir = Files.createTempDirectory(this.class.getSimpleName()) + for (i in 0..<3) { + def filePath = parentDir.resolve i.toString() + Files.write(filePath, i.toString().getBytes()) + } + for (i in 3..<7) { + Path dirPath = parentDir.resolve(i.toString()) + Files.createDirectories(dirPath) + } + + fileSystemUtils.deleteFilesExcept(parentDir.toFile(), '0', '3') + + List chartSubFolders = Files.list(parentDir).collect(Collectors.toList()) + assertThat(chartSubFolders).hasSize(2) + assertThat(chartSubFolders).contains(parentDir.resolve('0'), parentDir.resolve('3')) + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/HelmClientTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/HelmClientTest.groovy index f9f20b75d..47346c8d6 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/HelmClientTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/HelmClientTest.groovy @@ -1,56 +1,54 @@ package com.cloudogu.gitops.utils +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.kubernetes.api.HelmClient + import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat class HelmClientTest { - - @Test - void 'assembles parameters for upgrade'() { - def commandExecutor = new CommandExecutorForTest() - new HelmClient(commandExecutor).upgrade("the-release", "path/to/chart", [ - version: 'the-version', - namespace: 'the-namespace', - values: 'values.yaml', - ]) - new HelmClient(commandExecutor).upgrade("the-release", "path/to/chart", [:]) - new HelmClient(commandExecutor).upgrade("the-release", "path/to/chart", [namespace: 'the-namespace']) - - assertThat(commandExecutor.actualCommands[0]).startsWith('helm upgrade -i the-release path/to/chart --create-namespace') - assertThat(commandExecutor.actualCommands[0]).contains(' --version the-version') - assertThat(commandExecutor.actualCommands[0]).contains(' --values values.yaml') - assertThat(commandExecutor.actualCommands[0]).contains(' --namespace the-namespace') - - assertThat(commandExecutor.actualCommands[1]).isEqualTo('helm upgrade -i the-release path/to/chart --create-namespace') - assertThat(commandExecutor.actualCommands[2]).isEqualTo('helm upgrade -i the-release path/to/chart --create-namespace --namespace the-namespace') - } - - @Test - void 'runs helm template'() { - def commandExecutor = new CommandExecutorForTest() - new HelmClient(commandExecutor).template("the-release", "path/to/chart", [ - version: 'the-version', - namespace: 'the-namespace', - values: 'values.yaml', - ]) - new HelmClient(commandExecutor).template("the-release", "path/to/chart", [:]) - new HelmClient(commandExecutor).template("the-release", "path/to/chart", [namespace: 'the-namespace']) - - assertThat(commandExecutor.actualCommands[0]).startsWith('helm template the-release path/to/chart ') - assertThat(commandExecutor.actualCommands[0]).contains(' --version the-version') - assertThat(commandExecutor.actualCommands[0]).contains(' --values values.yaml') - assertThat(commandExecutor.actualCommands[0]).contains(' --namespace the-namespace') - - assertThat(commandExecutor.actualCommands[1]).isEqualTo('helm template the-release path/to/chart') - assertThat(commandExecutor.actualCommands[2]).isEqualTo('helm template the-release path/to/chart --namespace the-namespace') - } - - @Test - void 'assembles parameters for uninstall'() { - def commandExecutor = new CommandExecutorForTest() - new HelmClient(commandExecutor).uninstall("the-release", 'the-namespace') - - assertThat(commandExecutor.actualCommands[0]).isEqualTo('helm uninstall the-release --namespace the-namespace') - } + + @Test + void 'assembles parameters for upgrade'() { + def commandExecutor = new CommandExecutorForTest() + new HelmClient(commandExecutor).upgrade("the-release", "path/to/chart", [version : 'the-version', + namespace: 'the-namespace', + values : 'values.yaml',]) + new HelmClient(commandExecutor).upgrade("the-release", "path/to/chart", [:]) + new HelmClient(commandExecutor).upgrade("the-release", "path/to/chart", [namespace: 'the-namespace']) + + assertThat(commandExecutor.actualCommands[0]).startsWith('helm upgrade -i the-release path/to/chart --create-namespace') + assertThat(commandExecutor.actualCommands[0]).contains(' --version the-version') + assertThat(commandExecutor.actualCommands[0]).contains(' --values values.yaml') + assertThat(commandExecutor.actualCommands[0]).contains(' --namespace the-namespace') + + assertThat(commandExecutor.actualCommands[1]).isEqualTo('helm upgrade -i the-release path/to/chart --create-namespace') + assertThat(commandExecutor.actualCommands[2]).isEqualTo('helm upgrade -i the-release path/to/chart --create-namespace --namespace the-namespace') + } + + @Test + void 'runs helm template'() { + def commandExecutor = new CommandExecutorForTest() + new HelmClient(commandExecutor).template("the-release", "path/to/chart", [version : 'the-version', + namespace: 'the-namespace', + values : 'values.yaml',]) + new HelmClient(commandExecutor).template("the-release", "path/to/chart", [:]) + new HelmClient(commandExecutor).template("the-release", "path/to/chart", [namespace: 'the-namespace']) + + assertThat(commandExecutor.actualCommands[0]).startsWith('helm template the-release path/to/chart ') + assertThat(commandExecutor.actualCommands[0]).contains(' --version the-version') + assertThat(commandExecutor.actualCommands[0]).contains(' --values values.yaml') + assertThat(commandExecutor.actualCommands[0]).contains(' --namespace the-namespace') + + assertThat(commandExecutor.actualCommands[1]).isEqualTo('helm template the-release path/to/chart') + assertThat(commandExecutor.actualCommands[2]).isEqualTo('helm template the-release path/to/chart --namespace the-namespace') + } + + @Test + void 'assembles parameters for uninstall'() { + def commandExecutor = new CommandExecutorForTest() + new HelmClient(commandExecutor).uninstall("the-release", 'the-namespace') + + assertThat(commandExecutor.actualCommands[0]).isEqualTo('helm uninstall the-release --namespace the-namespace') + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/K8sClientForTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/K8sClientForTest.groovy index 8594961f9..310e71699 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/K8sClientForTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/K8sClientForTest.groovy @@ -2,21 +2,23 @@ package com.cloudogu.gitops.utils import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.kubernetes.api.K8sClient -import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer + import jakarta.inject.Provider +import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer + class K8sClientForTest extends K8sClient { - CommandExecutorForTest commandExecutorForTest + CommandExecutorForTest commandExecutorForTest - K8sClientForTest(Config config, CommandExecutorForTest commandExecutor = new CommandExecutorForTest()) { - super(commandExecutor, new FileSystemUtils(), new Provider() { - @Override - Config get() { - return config - } - }) - this.k8sJavaApiClient.client = new KubernetesMockServer().createClient() - commandExecutorForTest = commandExecutor - this.SLEEPTIME = 1 - } + K8sClientForTest(Config config, CommandExecutorForTest commandExecutor = new CommandExecutorForTest()) { + super(commandExecutor, new FileSystemUtils(), new Provider() { + @Override + Config get() { + return config + } + }) + this.k8sJavaApiClient.client = new KubernetesMockServer().createClient() + commandExecutorForTest = commandExecutor + this.SLEEPTIME = 1 + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/K8sClientTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/K8sClientTest.groovy index d5f267435..97e2dc129 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/K8sClientTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/K8sClientTest.groovy @@ -1,459 +1,417 @@ package com.cloudogu.gitops.utils +import static groovy.test.GroovyAssert.shouldFail +import static org.assertj.core.api.Assertions.assertThat + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.kubernetes.api.K8sClient + import groovy.yaml.YamlSlurper -import org.junit.jupiter.api.Test -import static groovy.test.GroovyAssert.shouldFail -import static org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test class K8sClientTest { - Config config = new Config(application: new Config.ApplicationSchema(namePrefix: "")) - - K8sClientForTest k8sClient = new K8sClientForTest(config) - CommandExecutorForTest commandExecutor = k8sClient.commandExecutorForTest - - @Test - void 'Gets internal nodeIp'() { - // waitForNode() - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/k3d-gitops-playground-server-0', 0)) - // waitForInternalNodeIp() - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', '1.2.3.4', 0)) - - def actualNodeIp = k8sClient.waitForInternalNodeIp() - - assertThat(actualNodeIp).isEqualTo('1.2.3.4') - assertThat(commandExecutor.actualCommands[1]).isEqualTo( - "kubectl get node/k3d-gitops-playground-server-0 " + - "--template='{{range .status.addresses}}{{ if eq .type \"InternalIP\" }}{{.address}}{{break}}{{end}}{{end}}'") - } - - @Test - void 'Gets internal nodeIp after waiting for node'() { - // waitForNode() - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', '', 0)) - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', '', 0)) - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/k3d-gitops-playground-server-0', 0)) - // waitForInternalNodeIp() - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', '1.2.3.4', 0)) - - def actualNodeIp = k8sClient.waitForInternalNodeIp() - - assertThat(actualNodeIp).isEqualTo('1.2.3.4') - } - - @Test - void 'Creates secret'() { - k8sClient.createSecret('generic', 'my-secret', 'my-ns', - new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl create secret generic my-secret -n my-ns --from-literal key1=value1 --from-literal key2=value2" + - " --dry-run=client -oyaml | kubectl apply -f-") - } - - @Test - void 'get secret'() { - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'Test', 0)) - k8sClient.getArgoCDNamespacesSecret('my-secret', 'my-ns') - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl get secret my-secret -n my-ns -ojsonpath={.data.namespaces}") - } - - @Test - void 'Creates secret without namespace'() { - k8sClient.createSecret('generic', 'my-secret', new Tuple2('key1', 'value1')) - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl create secret generic my-secret --from-literal key1=value1 --dry-run=client -oyaml" + - " | kubectl apply -f-") - } - - @Test - void 'Creates imagePullSecret without namespace'() { - k8sClient.createImagePullSecret('my-reg', 'host', 'user', 'pw') - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - 'kubectl create secret docker-registry my-reg' + - ' --docker-server host --docker-username user --docker-password pw' + - ' --dry-run=client -oyaml | kubectl apply -f-') - } - - @Test - void 'Creates imagePullSecret'() { - k8sClient.createImagePullSecret('my-reg', 'my-ns', 'host', 'user', 'pw') - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - 'kubectl create secret docker-registry my-reg -n my-ns' + - ' --docker-server host --docker-username user --docker-password pw' + - ' --dry-run=client -oyaml | kubectl apply -f-') - } - - @Test - void 'Creates no secret when literals are missing'() { - def exception = shouldFail(RuntimeException) { - k8sClient.createSecret('generic', 'my-secret') - } - assertThat(exception.message).isEqualTo('Missing values for parameter \'--from-literal\' in command \'kubectl create secret generic my-secret\'') - } - - @Test - void 'Ensure in secret creation, nullable String become empty string'() { - - k8sClient.createSecret("generic", "very-secret", new Tuple2('isnullbecomeempty', null)) - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl create secret generic very-secret --from-literal isnullbecomeempty= --dry-run=client -oyaml" + - " | kubectl apply -f-") - } - - @Test - void 'Creates configmap from file'() { - k8sClient.createConfigMapFromFile('my-map', 'my-ns', '/file') - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl create configmap my-map -n my-ns --from-file /file --dry-run=client -oyaml" + - " | kubectl apply -f-") - } - - @Test - void 'Creates configmap without namespace'() { - k8sClient.createConfigMapFromFile('my-map', '/file') - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl create configmap my-map --from-file /file --dry-run=client -oyaml" + - " | kubectl apply -f-") - } - - @Test - void 'Creates service type nodePort'() { - k8sClient.createServiceNodePort('my-svc', '42:23', '32000', 'my-ns') - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - 'kubectl create service nodeport my-svc -n my-ns --tcp 42:23 --node-port 32000' + - ' --dry-run=client -oyaml | kubectl apply -f-') - } - - @Test - void 'Creates service type nodePort without namespace and explicit nodePort'() { - k8sClient.createServiceNodePort('my-svc', '42:23') - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - 'kubectl create service nodeport my-svc --tcp 42:23' + - ' --dry-run=client -oyaml | kubectl apply -f-') - } - - @Test - void 'Adds labels'() { - k8sClient.label('secret', 'my-secret', 'my-ns', - new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl label secret my-secret -n my-ns --overwrite key1=value1 key2=value2") - } - - @Test - void 'Removes labels explicitly'() { - k8sClient.labelRemove('node', '--all', null, 'key1', 'key2') - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl label node --all --overwrite key1- key2-") - } - - @Test - void 'Removes labels kubectl-style'() { - // The syntax for removing labels is key appended by a minus, e.g. - // kubectl label node key- - k8sClient.label('node', '--all', - new Tuple2('key1-', ''), new Tuple2('key2-', '')) - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl label node --all --overwrite key1- key2-") - } - - @Test - void 'Adds labels without namespace'() { - k8sClient.label('secret', 'my-secret', - new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl label secret my-secret --overwrite key1=value1 key2=value2") - } - - @Test - void 'Does not add label when key value pairs are missing'() { - def exception = shouldFail(RuntimeException) { - k8sClient.label('secret', 'my-secret') - } - assertThat(exception.message).isEqualTo('Missing key-value-pairs') - } - - @Test - void 'Patches'() { - def expectedYaml = [a: 'b'] - k8sClient.patch('secret', 'my-secret', 'ns', expectedYaml) - - assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl patch secret my-secret -n ns --patch-file=") - - String patchFile = (commandExecutor.actualCommands[0] =~ /--patch-file=([\S]+)/)?.findResult { (it as List)[1] } - assertThat(parseActualYaml(patchFile)).isEqualTo(expectedYaml) - } - - @Test - void 'Patches without namespace'() { - k8sClient.patch('secret', 'my-secret', [a: 'b']) - - assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl patch secret my-secret --patch-file=") - } - - @Test - void 'Patches with type merge'() { - k8sClient.patch('secret', 'my-secret', '', 'merge', [a: 'b']) - - assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl patch secret my-secret --type=merge --patch-file=") - } - - @Test - void 'Deletes'() { - k8sClient.delete('secret', 'my-ns', - new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl delete secret -n my-ns --ignore-not-found=true --selector=key1=value1 --selector=key2=value2") - } - - @Test - void 'Deletes without namespace'() { - k8sClient.delete('secret', - new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) - - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl delete secret --ignore-not-found=true --selector=key1=value1 --selector=key2=value2") - } - - @Test - void 'Does not add delete when selectors are missing'() { - def exception = shouldFail(RuntimeException) { - k8sClient.delete('secret') - } - assertThat(exception.message).isEqualTo('Missing selectors') - } - - @Test - void 'Gets custom resources with name prefix'() { - commandExecutor.enqueueOutput(new CommandExecutor.Output('', "namespace,name\nnamespace2,name2", 0)) - def result = k8sClient.getCustomResource('foo') - assertThat(result).isEqualTo([new K8sClient.CustomResource('namespace', 'name'), new K8sClient.CustomResource('namespace2', 'name2')]) - } - - @Test - void 'fetches config map sucessfully'() { - commandExecutor.enqueueOutput(new CommandExecutor.Output('', "the-file-content", 0)) - def map = k8sClient.getConfigMap("the-map", "file.yaml") - - assertThat(map, "the-file-content") - } - - @Test - void 'errors when config map does not exist'() { - commandExecutor.enqueueOutput(new CommandExecutor.Output("Error from server (NotFound): configmaps \"the-map\" not found", "", 1)) - def exception = shouldFail() { - k8sClient.getConfigMap("the-map", "file.yaml") - } - assertThat(exception.message).isEqualTo("Could not fetch configmap the-map: Error from server (NotFound): configmaps \"the-map\" not found") - } - - @Test - void 'errors when file does not exist'() { - commandExecutor.enqueueOutput(new CommandExecutor.Output('', '', 0)) - def exception = shouldFail() { - k8sClient.getConfigMap("the-map", "file.yaml") - } - assertThat(exception.message).isEqualTo('Could not fetch file.yaml within config-map the-map') - } - - @Test - void 'returns current context'() { - def expectedOutput = 'k3d-something' - commandExecutor.enqueueOutput(new CommandExecutor.Output('', expectedOutput, 0)) - - assertThat(k8sClient.currentContext).isEqualTo(expectedOutput) - } - - @Test - void 'returns useful information, even if current context is not set'() { - def expectedOutput = '' - commandExecutor.enqueueOutput(new CommandExecutor.Output('error: current-context is not set', expectedOutput, 1)) - - assertThat(k8sClient.currentContext).isEqualTo('(current context not set)') - } - - @Test - void 'Creates namespace when it does not exist'() { - // Simulate that the namespace does not exist (kubectl get returns a non-zero exit code) - commandExecutor.enqueueOutput(new CommandExecutor.Output('Error from server (NotFound): namespaces "my-ns" not found', '', 1)) - - // Attempt to create the namespace - k8sClient.createNamespace('my-ns') - - // Assert that the correct kubectl command was issued to create the namespace - assertThat(commandExecutor.actualCommands[1]).isEqualTo( - "kubectl create namespace my-ns") - } - - @Test - void 'Does not create namespace if it already exists'() { - // Simulate that the namespace already exists (kubectl get returns a zero exit code) - commandExecutor.enqueueOutput(new CommandExecutor.Output('', '', 0)) - - // Attempt to create the namespace - k8sClient.createNamespace('my-ns') - - // Assert that no kubectl create command was issued except 'kubectl get namespace my-ns' - assertThat(commandExecutor.actualCommands.size()).is(1) - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - "kubectl get namespace my-ns") - } - - @Test - void 'Throws IllegalArgumentException when namespace name for Creation is null'() { - // Attempt to create a namespace with a null name - def exception = shouldFail(IllegalArgumentException) { - k8sClient.createNamespace(null) - } + Config config = new Config(application: new Config.ApplicationSchema(namePrefix: "")) - // Assert that the exception message is correct - assertThat(exception.message).isEqualTo("Namespace name must be provided and cannot be null or empty.") - } + K8sClientForTest k8sClient = new K8sClientForTest(config) + CommandExecutorForTest commandExecutor = k8sClient.commandExecutorForTest - @Test - void 'Throws IllegalArgumentException when namespace name for Creation is empty'() { - // Attempt to create a namespace with an empty name - def exception = shouldFail(IllegalArgumentException) { - k8sClient.createNamespace('') - } + @Test + void 'Gets internal nodeIp'() { + // waitForNode() + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/k3d-gitops-playground-server-0', 0)) + // waitForInternalNodeIp() + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', '1.2.3.4', 0)) - // Assert that the exception message is correct - assertThat(exception.message).isEqualTo("Namespace name must be provided and cannot be null or empty.") - } + def actualNodeIp = k8sClient.waitForInternalNodeIp() - @Test - void 'Throws RuntimeException when Namespace creation fails due to insufficient permissions'() { - // Simulate Namespace does not exist - commandExecutor.enqueueOutput(new CommandExecutor.Output('', '', 1)) - // Simulate a permission error during namespace creation - commandExecutor.enqueueOutput(new CommandExecutor.Output('Error from server (Forbidden): namespaces is forbidden', '', 1)) + assertThat(actualNodeIp).isEqualTo('1.2.3.4') + assertThat(commandExecutor.actualCommands[1]).isEqualTo("kubectl get node/k3d-gitops-playground-server-0 " + + "--template='{{range .status.addresses}}{{ if eq .type \"InternalIP\" }}{{.address}}{{break}}{{end}}{{end}}'") + } - // Attempt to create the namespace - def exception = shouldFail(RuntimeException) { - k8sClient.createNamespace('my-ns') - } + @Test + void 'Gets internal nodeIp after waiting for node'() { + // waitForNode() + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', '', 0)) + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', '', 0)) + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/k3d-gitops-playground-server-0', 0)) + // waitForInternalNodeIp() + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', '1.2.3.4', 0)) - // Assert that the exception message is correct - assertThat(exception.message).contains("Failed to create namespace my-ns (possibly due to insufficient permissions)") - } + def actualNodeIp = k8sClient.waitForInternalNodeIp() - @Test - void 'Throws RuntimeException on unexpected error during namespace creation'() { - // Simulate Namespace does not exist - commandExecutor.enqueueOutput(new CommandExecutor.Output('', '', 1)) - // Simulate an unexpected error during namespace creation - commandExecutor.enqueueOutput(new CommandExecutor.Output('', 'Unexpected error', 1)) + assertThat(actualNodeIp).isEqualTo('1.2.3.4') + } - // Attempt to create the namespace - def exception = shouldFail(RuntimeException) { - k8sClient.createNamespace('my-ns') - } + @Test + void 'Creates secret'() { + k8sClient.createSecret('generic', 'my-secret', 'my-ns', + new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) - // Assert that the exception message is correct - assertThat(exception.message).contains("Failed to create namespace my-ns (possibly due to insufficient permissions)") - } + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl create secret generic my-secret -n my-ns --from-literal key1=value1 --from-literal key2=value2" + + " --dry-run=client -oyaml | kubectl apply -f-") + } - @Test - void 'Patches nodePort successfully when all parameters are valid'() { - // Simulate the output of the kubectl get service command - def serviceJson = ''' - { - "spec": { - "ports": [ - {"name": "http", "nodePort": 30000}, - {"name": "https", "nodePort": 30001} - ] - } - }''' - commandExecutor.enqueueOutput(new CommandExecutor.Output('', serviceJson, 0)) - - // Attempt to patch the nodePort - k8sClient.patchServiceNodePort('my-service', 'my-namespace', 'https', 32000) - - // Assert that the correct kubectl patch command was issued - assertThat(commandExecutor.actualCommands[1]).isEqualTo( - 'kubectl patch service my-service -n my-namespace --type json -p [{"op":"replace","path":"/spec/ports/1/nodePort","value":32000}]' - ) - } - @Test - void 'Throws IllegalArgumentException when serviceName is null in patchServiceNodePort'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.patchServiceNodePort(null, 'my-namespace', 'https', 32000) - } + @Test + void 'get secret'() { + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'Test', 0)) + k8sClient.getArgoCDNamespacesSecret('my-secret', 'my-ns') - assertThat(exception.message).isEqualTo("Service name, namespace, port name, and valid nodePort must be provided") - } + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl get secret my-secret -n my-ns -ojsonpath={.data.namespaces}") + } - @Test - void 'Throws IllegalArgumentException when namespace is null in patchServiceNodePort'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.patchServiceNodePort('my-service', null, 'https', 32000) - } + @Test + void 'Creates secret without namespace'() { + k8sClient.createSecret('generic', 'my-secret', new Tuple2('key1', 'value1')) - assertThat(exception.message).isEqualTo("Service name, namespace, port name, and valid nodePort must be provided") - } + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl create secret generic my-secret --from-literal key1=value1 --dry-run=client -oyaml" + + " | kubectl apply -f-") + } - @Test - void 'Throws IllegalArgumentException when portName is null'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.patchServiceNodePort('my-service', 'my-namespace', null, 32000) - } + @Test + void 'Creates imagePullSecret without namespace'() { + k8sClient.createImagePullSecret('my-reg', 'host', 'user', 'pw') - assertThat(exception.message).isEqualTo("Service name, namespace, port name, and valid nodePort must be provided") - } + assertThat(commandExecutor.actualCommands[0]).isEqualTo('kubectl create secret docker-registry my-reg' + ' --docker-server host --docker-username user --docker-password pw' + + ' --dry-run=client -oyaml | kubectl apply -f-') + } + + @Test + void 'Creates imagePullSecret'() { + k8sClient.createImagePullSecret('my-reg', 'my-ns', 'host', 'user', 'pw') - @Test - void 'Throws IllegalArgumentException when newNodePort is not valid (less than 0)'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.patchServiceNodePort('my-service', 'my-namespace', 'https', -1) - } + assertThat(commandExecutor.actualCommands[0]).isEqualTo('kubectl create secret docker-registry my-reg -n my-ns' + + ' --docker-server host --docker-username user --docker-password pw' + + ' --dry-run=client -oyaml | kubectl apply -f-') + } + + @Test + void 'Creates no secret when literals are missing'() { + def exception = shouldFail(RuntimeException) { + k8sClient.createSecret('generic', 'my-secret') + } + assertThat(exception.message).isEqualTo('Missing values for parameter \'--from-literal\' in command \'kubectl create secret generic my-secret\'') + } + + @Test + void 'Ensure in secret creation, nullable String become empty string'() { - assertThat(exception.message).isEqualTo("Service name, namespace, port name, and valid nodePort must be provided") - } + k8sClient.createSecret("generic", "very-secret", new Tuple2('isnullbecomeempty', null)) - @Test - void 'Throws RuntimeException when service does not contain the specified port'() { - // Simulate the output of the kubectl get service command with no matching port - def serviceJson = ''' + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl create secret generic very-secret --from-literal isnullbecomeempty= --dry-run=client -oyaml" + + " | kubectl apply -f-") + } + + @Test + void 'Creates configmap from file'() { + k8sClient.createConfigMapFromFile('my-map', 'my-ns', '/file') + + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl create configmap my-map -n my-ns --from-file /file --dry-run=client -oyaml" + " | kubectl apply -f-") + } + + @Test + void 'Creates configmap without namespace'() { + k8sClient.createConfigMapFromFile('my-map', '/file') + + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl create configmap my-map --from-file /file --dry-run=client -oyaml" + " | kubectl apply -f-") + } + + @Test + void 'Creates service type nodePort'() { + k8sClient.createServiceNodePort('my-svc', '42:23', '32000', 'my-ns') + + assertThat(commandExecutor.actualCommands[0]).isEqualTo('kubectl create service nodeport my-svc -n my-ns --tcp 42:23 --node-port 32000' + + ' --dry-run=client -oyaml | kubectl apply -f-') + } + + @Test + void 'Creates service type nodePort without namespace and explicit nodePort'() { + k8sClient.createServiceNodePort('my-svc', '42:23') + + assertThat(commandExecutor.actualCommands[0]).isEqualTo('kubectl create service nodeport my-svc --tcp 42:23' + ' --dry-run=client -oyaml | kubectl apply -f-') + } + + @Test + void 'Adds labels'() { + k8sClient.label('secret', 'my-secret', 'my-ns', + new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) + + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl label secret my-secret -n my-ns --overwrite key1=value1 key2=value2") + } + + @Test + void 'Removes labels explicitly'() { + k8sClient.labelRemove('node', '--all', null, 'key1', 'key2') + + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl label node --all --overwrite key1- key2-") + } + + @Test + void 'Removes labels kubectl-style'() { + // The syntax for removing labels is key appended by a minus, e.g. + // kubectl label node key- + k8sClient.label('node', '--all', + new Tuple2('key1-', ''), new Tuple2('key2-', '')) + + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl label node --all --overwrite key1- key2-") + } + + @Test + void 'Adds labels without namespace'() { + k8sClient.label('secret', 'my-secret', + new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) + + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl label secret my-secret --overwrite key1=value1 key2=value2") + } + + @Test + void 'Does not add label when key value pairs are missing'() { + def exception = shouldFail(RuntimeException) { + k8sClient.label('secret', 'my-secret') + } + assertThat(exception.message).isEqualTo('Missing key-value-pairs') + } + + @Test + void 'Patches'() { + def expectedYaml = [a: 'b'] + k8sClient.patch('secret', 'my-secret', 'ns', expectedYaml) + + assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl patch secret my-secret -n ns --patch-file=") + + String patchFile = (commandExecutor.actualCommands[0] =~ /--patch-file=([\S]+)/)?.findResult { (it as List)[1] } + assertThat(parseActualYaml(patchFile)).isEqualTo(expectedYaml) + } + + @Test + void 'Patches without namespace'() { + k8sClient.patch('secret', 'my-secret', [a: 'b']) + + assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl patch secret my-secret --patch-file=") + } + + @Test + void 'Patches with type merge'() { + k8sClient.patch('secret', 'my-secret', '', 'merge', [a: 'b']) + + assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl patch secret my-secret --type=merge --patch-file=") + } + + @Test + void 'Deletes'() { + k8sClient.delete('secret', 'my-ns', + new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) + + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl delete secret -n my-ns --ignore-not-found=true --selector=key1=value1 --selector=key2=value2") + } + + @Test + void 'Deletes without namespace'() { + k8sClient.delete('secret', + new Tuple2('key1', 'value1'), new Tuple2('key2', 'value2')) + + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl delete secret --ignore-not-found=true --selector=key1=value1 --selector=key2=value2") + } + + @Test + void 'Does not add delete when selectors are missing'() { + def exception = shouldFail(RuntimeException) { + k8sClient.delete('secret') + } + assertThat(exception.message).isEqualTo('Missing selectors') + } + + @Test + void 'Gets custom resources with name prefix'() { + commandExecutor.enqueueOutput(new CommandExecutor.Output('', "namespace,name\nnamespace2,name2", 0)) + def result = k8sClient.getCustomResource('foo') + assertThat(result).isEqualTo([new K8sClient.CustomResource('namespace', 'name'), new K8sClient.CustomResource('namespace2', 'name2')]) + } + + @Test + void 'fetches config map sucessfully'() { + commandExecutor.enqueueOutput(new CommandExecutor.Output('', "the-file-content", 0)) + def map = k8sClient.getConfigMap("the-map", "file.yaml") + + assertThat(map, "the-file-content") + } + + @Test + void 'errors when config map does not exist'() { + commandExecutor.enqueueOutput(new CommandExecutor.Output("Error from server (NotFound): configmaps \"the-map\" not found", "", 1)) + def exception = shouldFail() { + k8sClient.getConfigMap("the-map", "file.yaml") + } + assertThat(exception.message).isEqualTo("Could not fetch configmap the-map: Error from server (NotFound): configmaps \"the-map\" not found") + } + + @Test + void 'errors when file does not exist'() { + commandExecutor.enqueueOutput(new CommandExecutor.Output('', '', 0)) + def exception = shouldFail() { + k8sClient.getConfigMap("the-map", "file.yaml") + } + assertThat(exception.message).isEqualTo('Could not fetch file.yaml within config-map the-map') + } + + @Test + void 'returns current context'() { + def expectedOutput = 'k3d-something' + commandExecutor.enqueueOutput(new CommandExecutor.Output('', expectedOutput, 0)) + + assertThat(k8sClient.currentContext).isEqualTo(expectedOutput) + } + + @Test + void 'returns useful information, even if current context is not set'() { + def expectedOutput = '' + commandExecutor.enqueueOutput(new CommandExecutor.Output('error: current-context is not set', expectedOutput, 1)) + + assertThat(k8sClient.currentContext).isEqualTo('(current context not set)') + } + + @Test + void 'Creates namespace when it does not exist'() { + // Simulate that the namespace does not exist (kubectl get returns a non-zero exit code) + commandExecutor.enqueueOutput(new CommandExecutor.Output('Error from server (NotFound): namespaces "my-ns" not found', '', 1)) + + // Attempt to create the namespace + k8sClient.createNamespace('my-ns') + + // Assert that the correct kubectl command was issued to create the namespace + assertThat(commandExecutor.actualCommands[1]).isEqualTo("kubectl create namespace my-ns") + } + + @Test + void 'Does not create namespace if it already exists'() { + // Simulate that the namespace already exists (kubectl get returns a zero exit code) + commandExecutor.enqueueOutput(new CommandExecutor.Output('', '', 0)) + + // Attempt to create the namespace + k8sClient.createNamespace('my-ns') + + // Assert that no kubectl create command was issued except 'kubectl get namespace my-ns' + assertThat(commandExecutor.actualCommands.size()).is(1) + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl get namespace my-ns") + } + + @Test + void 'Throws IllegalArgumentException when namespace name for Creation is null'() { + // Attempt to create a namespace with a null name + def exception = shouldFail(IllegalArgumentException) { + k8sClient.createNamespace(null) + } + + // Assert that the exception message is correct + assertThat(exception.message).isEqualTo("Namespace name must be provided and cannot be null or empty.") + } + + @Test + void 'Throws IllegalArgumentException when namespace name for Creation is empty'() { + // Attempt to create a namespace with an empty name + def exception = shouldFail(IllegalArgumentException) { + k8sClient.createNamespace('') + } + + // Assert that the exception message is correct + assertThat(exception.message).isEqualTo("Namespace name must be provided and cannot be null or empty.") + } + + @Test + void 'Throws RuntimeException when Namespace creation fails due to insufficient permissions'() { + // Simulate Namespace does not exist + commandExecutor.enqueueOutput(new CommandExecutor.Output('', '', 1)) + // Simulate a permission error during namespace creation + commandExecutor.enqueueOutput(new CommandExecutor.Output('Error from server (Forbidden): namespaces is forbidden', '', 1)) + + // Attempt to create the namespace + def exception = shouldFail(RuntimeException) { + k8sClient.createNamespace('my-ns') + } + + // Assert that the exception message is correct + assertThat(exception.message).contains("Failed to create namespace my-ns (possibly due to insufficient permissions)") + } + + @Test + void 'Throws RuntimeException on unexpected error during namespace creation'() { + // Simulate Namespace does not exist + commandExecutor.enqueueOutput(new CommandExecutor.Output('', '', 1)) + // Simulate an unexpected error during namespace creation + commandExecutor.enqueueOutput(new CommandExecutor.Output('', 'Unexpected error', 1)) + + // Attempt to create the namespace + def exception = shouldFail(RuntimeException) { + k8sClient.createNamespace('my-ns') + } + + // Assert that the exception message is correct + assertThat(exception.message).contains("Failed to create namespace my-ns (possibly due to insufficient permissions)") + } + + @Test + void 'Patches nodePort successfully when all parameters are valid'() { + // Simulate the output of the kubectl get service command + def serviceJson = ''' { "spec": { "ports": [ - {"name": "http", "nodePort": 30000} + {"name": "http", "nodePort": 30000}, + {"name": "https", "nodePort": 30001} ] } }''' - commandExecutor.enqueueOutput(new CommandExecutor.Output('', serviceJson, 0)) - - def exception = shouldFail(RuntimeException) { - k8sClient.patchServiceNodePort('my-service', 'my-namespace', 'https', 32000) - } - - assertThat(exception.message).isEqualTo("Port with name https not found in service my-service.") - } - - @Test - void 'Throws RuntimeException when kubectl patch command fails on Service NodePort'() { - // Simulate the output of the kubectl get service command - def serviceJson = ''' + commandExecutor.enqueueOutput(new CommandExecutor.Output('', serviceJson, 0)) + + // Attempt to patch the nodePort + k8sClient.patchServiceNodePort('my-service', 'my-namespace', 'https', 32000) + + // Assert that the correct kubectl patch command was issued + assertThat(commandExecutor.actualCommands[1]).isEqualTo('kubectl patch service my-service -n my-namespace --type json -p [{"op":"replace","path":"/spec/ports/1/nodePort","value":32000}]') + } + + @Test + void 'Throws IllegalArgumentException when serviceName is null in patchServiceNodePort'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.patchServiceNodePort(null, 'my-namespace', 'https', 32000) + } + + assertThat(exception.message).isEqualTo("Service name, namespace, port name, and valid nodePort must be provided") + } + + @Test + void 'Throws IllegalArgumentException when namespace is null in patchServiceNodePort'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.patchServiceNodePort('my-service', null, 'https', 32000) + } + + assertThat(exception.message).isEqualTo("Service name, namespace, port name, and valid nodePort must be provided") + } + + @Test + void 'Throws IllegalArgumentException when portName is null'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.patchServiceNodePort('my-service', 'my-namespace', null, 32000) + } + + assertThat(exception.message).isEqualTo("Service name, namespace, port name, and valid nodePort must be provided") + } + + @Test + void 'Throws IllegalArgumentException when newNodePort is not valid (less than 0)'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.patchServiceNodePort('my-service', 'my-namespace', 'https', -1) + } + + assertThat(exception.message).isEqualTo("Service name, namespace, port name, and valid nodePort must be provided") + } + + @Test + void 'Throws RuntimeException when service does not contain the specified port'() { + // Simulate the output of the kubectl get service command with no matching port + def serviceJson = ''' { "spec": { "ports": [ @@ -461,177 +419,184 @@ class K8sClientTest { ] } }''' - commandExecutor.enqueueOutput(new CommandExecutor.Output('', serviceJson, 0)) - - // Simulate a failure in the kubectl patch command - commandExecutor.enqueueOutput(new CommandExecutor.Output('Error from server (Forbidden): services "my-service" is forbidden', '', 1)) - - def exception = shouldFail(RuntimeException) { - k8sClient.patchServiceNodePort('my-service', 'my-namespace', 'http', 32000) - } - - assertThat(exception.message).contains("Executing command failed: kubectl patch service my-service -n my-namespace --type json -p [{\"op\":\"replace\",\"path\":\"/spec/ports/0/nodePort\",\"value\":32000}]") - } - - @Test - void 'Waits successfully until the resource reaches the desired phase'() { - // Simulate the resource initially being in a different phase and then reaching the desired phase - commandExecutor.enqueueOutput(new CommandExecutor.Output('', 'Pending', 0)) - commandExecutor.enqueueOutput(new CommandExecutor.Output('', 'Running', 0)) - - // Attempt to wait for the resource to reach the desired phase - k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running') - - // Assert that the correct kubectl get command was issued and that the method returned successfully - assertThat(commandExecutor.actualCommands).hasSize(2) - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - 'kubectl get pod my-pod -n my-namespace -o jsonpath={.status.phase}') - } - - @Test - void 'Throws IllegalArgumentException when resourceType is null'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.waitForResourcePhase(null, 'my-pod', 'my-namespace', 'Running') - } - - assertThat(exception.message).isEqualTo("Resource type, name, namespace, and desired phase must be provided") - } - - @Test - void 'Throws IllegalArgumentException when resourceName is null'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.waitForResourcePhase('pod', null, 'my-namespace', 'Running') - } - - assertThat(exception.message).isEqualTo("Resource type, name, namespace, and desired phase must be provided") - } - - @Test - void 'waitForResourcePhase Throws IllegalArgumentException when namespace is null'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.waitForResourcePhase('pod', 'my-pod', null, 'Running') - } - - assertThat(exception.message).isEqualTo("Resource type, name, namespace, and desired phase must be provided") - } - - @Test - void 'Waits for node port of a service'() { - commandExecutor.enqueueOutput(new CommandExecutor.Output('', '42', 0)) + commandExecutor.enqueueOutput(new CommandExecutor.Output('', serviceJson, 0)) - def nodePort = k8sClient.waitForNodePort('my-service', 'my-namespace') + def exception = shouldFail(RuntimeException) { + k8sClient.patchServiceNodePort('my-service', 'my-namespace', 'https', 32000) + } - // Assert the correct command was executed - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - 'kubectl get service my-service -n my-namespace -o jsonpath={.spec.ports[0].nodePort}' - ) + assertThat(exception.message).isEqualTo("Port with name https not found in service my-service.") + } - // Assert the returned node port is correct - assertThat(nodePort).isEqualTo('42') - } - - @Test - void 'Throws IllegalArgumentException when desiredPhase is null'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', null) - } - - assertThat(exception.message).isEqualTo("Resource type, name, namespace, and desired phase must be provided") - } - - @Test - void 'Throws IllegalArgumentException when timeoutSeconds is less than or equal to zero'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running', 0, 1) - } - - assertThat(exception.message).isEqualTo("Timeout and check interval must be greater than zero") - } - - @Test - void 'Throws IllegalArgumentException when checkIntervalSeconds is less than or equal to zero'() { - def exception = shouldFail(IllegalArgumentException) { - k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running', 60, 0) - } - - assertThat(exception.message).isEqualTo("Timeout and check interval must be greater than zero") - } - - @Test - void 'Throws RuntimeException when resource does not reach the desired phase within timeout'() { - // Simulate the resource not reaching the desired phase within the timeout period - commandExecutor.enqueueOutput(new CommandExecutor.Output('Pending', '', 0)) - commandExecutor.enqueueOutput(new CommandExecutor.Output('Pending', '', 0)) - - // Attempt to wait for the resource to reach the desired phase - def exception = shouldFail(RuntimeException) { - k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running', 2, 1) + @Test + void 'Throws RuntimeException when kubectl patch command fails on Service NodePort'() { + // Simulate the output of the kubectl get service command + def serviceJson = ''' + { + "spec": { + "ports": [ + {"name": "http", "nodePort": 30000} + ] } + }''' + commandExecutor.enqueueOutput(new CommandExecutor.Output('', serviceJson, 0)) + + // Simulate a failure in the kubectl patch command + commandExecutor.enqueueOutput(new CommandExecutor.Output('Error from server (Forbidden): services "my-service" is forbidden', '', 1)) + + def exception = shouldFail(RuntimeException) { + k8sClient.patchServiceNodePort('my-service', 'my-namespace', 'http', 32000) + } + + assertThat(exception.message).contains("Executing command failed: kubectl patch service my-service -n my-namespace --type json -p [{\"op\":\"replace\",\"path\":\"/spec/ports/0/nodePort\",\"value\":32000}]") + } + + @Test + void 'Waits successfully until the resource reaches the desired phase'() { + // Simulate the resource initially being in a different phase and then reaching the desired phase + commandExecutor.enqueueOutput(new CommandExecutor.Output('', 'Pending', 0)) + commandExecutor.enqueueOutput(new CommandExecutor.Output('', 'Running', 0)) + + // Attempt to wait for the resource to reach the desired phase + k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running') + + // Assert that the correct kubectl get command was issued and that the method returned successfully + assertThat(commandExecutor.actualCommands).hasSize(2) + assertThat(commandExecutor.actualCommands[0]).isEqualTo('kubectl get pod my-pod -n my-namespace -o jsonpath={.status.phase}') + } + + @Test + void 'Throws IllegalArgumentException when resourceType is null'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.waitForResourcePhase(null, 'my-pod', 'my-namespace', 'Running') + } + + assertThat(exception.message).isEqualTo("Resource type, name, namespace, and desired phase must be provided") + } - // Assert that the correct exception message is returned - assertThat(exception.message).contains("Timeout reached. Resource pod/my-pod in namespace my-namespace did not reach the desired phase: Running within 2 seconds.") - } - - @Test - void 'Handles immediate success without retrying'() { - // Simulate the resource already being in the desired phase - commandExecutor.enqueueOutput(new CommandExecutor.Output('', 'Running', 0)) - - // Attempt to wait for the resource to reach the desired phase - k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running') - - // Assert that the command was executed only once and no retries occurred - assertThat(commandExecutor.actualCommands).hasSize(1) - assertThat(commandExecutor.actualCommands[0]).isEqualTo( - 'kubectl get pod my-pod -n my-namespace -o jsonpath={.status.phase}') - } - - @Test - void 'Runs a pod '() { - k8sClient.run('my-pod', 'alpine') - - assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl run my-pod --image alpine") - } - - @Test - void 'Runs a pod with params'() { - k8sClient.run('my-pod', 'alpine', 'my-ns', '--rm') - - assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl run my-pod --image alpine -n my-ns --rm") - } - - @Test - void 'Runs a pod with overrides'() { - def overrides = [ - spec: [ - containers: [ - [ - name : "tmp-docker-gid-grepper", - image: "bash:5", - ] - ] - ] - ] - k8sClient.run('my-pod', 'alpine', 'my-ns', overrides, '--restart=Never', '-ti', '--rm', '--quiet') - - assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl run my-pod --image alpine -n my-ns --restart=Never -ti --rm --quiet") - assertThat(commandExecutor.actualCommands[0]).contains('{"spec":{"containers":[{"name":"tmp-docker-gid-grepper","image":"bash:5"}]}}'.toString().trim()) - } - - @Test - void 'fetch some data from monitoring namespace'() { - // prepare test outout - commandExecutor.enqueueOutput(new CommandExecutor.Output('', '{"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"}', 0)) - // call k8s - def result = k8sClient.getAnnotation('namespace', 'monitoring', 'openshift.io/sa.scc.uid-range') - assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl get namespace monitoring -o jsonpath={.metadata.annotations}") - assertThat(result).isEqualTo("1000920000/10000"); - } - - - private Map parseActualYaml(String pathToYamlFile) { - File yamlFile = new File(pathToYamlFile) - def ys = new YamlSlurper() - return ys.parse(yamlFile) as Map - } + @Test + void 'Throws IllegalArgumentException when resourceName is null'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.waitForResourcePhase('pod', null, 'my-namespace', 'Running') + } + + assertThat(exception.message).isEqualTo("Resource type, name, namespace, and desired phase must be provided") + } + + @Test + void 'waitForResourcePhase Throws IllegalArgumentException when namespace is null'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.waitForResourcePhase('pod', 'my-pod', null, 'Running') + } + + assertThat(exception.message).isEqualTo("Resource type, name, namespace, and desired phase must be provided") + } + + @Test + void 'Waits for node port of a service'() { + commandExecutor.enqueueOutput(new CommandExecutor.Output('', '42', 0)) + + def nodePort = k8sClient.waitForNodePort('my-service', 'my-namespace') + + // Assert the correct command was executed + assertThat(commandExecutor.actualCommands[0]).isEqualTo('kubectl get service my-service -n my-namespace -o jsonpath={.spec.ports[0].nodePort}') + + // Assert the returned node port is correct + assertThat(nodePort).isEqualTo('42') + } + + @Test + void 'Throws IllegalArgumentException when desiredPhase is null'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', null) + } + + assertThat(exception.message).isEqualTo("Resource type, name, namespace, and desired phase must be provided") + } + + @Test + void 'Throws IllegalArgumentException when timeoutSeconds is less than or equal to zero'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running', 0, 1) + } + + assertThat(exception.message).isEqualTo("Timeout and check interval must be greater than zero") + } + + @Test + void 'Throws IllegalArgumentException when checkIntervalSeconds is less than or equal to zero'() { + def exception = shouldFail(IllegalArgumentException) { + k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running', 60, 0) + } + + assertThat(exception.message).isEqualTo("Timeout and check interval must be greater than zero") + } + + @Test + void 'Throws RuntimeException when resource does not reach the desired phase within timeout'() { + // Simulate the resource not reaching the desired phase within the timeout period + commandExecutor.enqueueOutput(new CommandExecutor.Output('Pending', '', 0)) + commandExecutor.enqueueOutput(new CommandExecutor.Output('Pending', '', 0)) + + // Attempt to wait for the resource to reach the desired phase + def exception = shouldFail(RuntimeException) { + k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running', 2, 1) + } + + // Assert that the correct exception message is returned + assertThat(exception.message).contains("Timeout reached. Resource pod/my-pod in namespace my-namespace did not reach the desired phase: Running within 2 seconds.") + } + + @Test + void 'Handles immediate success without retrying'() { + // Simulate the resource already being in the desired phase + commandExecutor.enqueueOutput(new CommandExecutor.Output('', 'Running', 0)) + + // Attempt to wait for the resource to reach the desired phase + k8sClient.waitForResourcePhase('pod', 'my-pod', 'my-namespace', 'Running') + + // Assert that the command was executed only once and no retries occurred + assertThat(commandExecutor.actualCommands).hasSize(1) + assertThat(commandExecutor.actualCommands[0]).isEqualTo('kubectl get pod my-pod -n my-namespace -o jsonpath={.status.phase}') + } + + @Test + void 'Runs a pod '() { + k8sClient.run('my-pod', 'alpine') + + assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl run my-pod --image alpine") + } + + @Test + void 'Runs a pod with params'() { + k8sClient.run('my-pod', 'alpine', 'my-ns', '--rm') + + assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl run my-pod --image alpine -n my-ns --rm") + } + + @Test + void 'Runs a pod with overrides'() { + def overrides = [spec: [containers: [[name : "tmp-docker-gid-grepper", + image: "bash:5",]]]] + k8sClient.run('my-pod', 'alpine', 'my-ns', overrides, '--restart=Never', '-ti', '--rm', '--quiet') + + assertThat(commandExecutor.actualCommands[0]).startsWith("kubectl run my-pod --image alpine -n my-ns --restart=Never -ti --rm --quiet") + assertThat(commandExecutor.actualCommands[0]).contains('{"spec":{"containers":[{"name":"tmp-docker-gid-grepper","image":"bash:5"}]}}'.toString().trim()) + } + + @Test + void 'fetch some data from monitoring namespace'() { + // prepare test outout + commandExecutor.enqueueOutput(new CommandExecutor.Output('', '{"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"}', 0)) + // call k8s + def result = k8sClient.getAnnotation('namespace', 'monitoring', 'openshift.io/sa.scc.uid-range') + assertThat(commandExecutor.actualCommands[0]).isEqualTo("kubectl get namespace monitoring -o jsonpath={.metadata.annotations}") + assertThat(result).isEqualTo("1000920000/10000"); + } + + 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/utils/NetworkingUtilsTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/NetworkingUtilsTest.groovy index 9edac11f0..b69d721ea 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/NetworkingUtilsTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/NetworkingUtilsTest.groovy @@ -1,93 +1,94 @@ package com.cloudogu.gitops.utils -import com.cloudogu.gitops.config.Config -import org.junit.jupiter.api.Test - import static groovy.test.GroovyAssert.shouldFail import static org.assertj.core.api.Assertions.assertThat +import com.cloudogu.gitops.config.Config + +import org.junit.jupiter.api.Test + class NetworkingUtilsTest { - Config config = new Config(application: new Config.ApplicationSchema(namePrefix: "foo-")) - - K8sClientForTest k8sClient = new K8sClientForTest(config) - CommandExecutorForTest commandExecutor = new CommandExecutorForTest() - NetworkingUtils networkingUtils = new NetworkingUtils(k8sClient, commandExecutor) - - @Test - void 'clusterBindAddress: returns bind address for external cluster'() { - def internalNodeIp = "1.2.3.4" - def localIp = "5.6.7.8" - // waitForInternalNodeIp -> waitForNode() - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/something', 0)) - // waitForInternalNodeIp -> actual exec - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', internalNodeIp, 0)) - commandExecutor.enqueueOutput(new CommandExecutor.Output('', - "1.0.0.0 via w.x.y.z dev someDevice src ${localIp} uid 1000", 0)) - - def actualBindAddress = networkingUtils.findClusterBindAddress() - - assertThat(actualBindAddress).isEqualTo(internalNodeIp) - } - - @Test - void 'clusterBindAddress: returns localhost when node IP and local IP are equal'() { - def internalNodeIp = networkingUtils.localAddress - assertThat(internalNodeIp).isNotEmpty() - - // waitForInternalNodeIp -> waitForNode(), don't care - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/something', 0)) - // waitForInternalNodeIp -> actual exec - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', internalNodeIp, 0)) - - def actualBindAddress = networkingUtils.findClusterBindAddress() - - assertThat(actualBindAddress).isEqualTo('localhost') - } - - @Test - void 'clusterBindAddress: fails when no potential bind address'() { - def internalNodeIp = '' - // waitForInternalNodeIp -> waitForNode() - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/something', 0)) - // waitForInternalNodeIp -> actual exec - k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', internalNodeIp, 0)) - commandExecutor.enqueueOutput(new CommandExecutor.Output('', - "1.0.0.0 via w.x.y.z dev someDevice src 1.2.3.4 uid 1000", 0)) - - def exception = shouldFail(RuntimeException) { - networkingUtils.findClusterBindAddress() - } - assertThat(exception.message).isEqualTo('Failed to retrieve internal node IP') - } - - @Test - void 'get hosts'() { - assertThat(NetworkingUtils.getHost("https://example.com")).isEqualTo("example.com") - assertThat(NetworkingUtils.getHost("http://example.com")).isEqualTo("example.com") - assertThat(NetworkingUtils.getHost("")).isEqualTo("") - assertThat(NetworkingUtils.getHost("example.com")).isEqualTo("example.com") - - // Legacy! The function is misleading. - //assertThat(NetworkingUtils.getHost("http://example.com/bla")).isEqualTo("example.com") - //assertThat(NetworkingUtils.getHost("http://example.com:9090/bla")).isEqualTo("example.com") - //assertThat(NetworkingUtils.getHost("example.com/bla")).isEqualTo("example.com") - //assertThat(NetworkingUtils.getHost("example.com:9090/bla")).isEqualTo("example.com") - assertThat(NetworkingUtils.getHost("http://example.com/bla")).isEqualTo("example.com/bla") - assertThat(NetworkingUtils.getHost("http://example.com:9090/bla")).isEqualTo("example.com:9090/bla") - assertThat(NetworkingUtils.getHost("example.com/bla")).isEqualTo("example.com/bla") - assertThat(NetworkingUtils.getHost("example.com:9090/bla")).isEqualTo("example.com:9090/bla") - - // More legacy, known bugs. We should get rid of this method and scmm.host and scmm.protocol altogether! - // assertThat(NetworkingUtils.getHost("ftp://example.com")).isEqualTo("example.com") - } - - @Test - void 'get protocols'() { - assertThat(NetworkingUtils.getProtocol("https://example.com")).isEqualTo("https"); - assertThat(NetworkingUtils.getProtocol("http://example.com")).isEqualTo("http"); - assertThat(NetworkingUtils.getProtocol("ftp://example.com")).isEqualTo(""); - assertThat(NetworkingUtils.getProtocol("example.com")).isEqualTo(""); - assertThat(NetworkingUtils.getProtocol("")).isEqualTo("") - } -} + Config config = new Config(application: new Config.ApplicationSchema(namePrefix: "foo-")) + + K8sClientForTest k8sClient = new K8sClientForTest(config) + CommandExecutorForTest commandExecutor = new CommandExecutorForTest() + NetworkingUtils networkingUtils = new NetworkingUtils(k8sClient, commandExecutor) + + @Test + void 'clusterBindAddress: returns bind address for external cluster'() { + def internalNodeIp = "1.2.3.4" + def localIp = "5.6.7.8" + // waitForInternalNodeIp -> waitForNode() + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/something', 0)) + // waitForInternalNodeIp -> actual exec + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', internalNodeIp, 0)) + commandExecutor.enqueueOutput(new CommandExecutor.Output('', + "1.0.0.0 via w.x.y.z dev someDevice src ${localIp} uid 1000", 0)) + + def actualBindAddress = networkingUtils.findClusterBindAddress() + + assertThat(actualBindAddress).isEqualTo(internalNodeIp) + } + + @Test + void 'clusterBindAddress: returns localhost when node IP and local IP are equal'() { + def internalNodeIp = networkingUtils.localAddress + assertThat(internalNodeIp).isNotEmpty() + + // waitForInternalNodeIp -> waitForNode(), don't care + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/something', 0)) + // waitForInternalNodeIp -> actual exec + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', internalNodeIp, 0)) + + def actualBindAddress = networkingUtils.findClusterBindAddress() + + assertThat(actualBindAddress).isEqualTo('localhost') + } + + @Test + void 'clusterBindAddress: fails when no potential bind address'() { + def internalNodeIp = '' + // waitForInternalNodeIp -> waitForNode() + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', 'node/something', 0)) + // waitForInternalNodeIp -> actual exec + k8sClient.commandExecutorForTest.enqueueOutput(new CommandExecutor.Output('', internalNodeIp, 0)) + commandExecutor.enqueueOutput(new CommandExecutor.Output('', + "1.0.0.0 via w.x.y.z dev someDevice src 1.2.3.4 uid 1000", 0)) + + def exception = shouldFail(RuntimeException) { + networkingUtils.findClusterBindAddress() + } + assertThat(exception.message).isEqualTo('Failed to retrieve internal node IP') + } + + @Test + void 'get hosts'() { + assertThat(NetworkingUtils.getHost("https://example.com")).isEqualTo("example.com") + assertThat(NetworkingUtils.getHost("http://example.com")).isEqualTo("example.com") + assertThat(NetworkingUtils.getHost("")).isEqualTo("") + assertThat(NetworkingUtils.getHost("example.com")).isEqualTo("example.com") + + // Legacy! The function is misleading. + //assertThat(NetworkingUtils.getHost("http://example.com/bla")).isEqualTo("example.com") + //assertThat(NetworkingUtils.getHost("http://example.com:9090/bla")).isEqualTo("example.com") + //assertThat(NetworkingUtils.getHost("example.com/bla")).isEqualTo("example.com") + //assertThat(NetworkingUtils.getHost("example.com:9090/bla")).isEqualTo("example.com") + assertThat(NetworkingUtils.getHost("http://example.com/bla")).isEqualTo("example.com/bla") + assertThat(NetworkingUtils.getHost("http://example.com:9090/bla")).isEqualTo("example.com:9090/bla") + assertThat(NetworkingUtils.getHost("example.com/bla")).isEqualTo("example.com/bla") + assertThat(NetworkingUtils.getHost("example.com:9090/bla")).isEqualTo("example.com:9090/bla") + + // More legacy, known bugs. We should get rid of this method and scmm.host and scmm.protocol altogether! + // assertThat(NetworkingUtils.getHost("ftp://example.com")).isEqualTo("example.com") + } + + @Test + void 'get protocols'() { + assertThat(NetworkingUtils.getProtocol("https://example.com")).isEqualTo("https"); + assertThat(NetworkingUtils.getProtocol("http://example.com")).isEqualTo("http"); + assertThat(NetworkingUtils.getProtocol("ftp://example.com")).isEqualTo(""); + assertThat(NetworkingUtils.getProtocol("example.com")).isEqualTo(""); + assertThat(NetworkingUtils.getProtocol("")).isEqualTo("") + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/TemplatingEngineTest.groovy b/src/test/groovy/com/cloudogu/gitops/utils/TemplatingEngineTest.groovy index e381d25e2..ac5256c61 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/TemplatingEngineTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/TemplatingEngineTest.groovy @@ -1,25 +1,24 @@ package com.cloudogu.gitops.utils +import static org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import static org.assertj.core.api.Assertions.assertThat - class TemplatingEngineTest { - File tmpDir + File tmpDir - @BeforeEach - void before() { - tmpDir = File.createTempDir('gitops-playground-tests-templatingengine') - tmpDir.deleteOnExit() - } + @BeforeEach + void before() { + tmpDir = File.createTempDir('gitops-playground-tests-templatingengine') + tmpDir.deleteOnExit() + } - @Test - void 'replaces two templates in different folders'() { - def fooTemplate = new File(tmpDir.absolutePath, "foo.ftl.txt") - fooTemplate.text = """ + @Test + void 'replaces two templates in different folders'() { + def fooTemplate = new File(tmpDir.absolutePath, "foo.ftl.txt") + fooTemplate.text = """ this is the template I can embed \${string} <#if display> @@ -29,83 +28,75 @@ class TemplatingEngineTest { """ - def tmpDir2 = File.createTempDir('gitops-playground-tests-templatingengine') - tmpDir2.deleteOnExit() - def barTemplate = new File(tmpDir2.absolutePath, "bar.ftl.txt") - barTemplate.text = "Hello \${name}" - - def engine = new TemplatingEngine() - engine.replaceTemplate(barTemplate, [ - name: "Playground", - ]) - - assertThat(new File(tmpDir2.absolutePath, "bar.txt").text).isEqualTo("Hello Playground") - assertThat(barTemplate).doesNotExist() - } - - @Test - void 'keeps template file'() { - def barTemplate = new File(tmpDir.absolutePath, "bar.ftl.txt") - def barTarget = new File(tmpDir.absolutePath, "bar.txt") - barTemplate.text = "Hello \${name}" - - def engine = new TemplatingEngine() - engine.template(barTemplate, barTarget, [ - name: "Playground", - ]) - - assertThat(barTarget.text).isEqualTo("Hello Playground") - assertThat(barTemplate).exists() - } - - @Test - void 'Templates from file to string'() { - def fooTemplate = new File(tmpDir.absolutePath, "foo.ftl.txt") - fooTemplate.text = "Hello \${name}" - - def engine = new TemplatingEngine() - String result = engine.template(fooTemplate, [ - name: "Playground", - ]) - - assertThat(result).isEqualTo("Hello Playground") - } - - @Test - void 'Templates from string to string'() { - def fooTemplate = "Hello \${name}" - - def engine = new TemplatingEngine() - String result = engine.template(fooTemplate, [ - name: "Playground", - ]) - - assertThat(result).isEqualTo("Hello Playground") - } - - @Test - void 'Ignores templates without variables'() { - def fooTemplate = "Hello name" - - def engine = new TemplatingEngine() - String result = engine.template(fooTemplate, [:]) - - assertThat(result).isEqualTo("Hello name") - } - - @Test - void "replaces yaml templates"() { - def barTemplate = new File(tmpDir.absolutePath + File.separator + "subdirectory", "result.ftl.yaml") - barTemplate.getParentFile().mkdirs() - barTemplate.text = 'foo: ${prefix}suffix' - def barTarget = new File(tmpDir.absolutePath, "subdirectory/keep-this-way.yaml") - barTarget.text = 'thiswont: ${prefix}-be-replaced' - - def engine = new TemplatingEngine() - engine.replaceTemplates(tmpDir, [prefix: "myteam-"]) - - assertThat(new File("$tmpDir/subdirectory/result.yaml").text).isEqualTo("foo: myteam-suffix") - assertThat(new File("$tmpDir/subdirectory/keep-this-way.yaml").text).isEqualTo('thiswont: ${prefix}-be-replaced') - assertThat(new File("$tmpDir/subdirectory/result.ftl.yaml").exists()).isFalse() - } -} + def tmpDir2 = File.createTempDir('gitops-playground-tests-templatingengine') + tmpDir2.deleteOnExit() + def barTemplate = new File(tmpDir2.absolutePath, "bar.ftl.txt") + barTemplate.text = "Hello \${name}" + + def engine = new TemplatingEngine() + engine.replaceTemplate(barTemplate, [name: "Playground",]) + + assertThat(new File(tmpDir2.absolutePath, "bar.txt").text).isEqualTo("Hello Playground") + assertThat(barTemplate).doesNotExist() + } + + @Test + void 'keeps template file'() { + def barTemplate = new File(tmpDir.absolutePath, "bar.ftl.txt") + def barTarget = new File(tmpDir.absolutePath, "bar.txt") + barTemplate.text = "Hello \${name}" + + def engine = new TemplatingEngine() + engine.template(barTemplate, barTarget, [name: "Playground",]) + + assertThat(barTarget.text).isEqualTo("Hello Playground") + assertThat(barTemplate).exists() + } + + @Test + void 'Templates from file to string'() { + def fooTemplate = new File(tmpDir.absolutePath, "foo.ftl.txt") + fooTemplate.text = "Hello \${name}" + + def engine = new TemplatingEngine() + String result = engine.template(fooTemplate, [name: "Playground",]) + + assertThat(result).isEqualTo("Hello Playground") + } + + @Test + void 'Templates from string to string'() { + def fooTemplate = "Hello \${name}" + + def engine = new TemplatingEngine() + String result = engine.template(fooTemplate, [name: "Playground",]) + + assertThat(result).isEqualTo("Hello Playground") + } + + @Test + void 'Ignores templates without variables'() { + def fooTemplate = "Hello name" + + def engine = new TemplatingEngine() + String result = engine.template(fooTemplate, [:]) + + assertThat(result).isEqualTo("Hello name") + } + + @Test + void "replaces yaml templates"() { + def barTemplate = new File(tmpDir.absolutePath + File.separator + "subdirectory", "result.ftl.yaml") + barTemplate.getParentFile().mkdirs() + barTemplate.text = 'foo: ${prefix}suffix' + def barTarget = new File(tmpDir.absolutePath, "subdirectory/keep-this-way.yaml") + barTarget.text = 'thiswont: ${prefix}-be-replaced' + + def engine = new TemplatingEngine() + engine.replaceTemplates(tmpDir, [prefix: "myteam-"]) + + assertThat(new File("$tmpDir/subdirectory/result.yaml").text).isEqualTo("foo: myteam-suffix") + assertThat(new File("$tmpDir/subdirectory/keep-this-way.yaml").text).isEqualTo('thiswont: ${prefix}-be-replaced') + assertThat(new File("$tmpDir/subdirectory/result.ftl.yaml").exists()).isFalse() + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/TestLogger.groovy b/src/test/groovy/com/cloudogu/gitops/utils/TestLogger.groovy index aa355a550..2da720ff9 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/TestLogger.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/TestLogger.groovy @@ -1,5 +1,7 @@ package com.cloudogu.gitops.utils +import java.util.stream.Collectors + import ch.qos.logback.classic.Level import ch.qos.logback.classic.Logger import ch.qos.logback.classic.LoggerContext @@ -7,69 +9,65 @@ import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.core.read.ListAppender import org.slf4j.LoggerFactory -import java.util.stream.Collectors - class TestLogger { - private Class loggerInClass - private MemoryAppender memoryAppender + private Class loggerInClass + private MemoryAppender memoryAppender - TestLogger(Class clazz, Level loglevel = Level.DEBUG) { - this.loggerInClass = clazz - Logger logger = (Logger) LoggerFactory.getLogger(loggerInClass) - memoryAppender = new MemoryAppender() - memoryAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()) - logger.setLevel(loglevel) - logger.addAppender(memoryAppender) - memoryAppender.start() - } + TestLogger(Class clazz, Level loglevel = Level.DEBUG) { + this.loggerInClass = clazz + Logger logger = (Logger) LoggerFactory.getLogger(loggerInClass) + memoryAppender = new MemoryAppender() + memoryAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()) + logger.setLevel(loglevel) + logger.addAppender(memoryAppender) + memoryAppender.start() + } - void changeLogLevel(Level loglevel) { - Logger logger = (Logger) LoggerFactory.getLogger(loggerInClass) - logger.setLevel(loglevel) - } + void changeLogLevel(Level loglevel) { + Logger logger = (Logger) LoggerFactory.getLogger(loggerInClass) + logger.setLevel(loglevel) + } - MemoryAppender getLogs() { - return memoryAppender - } + MemoryAppender getLogs() { + return memoryAppender + } } class MemoryAppender extends ListAppender { - void reset() { - list.clear(); - } + void reset() { + list.clear(); + } - boolean contains(String string, Level level) { - return list.stream() - .anyMatch(event -> event.toString().contains(string) - && event.getLevel().equals(level)); - } + boolean contains(String string, Level level) { + return list.stream() + .anyMatch(event -> event.toString().contains(string) && event.getLevel().equals(level)); + } - int countEventsForLogger(String loggerName) { - return (int) list.stream() - .filter(event -> event.getLoggerName().contains(loggerName)) - .count(); - } + int countEventsForLogger(String loggerName) { + return (int) list.stream() + .filter(event -> event.getLoggerName().contains(loggerName)) + .count(); + } - List search(String string) { - return list.stream() - .filter(event -> event.toString().contains(string)) - .collect(Collectors.toList()) as List; - } + List search(String string) { + return list.stream() + .filter(event -> event.toString().contains(string)) + .collect(Collectors.toList()) as List; + } - List search(String string, Level level) { - return list.stream() - .filter(event -> event.toString().contains(string) - && event.getLevel().equals(level)) - .collect(Collectors.toList()) as List; - } + List search(String string, Level level) { + return list.stream() + .filter(event -> event.toString().contains(string) && event.getLevel().equals(level)) + .collect(Collectors.toList()) as List; + } - int getSize() { - return list.size(); - } + int getSize() { + return list.size(); + } - List getLoggedEvents() { - return Collections.unmodifiableList(list); - } -} + List getLoggedEvents() { + return Collections.unmodifiableList(list); + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/git/GitHandlerForTests.groovy b/src/test/groovy/com/cloudogu/gitops/utils/git/GitHandlerForTests.groovy index 75712f729..2b7bd5a28 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/git/GitHandlerForTests.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/git/GitHandlerForTests.groovy @@ -1,5 +1,7 @@ package com.cloudogu.gitops.utils.git +import static org.mockito.Mockito.mock + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.features.deployment.HelmStrategy import com.cloudogu.gitops.features.git.GitHandler @@ -8,50 +10,48 @@ import com.cloudogu.gitops.utils.FileSystemUtils import com.cloudogu.gitops.utils.K8sClientForTest import com.cloudogu.gitops.utils.NetworkingUtils -import static org.mockito.Mockito.mock - class GitHandlerForTests extends GitHandler { - private final GitProvider tenantProvider - private final GitProvider centralProvider - - GitHandlerForTests(Config config, GitProvider tenantProvider, GitProvider centralProvider = null) { - super(config, mock(HelmStrategy), new FileSystemUtils(), new K8sClientForTest(config), new NetworkingUtils()) - this.tenantProvider = tenantProvider - this.centralProvider = centralProvider - } - - @Override - void enable() { - // Inject the test providers into the base class before running the real logic - this.tenant = tenantProvider - this.central = centralProvider - - // Mirror the production side effect: set namespace for internal SCMM - if (this.config?.scm?.scmManager != null) { - this.config.scm.scmManager.namespace = "${config.application.namePrefix}scm-manager".toString() - } - - // === Run ONLY the repo setup logic (NO provider construction here) === - final String namePrefix = (config?.application?.namePrefix ?: "").trim() - if (this.central) { - setupRepos(this.central, namePrefix) - setupRepos(this.tenant, namePrefix) - } else { - setupRepos(this.tenant, namePrefix) - } - - } - - @Override - void validate() {} - - @Override - GitProvider getTenant() { return tenantProvider } - - @Override - GitProvider getCentral() { return centralProvider } - - @Override - GitProvider getResourcesScm() { return centralProvider ?: tenantProvider } + private final GitProvider tenantProvider + private final GitProvider centralProvider + + GitHandlerForTests(Config config, GitProvider tenantProvider, GitProvider centralProvider = null) { + super(config, mock(HelmStrategy), new FileSystemUtils(), new K8sClientForTest(config), new NetworkingUtils()) + this.tenantProvider = tenantProvider + this.centralProvider = centralProvider + } + + @Override + void enable() { + // Inject the test providers into the base class before running the real logic + this.tenant = tenantProvider + this.central = centralProvider + + // Mirror the production side effect: set namespace for internal SCMM + if (this.config?.scm?.scmManager != null) { + this.config.scm.scmManager.namespace = "${config.application.namePrefix}scm-manager".toString() + } + + // === Run ONLY the repo setup logic (NO provider construction here) === + final String namePrefix = (config?.application?.namePrefix ?: "").trim() + if (this.central) { + setupRepos(this.central, namePrefix) + setupRepos(this.tenant, namePrefix) + } else { + setupRepos(this.tenant, namePrefix) + } + + } + + @Override + void validate() {} + + @Override + GitProvider getTenant() { return tenantProvider } + + @Override + GitProvider getCentral() { return centralProvider } + + @Override + GitProvider getResourcesScm() { return centralProvider ?: tenantProvider } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/git/GitlabMock.groovy b/src/test/groovy/com/cloudogu/gitops/utils/git/GitlabMock.groovy index dcec9d857..9c5c5d8a9 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/git/GitlabMock.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/git/GitlabMock.groovy @@ -7,51 +7,68 @@ import com.cloudogu.gitops.git.providers.RepoUrlScope import com.cloudogu.gitops.git.providers.Scope class GitlabMock implements GitProvider { - URI base = new URI("https://example.com/group") // from config.scm.gitlab.url - String namePrefix = "" // prefix if you use tenant mode - - final List createdRepos = [] - final List permissionCalls = [] - - @Override - boolean createRepository(String repoTarget, String description, boolean initialize) { - createdRepos << repoTarget - return true - } - - @Override - boolean createRepository(String repoTarget, String description) { - return createRepository(repoTarget, description, true) - } - - @Override - void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) { - permissionCalls << [repoTarget: repoTarget, principal: principal, role: role, scope: scope] - } - - @Override - String repoUrl(String repoTarget, RepoUrlScope scope) { - def cleaned = base.toString().replaceAll('/+$','') - return "${cleaned}/${repoTarget}.git" - } - - - @Override - String repoPrefix() { - def cleaned = base.toString().replaceAll('/+$','') - return "${cleaned}/${namePrefix?:''}".toString() - } - - // trivial passthroughs - @Override URI prometheusMetricsEndpoint() { return base } - @Override Credentials getCredentials() { return new Credentials("gitops","gitops") } - @Override void deleteRepository(String n, String r, boolean p) {} - @Override void deleteUser(String name) {} - @Override void setDefaultBranch(String target, String branch) {} - @Override String getUrl() { return base.toString() } - @Override String getProtocol() { return base.scheme } - @Override String getHost() { return base.host } - @Override String getGitOpsUsername() { return "gitops" } - - -} + URI base = new URI("https://example.com/group") + // from config.scm.gitlab.url + String namePrefix = "" + // prefix if you use tenant mode + + final List createdRepos = [] + final List permissionCalls = [] + + @Override + boolean createRepository(String repoTarget, String description, boolean initialize) { + createdRepos << repoTarget + return true + } + + @Override + boolean createRepository(String repoTarget, String description) { + return createRepository(repoTarget, description, true) + } + + @Override + void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) { + permissionCalls << [repoTarget: repoTarget, principal: principal, role: role, scope: scope] + } + + @Override + String repoUrl(String repoTarget, RepoUrlScope scope) { + def cleaned = base.toString().replaceAll('/+$', '') + return "${cleaned}/${repoTarget}.git" + } + + @Override + String repoPrefix() { + def cleaned = base.toString().replaceAll('/+$', '') + return "${cleaned}/${namePrefix ?: ''}".toString() + } + + // trivial passthroughs + @Override + URI prometheusMetricsEndpoint() { return base } + + @Override + Credentials getCredentials() { return new Credentials("gitops", "gitops") } + + @Override + void deleteRepository(String n, String r, boolean p) {} + + @Override + void deleteUser(String name) {} + + @Override + void setDefaultBranch(String target, String branch) {} + + @Override + String getUrl() { return base.toString() } + + @Override + String getProtocol() { return base.scheme } + + @Override + String getHost() { return base.host } + + @Override + String getGitOpsUsername() { return "gitops" } + +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/git/ScmManagerMock.groovy b/src/test/groovy/com/cloudogu/gitops/utils/git/ScmManagerMock.groovy index e0cad3851..0310b6129 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/git/ScmManagerMock.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/git/ScmManagerMock.groovy @@ -6,124 +6,123 @@ import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.git.providers.RepoUrlScope import com.cloudogu.gitops.git.providers.Scope - /** * Lightweight test double for ScmManager/GitProvider. * - Configurable in-cluster and client bases * - Optional namePrefix to model “tenant” behavior - * - Records createRepository / setRepositoryPermission calls for assertions - */ + * - Records createRepository / setRepositoryPermission calls for assertions*/ class ScmManagerMock implements GitProvider { - private final Set initOnceRepos = [] as Set - private final Map createCalls = [:].withDefault{0} - - void initOnceRepo(String fullName) { initOnceRepos << fullName } - void clearInitOnce() { initOnceRepos.clear(); createCalls.clear() } - - - // --- configurable --- - URI inClusterBase = new URI("http://scmm.scm-manager.svc.cluster.local/scm") - URI clientBase = new URI("http://localhost:8080/scm") - String rootPath = "repo" // SCMM rootPath - String namePrefix = "" // e.g., "fv40-" for tenant mode - Credentials credentials = new Credentials("gitops", "gitops") - String gitOpsUsername = "gitops" - URI prometheus = new URI("http://localhost:8080/scm/api/v2/metrics/prometheus") - - // --- call recordings for assertions --- - final List createdRepos = [] - final List permissionCalls = [] - /** Optional sequence to control createRepository() return values per call */ - List nextCreateResults = [] // empty -> default true - - @Override - boolean createRepository(String repoTarget, String description, boolean initialize) { - if (initOnceRepos.contains(repoTarget)) { - return ++createCalls[repoTarget] == 1 // 1. call true, then false - } - createdRepos << repoTarget - // Pretend repository was created successfully. - // If you need idempotency checks, examine createdRepos.count(repoTarget) in your tests. - return nextCreateResults ? nextCreateResults.remove(0) : true - } - - @Override - void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) { - permissionCalls << [ - repoTarget: repoTarget, - principal : principal, - role : role, - scope : scope - ] - } - - /** …/scm/// */ - @Override - String repoUrl(String repoTarget, RepoUrlScope scope) { - URI base = (scope == RepoUrlScope.CLIENT) ? clientBase : inClusterBase - def cleanedBase = withoutTrailingSlash(base).toString() - return "${cleanedBase}/${rootPath}/${repoTarget}" - } - - /** In-cluster repo prefix: …/scm//[] */ - @Override - String repoPrefix() { - def base = withoutTrailingSlash(inClusterBase).toString() - def prefix = (namePrefix ?: "").strip() - return "${base}/${rootPath}/${prefix}" - } - - @Override - Credentials getCredentials() { - return credentials - } - - - /** …/scm/api/v2/metrics/prometheus */ - @Override - URI prometheusMetricsEndpoint() { - return prometheus - } - - @Override - void deleteRepository(String namespace, String repository, boolean prefixNamespace) { - - } - - @Override - void deleteUser(String name) { - - } - - @Override - void setDefaultBranch(String repoTarget, String branch) { - - } - - /** In-cluster base …/scm (without trailing slash) */ - @Override - String getUrl() { - return inClusterBase.toString() - } - - @Override - String getProtocol() { - return inClusterBase.scheme // e.g., "http" - } - - @Override - String getHost() { - return inClusterBase.host // e.g., "scmm.ns.svc.cluster.local" - } - - @Override - String getGitOpsUsername() { - return gitOpsUsername - } - // --- helpers --- - private static URI withoutTrailingSlash(URI uri) { - def s = uri.toString() - return new URI(s.endsWith("/") ? s.substring(0, s.length() - 1) : s) - } + private final Set initOnceRepos = [] as Set + private final Map createCalls = [:].withDefault { 0 } + + void initOnceRepo(String fullName) { initOnceRepos << fullName } + + void clearInitOnce() { initOnceRepos.clear(); createCalls.clear() } + + // --- configurable --- + URI inClusterBase = new URI("http://scmm.scm-manager.svc.cluster.local/scm") + URI clientBase = new URI("http://localhost:8080/scm") + String rootPath = "repo" + // SCMM rootPath + String namePrefix = "" + // e.g., "fv40-" for tenant mode + Credentials credentials = new Credentials("gitops", "gitops") + String gitOpsUsername = "gitops" + URI prometheus = new URI("http://localhost:8080/scm/api/v2/metrics/prometheus") + + // --- call recordings for assertions --- + final List createdRepos = [] + final List permissionCalls = [] + /** Optional sequence to control createRepository() return values per call */ + List nextCreateResults = [] + // empty -> default true + + @Override + boolean createRepository(String repoTarget, String description, boolean initialize) { + if (initOnceRepos.contains(repoTarget)) { + return ++createCalls[repoTarget] == 1 // 1. call true, then false + } + createdRepos << repoTarget + // Pretend repository was created successfully. + // If you need idempotency checks, examine createdRepos.count(repoTarget) in your tests. + return nextCreateResults ? nextCreateResults.remove(0) : true + } + + @Override + void setRepositoryPermission(String repoTarget, String principal, AccessRole role, Scope scope) { + permissionCalls << [repoTarget: repoTarget, + principal : principal, + role : role, + scope : scope] + } + + /** …/scm/// */ + @Override + String repoUrl(String repoTarget, RepoUrlScope scope) { + URI base = (scope == RepoUrlScope.CLIENT) ? clientBase : inClusterBase + def cleanedBase = withoutTrailingSlash(base).toString() + return "${cleanedBase}/${rootPath}/${repoTarget}" + } + + /** In-cluster repo prefix: …/scm//[] */ + @Override + String repoPrefix() { + def base = withoutTrailingSlash(inClusterBase).toString() + def prefix = (namePrefix ?: "").strip() + return "${base}/${rootPath}/${prefix}" + } + + @Override + Credentials getCredentials() { + return credentials + } + + /** …/scm/api/v2/metrics/prometheus */ + @Override + URI prometheusMetricsEndpoint() { + return prometheus + } + + @Override + void deleteRepository(String namespace, String repository, boolean prefixNamespace) { + + } + + @Override + void deleteUser(String name) { + + } + + @Override + void setDefaultBranch(String repoTarget, String branch) { + + } + + /** In-cluster base …/scm (without trailing slash) */ + @Override + String getUrl() { + return inClusterBase.toString() + } + + @Override + String getProtocol() { + return inClusterBase.scheme // e.g., "http" + } + + @Override + String getHost() { + return inClusterBase.host // e.g., "scmm.ns.svc.cluster.local" + } + + @Override + String getGitOpsUsername() { + return gitOpsUsername + } + + // --- helpers --- + private static URI withoutTrailingSlash(URI uri) { + def s = uri.toString() + return new URI(s.endsWith("/") ? s.substring(0, s.length() - 1) : s) + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/git/TestGitProvider.groovy b/src/test/groovy/com/cloudogu/gitops/utils/git/TestGitProvider.groovy index 615f9bcd3..2dfa5d33b 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/git/TestGitProvider.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/git/TestGitProvider.groovy @@ -4,26 +4,19 @@ import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.providers.GitProvider class TestGitProvider { - static Map buildProviders(Config cfg) { - if (cfg.scm.scmProviderType?.toString() == 'GITLAB') { - def gitlab = new GitlabMock( - base: new URI(cfg.scm.gitlab.url), - namePrefix: cfg.application.namePrefix - ) - return [tenant: gitlab, central: cfg.multiTenant.useDedicatedInstance ? gitlab : null] - } - - def serviceDns = "http://scmm.${cfg.application.namePrefix}scm-manager.svc.cluster.local/scm" - String tenantInCluster = (cfg.scm.scmManager?.url ?: serviceDns) as String - String centralInCluster = (cfg.multiTenant.scmManager?.url ?: tenantInCluster) as String - - def tenant = new ScmManagerMock(inClusterBase: new URI(tenantInCluster), namePrefix: cfg.application.namePrefix) - def central = cfg.multiTenant.useDedicatedInstance - ? new ScmManagerMock(inClusterBase: new URI(centralInCluster), namePrefix: cfg.application.namePrefix) - : null - return [tenant: tenant, central: central] - } -} - + static Map buildProviders(Config cfg) { + if (cfg.scm.scmProviderType?.toString() == 'GITLAB') { + def gitlab = new GitlabMock(base: new URI(cfg.scm.gitlab.url), + namePrefix: cfg.application.namePrefix) + return [tenant: gitlab, central: cfg.multiTenant.useDedicatedInstance ? gitlab : null] + } + def serviceDns = "http://scmm.${cfg.application.namePrefix}scm-manager.svc.cluster.local/scm" + String tenantInCluster = (cfg.scm.scmManager?.url ?: serviceDns) as String + String centralInCluster = (cfg.multiTenant.scmManager?.url ?: tenantInCluster) as String + def tenant = new ScmManagerMock(inClusterBase: new URI(tenantInCluster), namePrefix: cfg.application.namePrefix) + def central = cfg.multiTenant.useDedicatedInstance ? new ScmManagerMock(inClusterBase: new URI(centralInCluster), namePrefix: cfg.application.namePrefix) : null + return [tenant: tenant, central: central] + } +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/git/TestGitRepoFactory.groovy b/src/test/groovy/com/cloudogu/gitops/utils/git/TestGitRepoFactory.groovy index 846a00897..bed713e7a 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/git/TestGitRepoFactory.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/git/TestGitRepoFactory.groovy @@ -1,68 +1,65 @@ package com.cloudogu.gitops.utils.git +import static org.mockito.Mockito.doAnswer +import static org.mockito.Mockito.spy + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.git.GitRepo import com.cloudogu.gitops.git.GitRepoFactory import com.cloudogu.gitops.git.providers.GitProvider import com.cloudogu.gitops.utils.FileSystemUtils -import org.apache.commons.io.FileUtils - -import static org.mockito.Mockito.doAnswer -import static org.mockito.Mockito.spy +import org.apache.commons.io.FileUtils class TestGitRepoFactory extends GitRepoFactory { - Map repos = [:] - GitProvider defaultProvider - - TestGitRepoFactory(Config config, FileSystemUtils fileSystemUtils) { - super(config, fileSystemUtils) - } + Map repos = [:] + GitProvider defaultProvider - @Override - GitRepo getRepo(String repoTarget, GitProvider scm) { - def effectiveProvider = scm ?: defaultProvider + TestGitRepoFactory(Config config, FileSystemUtils fileSystemUtils) { + super(config, fileSystemUtils) + } - if (!effectiveProvider) { - throw new IllegalStateException( - "No GitProvider provided for repo '${repoTarget}' and defaultProvider is null." - ) - } + @Override + GitRepo getRepo(String repoTarget, GitProvider scm) { + def effectiveProvider = scm ?: defaultProvider - if (repos[repoTarget]) { - return repos[repoTarget] - } + if (!effectiveProvider) { + throw new IllegalStateException("No GitProvider provided for repo '${repoTarget}' and defaultProvider is null.") + } - GitRepo repoNew = new GitRepo(config, scm, repoTarget, fileSystemUtils) { - String remoteGitRepoUrl = '' + if (repos[repoTarget]) { + return repos[repoTarget] + } - @Override - String getGitRepositoryUrl() { - if (!remoteGitRepoUrl) { + GitRepo repoNew = new GitRepo(config, scm, repoTarget, fileSystemUtils) { + String remoteGitRepoUrl = '' - def tempDir = File.createTempDir('gitops-playground-repocopy') - tempDir.deleteOnExit() - def originalRepo = System.getProperty("user.dir") + "/src/test/groovy/com/cloudogu/gitops/utils/data/git-repository/" + @Override + String getGitRepositoryUrl() { + if (!remoteGitRepoUrl) { - FileUtils.copyDirectory(new File(originalRepo), tempDir) - remoteGitRepoUrl = 'file://' + tempDir.absolutePath - } - return remoteGitRepoUrl - } - } + def tempDir = File.createTempDir('gitops-playground-repocopy') + tempDir.deleteOnExit() + def originalRepo = System.getProperty("user.dir") + "/src/test/groovy/com/cloudogu/gitops/utils/data/git-repository/" + FileUtils.copyDirectory(new File(originalRepo), tempDir) + remoteGitRepoUrl = 'file://' + tempDir.absolutePath + } + return remoteGitRepoUrl + } + } - GitRepo spyRepo = spy(repoNew) + GitRepo spyRepo = spy(repoNew) - // Test-only: remove local clone target before cloning to avoid "not empty" errors - doAnswer { invocation -> - File target = new File(spyRepo.absoluteLocalRepoTmpDir) - if (target?.exists()) { - FileUtils.deleteDirectory(target) - } - invocation.callRealMethod() - }.when(spyRepo).cloneRepo() - repos.put(repoTarget, spyRepo) - return spyRepo - } + // Test-only: remove local clone target before cloning to avoid "not empty" errors + doAnswer { invocation -> + File target = new File(spyRepo.absoluteLocalRepoTmpDir) + if (target?.exists()) { + FileUtils.deleteDirectory(target) + } + invocation.callRealMethod() + }.when(spyRepo).cloneRepo() + repos.put(repoTarget, spyRepo) + return spyRepo + } } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/utils/git/TestScmManagerApiClient.groovy b/src/test/groovy/com/cloudogu/gitops/utils/git/TestScmManagerApiClient.groovy index be30b219f..5c9500ee5 100644 --- a/src/test/groovy/com/cloudogu/gitops/utils/git/TestScmManagerApiClient.groovy +++ b/src/test/groovy/com/cloudogu/gitops/utils/git/TestScmManagerApiClient.groovy @@ -1,77 +1,77 @@ package com.cloudogu.gitops.utils.git +import static org.mockito.ArgumentMatchers.* +import static org.mockito.Mockito.mock +import static org.mockito.Mockito.when + import com.cloudogu.gitops.config.Config import com.cloudogu.gitops.config.Credentials import com.cloudogu.gitops.git.providers.scmmanager.Permission import com.cloudogu.gitops.git.providers.scmmanager.api.Repository import com.cloudogu.gitops.git.providers.scmmanager.api.RepositoryApi import com.cloudogu.gitops.git.providers.scmmanager.api.ScmManagerApiClient + import okhttp3.internal.http.RealResponseBody import okio.BufferedSource import org.mockito.ArgumentMatchers import retrofit2.Call import retrofit2.Response -import static org.mockito.ArgumentMatchers.* -import static org.mockito.Mockito.mock -import static org.mockito.Mockito.when - class TestScmManagerApiClient extends ScmManagerApiClient { - RepositoryApi repositoryApi = mock(RepositoryApi) - Set createdRepos = new HashSet<>() - Set createdPermissions = new HashSet<>() + RepositoryApi repositoryApi = mock(RepositoryApi) + Set createdRepos = new HashSet<>() + Set createdPermissions = new HashSet<>() - TestScmManagerApiClient(Config config) { - super(config.scm.scmManager.url, new Credentials(config.scm.scmManager.username, config.scm.scmManager.password), null) - } + TestScmManagerApiClient(Config config) { + super(config.scm.scmManager.url, new Credentials(config.scm.scmManager.username, config.scm.scmManager.password), null) + } - @Override - RepositoryApi repositoryApi() { - return repositoryApi - } + @Override + RepositoryApi repositoryApi() { + return repositoryApi + } - /** - * Make all repo API calls return created on the first call and exists on subsequent calls for each repo. - */ - void mockRepoApiBehaviour() { - def responseCreated = mockSuccessfulResponse(201) - def responseExists = mockErrorResponse(409) + /** + * Make all repo API calls return created on the first call and exists on subsequent calls for each repo.*/ + void mockRepoApiBehaviour() { + def responseCreated = mockSuccessfulResponse(201) + def responseExists = mockErrorResponse(409) - when(repositoryApi.create(ArgumentMatchers.any(Repository), anyBoolean())) - .thenAnswer { invocation -> - Repository repo = invocation.getArgument(0) - if (createdRepos.contains(repo.fullRepoName)) { - return responseExists - } else { - createdRepos.add(repo.fullRepoName) - return responseCreated - } - } - when(repositoryApi.createPermission(anyString(), anyString(), any(Permission))) - .thenAnswer { invocation -> - String namespace = invocation.getArgument(0) - String name = invocation.getArgument(1) - if (createdPermissions.contains("${namespace}/${name}".toString())) { - return responseExists - } else { - createdPermissions.add("${namespace}/${name}".toString()) - return responseCreated - } - } - } + when(repositoryApi.create(ArgumentMatchers.any(Repository), anyBoolean())) + .thenAnswer { invocation -> + Repository repo = invocation.getArgument(0) + if (createdRepos.contains(repo.fullRepoName)) { + return responseExists + } else { + createdRepos.add(repo.fullRepoName) + return responseCreated + } + } + when(repositoryApi.createPermission(anyString(), anyString(), any(Permission))) + .thenAnswer { invocation -> + String namespace = invocation.getArgument(0) + String name = invocation.getArgument(1) + if (createdPermissions.contains("${namespace}/${name}".toString())) { + return responseExists + } else { + createdPermissions.add("${namespace}/${name}".toString()) + return responseCreated + } + } + } - static Call mockSuccessfulResponse(int expectedReturnCode) { - def expectedCall = mock(Call) - when(expectedCall.execute()).thenReturn(Response.success(expectedReturnCode, null)) - expectedCall - } + static Call mockSuccessfulResponse(int expectedReturnCode) { + def expectedCall = mock(Call) + when(expectedCall.execute()).thenReturn(Response.success(expectedReturnCode, null)) + expectedCall + } - static Call mockErrorResponse(int expectedReturnCode) { - def expectedCall = mock(Call) - // Response is a final class that cannot be mocked 😠 - Response errorResponse = Response.error(expectedReturnCode, new RealResponseBody('dontcare', 0, mock(BufferedSource))) - when(expectedCall.execute()).thenReturn(errorResponse) - expectedCall - } + static Call mockErrorResponse(int expectedReturnCode) { + def expectedCall = mock(Call) + // Response is a final class that cannot be mocked 😠 + Response errorResponse = Response.error(expectedReturnCode, new RealResponseBody('dontcare', 0, mock(BufferedSource))) + when(expectedCall.execute()).thenReturn(errorResponse) + expectedCall + } } \ No newline at end of file