From a1e7c2919a756e17e220cb7c5f522a4483de218b Mon Sep 17 00:00:00 2001 From: Anna Vetcininova Date: Mon, 21 Sep 2026 09:42:14 +0200 Subject: [PATCH 1/2] Make ArgoCD Helm bootstrap idempotent --- .../kubernetes/api/K8sClient.java | 23 +++++ .../kubernetes/api/K8sClientHelper.java | 25 +----- .../gitops/tools/core/argocd/ArgoCD.java | 9 +- .../kubernetes/api/K8sClientTest.java | 85 +++++++++++++++++++ .../core/argocd/ArgoCDConfigurationTest.java | 13 +++ 5 files changed, 133 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClient.java b/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClient.java index a48ae06ad..62392d2fb 100644 --- a/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClient.java +++ b/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClient.java @@ -404,6 +404,29 @@ public boolean namespaceExists(String namespace) { return false; } + /** + * Checks if a resource exists. Custom resources are resolved via Kubernetes API discovery. + * + * @param resource resource type, e.g. {@code application} or {@code secret} + * @param name resource name + * @param namespace namespace of the resource; empty means the default namespace + * @return true if the resource exists + */ + public boolean resourceExists(String resource, String name, String namespace) { + try { + Resource resourceClient = K8sClientHelper.getResourceClient( + client, + resource, + name, + resolveNamespace(namespace) + ); + return resourceClient.get() != null; + } catch (KubernetesApiResourceNotFoundException e) { + log.trace("Resource type {} is not available: {}", resource, e.getMessage()); + return false; + } + } + /** * Creates or updates an empty secret in the default namespace (idempotent). * diff --git a/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientHelper.java b/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientHelper.java index 65f2035df..d92766b1a 100644 --- a/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientHelper.java +++ b/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientHelper.java @@ -235,31 +235,14 @@ static io.fabric8.kubernetes.client.dsl.Resource getCustomResourceClient( String resourceType, String name, String namespace) { - String normalized = resourceType.toLowerCase(Locale.ROOT); - - Map match = findApiResourceViaDiscovery(client, normalized, resourceType); - - if (match.isEmpty()) { - throw new K8sClient.KubernetesApiResourceNotFoundException(resourceType); - } - - log.debug( - "Resolved '{}' via discovery to {}/{} kind={} plural={} namespaced={}", - resourceType, - match.get(GROUP_KEY), - match.get(VERSION_KEY), - match.get(KIND_KEY), - match.get(PLURAL_KEY), - match.get(NAMESPACED_KEY) - ); - - ResourceDefinitionContext context = toResourceDefinitionContext(match); - boolean namespaced = Boolean.TRUE.equals(match.get(NAMESPACED_KEY)); + ResourceDefinitionContext context = resolveResourceDefinitionContext(client, resourceType); // type is MixedOperation>; kept as `var` deliberately. var resourceClient = client.genericKubernetesResources(context); - return namespaced ? resourceClient.inNamespace(namespace).withName(name) : resourceClient.withName(name); + return context.isNamespaceScoped() + ? resourceClient.inNamespace(namespace).withName(name) + : resourceClient.withName(name); } private static ResourceDefinitionContext toResourceDefinitionContext(Map match) { diff --git a/src/main/java/com/cloudogu/gitops/tools/core/argocd/ArgoCD.java b/src/main/java/com/cloudogu/gitops/tools/core/argocd/ArgoCD.java index c89befd22..2b26833f9 100644 --- a/src/main/java/com/cloudogu/gitops/tools/core/argocd/ArgoCD.java +++ b/src/main/java/com/cloudogu/gitops/tools/core/argocd/ArgoCD.java @@ -109,10 +109,13 @@ protected void preDeploy() { @Override protected void deploy() { - log.debug("Installing Argo CD"); + log.debug("Ensuring Argo CD is installed"); if (toolConfig().operator()) { deployWithOperator(); + } else if (isAlreadyBootstrapped()) { + log.debug("ArgoCD is already bootstrapped, skipping Helm installation"); + updateBcryptAdminPassword(); } else { deployWithHelm(); } @@ -270,6 +273,10 @@ private void updateAdminPasswordForOperator() { updateBcryptAdminPassword(); } + private boolean isAlreadyBootstrapped() { + return k8sClient.resourceExists("application", "bootstrap", namespace); + } + private void deployWithHelm() { String umbrellaChartPath = clusterResourcesRepo.helmDir(); diff --git a/src/test/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientTest.java b/src/test/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientTest.java index cc2c9dc87..6b615f303 100644 --- a/src/test/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientTest.java +++ b/src/test/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientTest.java @@ -62,6 +62,91 @@ void setup() { k8sApiClient.defaultRetries = 3; } + @Test + void resourceExistsReturnsTrueForExistingResource() { + var pod = new PodBuilder() + .withNewMetadata() + .withName("test-pod") + .withNamespace("test-ns") + .endMetadata() + .build(); + + server.expect() + .get() + .withPath("/api/v1/namespaces/test-ns/pods/test-pod") + .andReturn(200, pod) + .once(); + + assertThat(k8sApiClient.resourceExists("pod", "test-pod", "test-ns")).isTrue(); + } + + @Test + void resourceExistsReturnsFalseForMissingResource() { + server.expect() + .get() + .withPath("/api/v1/namespaces/test-ns/pods/missing-pod") + .andReturn(404, new StatusBuilder().withCode(404).withReason("NotFound").build()) + .once(); + + assertThat(k8sApiClient.resourceExists("pod", "missing-pod", "test-ns")).isFalse(); + } + + @Test + void resourceExistsFallsBackToCrdForCustomResource() { + server.expect() + .get() + .withPath("/apis") + .andReturn(200, Map.of("groups", List.of())) + .once(); + + server.expect() + .get() + .withPath("/apis/apiextensions.k8s.io/v1/customresourcedefinitions") + .andReturn( + 200, Map.of( + "apiVersion", "apiextensions.k8s.io/v1", + "kind", "CustomResourceDefinitionList", + "items", List.of(Map.of( + "apiVersion", "apiextensions.k8s.io/v1", + "kind", "CustomResourceDefinition", + "metadata", Map.of("name", "applications.argoproj.io"), + "spec", Map.of( + "group", "argoproj.io", + "scope", "Namespaced", + "names", Map.of( + "kind", "Application", + "plural", "applications", + "singular", "application" + ), + "versions", List.of(Map.of( + "name", "v1alpha1", + "served", true, + "storage", true + )) + ) + )) + ) + ) + .once(); + + GenericKubernetesResource bootstrap = new GenericKubernetesResourceBuilder() + .withApiVersion("argoproj.io/v1alpha1") + .withKind("Application") + .withNewMetadata() + .withName("bootstrap") + .withNamespace("argocd") + .endMetadata() + .build(); + + server.expect() + .get() + .withPath("/apis/argoproj.io/v1alpha1/namespaces/argocd/applications/bootstrap") + .andReturn(200, bootstrap) + .once(); + + assertThat(k8sApiClient.resourceExists("application", "bootstrap", "argocd")).isTrue(); + } + // ======================================== // Node Operations Tests // ======================================== diff --git a/src/test/java/com/cloudogu/gitops/tools/core/argocd/ArgoCDConfigurationTest.java b/src/test/java/com/cloudogu/gitops/tools/core/argocd/ArgoCDConfigurationTest.java index d8881ada1..a28ce2b5b 100644 --- a/src/test/java/com/cloudogu/gitops/tools/core/argocd/ArgoCDConfigurationTest.java +++ b/src/test/java/com/cloudogu/gitops/tools/core/argocd/ArgoCDConfigurationTest.java @@ -204,6 +204,19 @@ void resolvesAdminPasswordFromApplicationSecretWithoutMutatingConfig() { assertThat(reference.getPassword()).isNull(); } + @Test + void skipsHelmBootstrapWhenArgoCdIsAlreadyBootstrapped() { + ArgoCDForTest argocd = (ArgoCDForTest) createArgoCD(); + + execute(argocd); + int helmCommandCountAfterInitialBootstrap = helmCommands.getActualCommands().size(); + assertThat(k8sClient.resourceExists("application", "bootstrap", "argocd")).isTrue(); + + execute(argocd); + + assertThat(helmCommands.getActualCommands()).hasSize(helmCommandCountAfterInitialBootstrap); + } + @Test void installsArgoCd() throws IOException { ArgoCDForTest argocd = (ArgoCDForTest) createArgoCD(); From aedcf40187b969c3e689a18bddbe28008b75cea3 Mon Sep 17 00:00:00 2001 From: Anna Vetcininova Date: Mon, 21 Sep 2026 10:51:57 +0200 Subject: [PATCH 2/2] Support idempotent and isolated ArgoCD bootstrap --- .../apps/argocd/applications/argocd.ftl.yaml | 4 ++ .../destroy/ArgoCDDestructionHandler.java | 4 +- .../kubernetes/api/K8sClientHelper.java | 2 + .../gitops/tools/core/argocd/ArgoCD.java | 48 ++++++++++++- .../kubernetes/api/K8sClientTest.java | 15 ++++ .../core/argocd/ArgoCDConfigurationTest.java | 69 +++++++++++++++++-- 6 files changed, 131 insertions(+), 11 deletions(-) diff --git a/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml b/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml index d9f9f2fe2..668f03f20 100644 --- a/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml +++ b/argocd/cluster-resources/apps/argocd/applications/argocd.ftl.yaml @@ -21,6 +21,10 @@ spec: path: apps/argocd/${config.features.argocd.operator?string("operator/", "argocd/")} repoURL: ${scm.repoUrl}argocd/cluster-resources.git targetRevision: main +<#if !config.features.argocd.operator> + helm: + releaseName: ${config.application.namePrefix}${config.features.argocd.namespace} + # needed to sync the operator/rbac folder <#if config.features.argocd.operator> directory: diff --git a/src/main/java/com/cloudogu/gitops/destroy/ArgoCDDestructionHandler.java b/src/main/java/com/cloudogu/gitops/destroy/ArgoCDDestructionHandler.java index 3c2be6c46..49d76cb16 100644 --- a/src/main/java/com/cloudogu/gitops/destroy/ArgoCDDestructionHandler.java +++ b/src/main/java/com/cloudogu/gitops/destroy/ArgoCDDestructionHandler.java @@ -69,7 +69,7 @@ public void destroy() { } installArgoCDViaHelm(repo, argocdNamespace); - helmClient.uninstall(ARGOCD, ARGOCD); + helmClient.uninstall(argocdNamespace, argocdNamespace); for (CustomResource project : k8sClient.getCustomResource("appprojects")) { k8sClient.delete("appproject", project.namespace(), project.name()); } @@ -96,7 +96,7 @@ public void installArgoCDViaHelm(GitRepo repo, String argocdNamespace) { "dependencies")); helmClient.addRepo("argo", (String) helmDependencies.get(0).get("repository")); helmClient.dependencyBuild(umbrellaChartPath); - helmClient.upgrade(ARGOCD, umbrellaChartPath, Map.of("namespace", argocdNamespace)); + helmClient.upgrade(argocdNamespace, umbrellaChartPath, Map.of("namespace", argocdNamespace)); } } diff --git a/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientHelper.java b/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientHelper.java index d92766b1a..997329781 100644 --- a/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientHelper.java +++ b/src/main/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientHelper.java @@ -218,6 +218,8 @@ private static io.fabric8.kubernetes.client.dsl.Resource resolveResourceClien case "node", "nodes" -> client.nodes().withName(name); case "serviceaccount", "serviceaccounts" -> client.serviceAccounts().inNamespace(resolvedNamespace).withName(name); + case "customresourcedefinition", "customresourcedefinitions", "crd", "crds" -> + client.apiextensions().v1().customResourceDefinitions().withName(name); default -> { log.debug( "Searching API resource via discovery for resourceType={}, name={}, ns={}", diff --git a/src/main/java/com/cloudogu/gitops/tools/core/argocd/ArgoCD.java b/src/main/java/com/cloudogu/gitops/tools/core/argocd/ArgoCD.java index 2b26833f9..74bbf8a70 100644 --- a/src/main/java/com/cloudogu/gitops/tools/core/argocd/ArgoCD.java +++ b/src/main/java/com/cloudogu/gitops/tools/core/argocd/ArgoCD.java @@ -33,6 +33,9 @@ public class ArgoCD extends AbstractMappedTool implements Conf private static final int BCRYPT_LOG_ROUNDS = 4; private static final String TOOL_NAME = "argocd"; private static final String SECRET_RESOURCE = "secret"; + private static final String ARGOCD_APPLICATION_CRD = "applications.argoproj.io"; + private static final String HELM_RELEASE_NAME_ANNOTATION = "meta.helm.sh/release-name"; + private static final String HELM_RELEASE_NAMESPACE_ANNOTATION = "meta.helm.sh/release-namespace"; private final K8sClient k8sClient; private final HelmClient helmClient; @@ -104,6 +107,7 @@ protected void preDeploy() { deploymentMode.generateRBAC(); } else { mergeHelmValuesIfConfigured(); + disableSharedArgoCdCrdsIfRequired(); } } @@ -227,7 +231,12 @@ private void deleteHelmArgoSecrets() { // 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_RESOURCE, namespace, new Tuple<>("owner", "helm"), new Tuple<>("name", TOOL_NAME)); + k8sClient.delete( + SECRET_RESOURCE, + namespace, + new Tuple<>("owner", "helm"), + new Tuple<>("name", helmReleaseName()) + ); } private void deployWithOperator() { @@ -277,6 +286,41 @@ private boolean isAlreadyBootstrapped() { return k8sClient.resourceExists("application", "bootstrap", namespace); } + private String helmReleaseName() { + return namespace; + } + + private void disableSharedArgoCdCrdsIfRequired() { + if (!k8sClient.resourceExists("crd", ARGOCD_APPLICATION_CRD, "")) { + return; + } + + String ownerReleaseName = getCrdAnnotation(HELM_RELEASE_NAME_ANNOTATION); + String ownerReleaseNamespace = getCrdAnnotation(HELM_RELEASE_NAMESPACE_ANNOTATION); + if (helmReleaseName().equals(ownerReleaseName) && namespace.equals(ownerReleaseNamespace)) { + return; + } + + log.debug( + "ArgoCD CRDs are already managed outside Helm release {}/{}; disabling CRD installation", + namespace, + helmReleaseName() + ); + mergeAndWriteYamlValues( + clusterResourcesRepo.helmValuesFile(), + Map.of("argo-cd", Map.of("crds", Map.of("install", false))), + "values.yaml" + ); + } + + private String getCrdAnnotation(String annotation) { + try { + return k8sClient.getAnnotation("crd", ARGOCD_APPLICATION_CRD, annotation); + } catch (IllegalStateException e) { + return null; + } + } + private void deployWithHelm() { String umbrellaChartPath = clusterResourcesRepo.helmDir(); @@ -288,7 +332,7 @@ private void deployWithHelm() { helmClient.addRepo("argo", repository); helmClient.dependencyBuild(umbrellaChartPath); - helmClient.upgrade(TOOL_NAME, umbrellaChartPath, Map.of("namespace", namespace)); + helmClient.upgrade(helmReleaseName(), umbrellaChartPath, Map.of("namespace", namespace)); updateBcryptAdminPassword(); } diff --git a/src/test/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientTest.java b/src/test/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientTest.java index 6b615f303..ff3f25c55 100644 --- a/src/test/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientTest.java +++ b/src/test/java/com/cloudogu/gitops/infrastructure/kubernetes/api/K8sClientTest.java @@ -91,6 +91,21 @@ void resourceExistsReturnsFalseForMissingResource() { assertThat(k8sApiClient.resourceExists("pod", "missing-pod", "test-ns")).isFalse(); } + @Test + void resourceExistsSupportsCustomResourceDefinitionAlias() { + server.expect() + .get() + .withPath("/apis/apiextensions.k8s.io/v1/customresourcedefinitions/applications.argoproj.io") + .andReturn(200, Map.of( + "apiVersion", "apiextensions.k8s.io/v1", + "kind", "CustomResourceDefinition", + "metadata", Map.of("name", "applications.argoproj.io") + )) + .once(); + + assertThat(k8sApiClient.resourceExists("crd", "applications.argoproj.io", "")).isTrue(); + } + @Test void resourceExistsFallsBackToCrdForCustomResource() { server.expect() diff --git a/src/test/java/com/cloudogu/gitops/tools/core/argocd/ArgoCDConfigurationTest.java b/src/test/java/com/cloudogu/gitops/tools/core/argocd/ArgoCDConfigurationTest.java index a28ce2b5b..18c38eefe 100644 --- a/src/test/java/com/cloudogu/gitops/tools/core/argocd/ArgoCDConfigurationTest.java +++ b/src/test/java/com/cloudogu/gitops/tools/core/argocd/ArgoCDConfigurationTest.java @@ -295,6 +295,7 @@ void installsArgoCd() throws IOException { assertThat(value(argocdYaml, "spec", "source", "directory")).isNull(); assertThat((String) value(argocdYaml, "spec", "source", "path")) .isIn("apps/argocd/argocd", "apps/argocd/argocd/"); + assertThat(value(argocdYaml, "spec", "source", "helm", "releaseName")).isEqualTo("argocd"); } @Test @@ -666,11 +667,55 @@ void generatesArgoCdYamlWithNamePrefix() throws IOException { execute(argocd); GitRepo clusterResourcesRepo = argocd.clusterResourcesRepo; + clusterResourcesRepoLayout = argocd.getClusterRepoLayout(); assertArgoCdYamlPrefixes( clusterResourcesRepo.getGitProvider().getUrl(), config.getApplication().getNamePrefix(), - argocd.getClusterRepoLayout() + clusterResourcesRepoLayout + ); + + assertThat(helmCommands.getActualCommands().get(2).trim()) + .isEqualTo("helm upgrade -i abc-argocd " + clusterResourcesRepoLayout.helmDir() + + " --create-namespace --namespace abc-argocd"); + + Map argocdYaml = parseActualYaml( + Path.of(clusterResourcesRepoLayout.applicationsDir(), "argocd.yaml").toString() ); + assertThat(value(argocdYaml, "spec", "source", "helm", "releaseName")).isEqualTo("abc-argocd"); + } + + @Test + void disablesArgoCdCrdInstallationWhenCrdsBelongToAnotherHelmRelease() throws IOException { + createArgoCdCrds("argocd", "argocd"); + config.getApplication().setNamePrefix("abc-"); + + Map valuesYaml = executeAndReadHelmValues(); + + assertThat(value(valuesYaml, "argo-cd", "crds", "install")).isEqualTo(false); + } + + @Test + void deletesHelmReleaseSecretUsingPrefixedReleaseName() { + config.getApplication().setNamePrefix("abc-"); + ArgoCDForTest argocd = (ArgoCDForTest) createArgoCD(); + client.secrets() + .inNamespace("abc-argocd") + .resource(new SecretBuilder() + .withNewMetadata() + .withName("sh.helm.release.v1.abc-argocd.v1") + .withNamespace("abc-argocd") + .addToLabels("owner", "helm") + .addToLabels("name", "abc-argocd") + .endMetadata() + .build()) + .create(); + + execute(argocd); + + assertThat(client.secrets() + .inNamespace("abc-argocd") + .withName("sh.helm.release.v1.abc-argocd.v1") + .get()).isNull(); } @Test @@ -1622,7 +1667,7 @@ private void prepareKubernetesObjectsForArgoCd() { createNamespaceIfMissing(namespace); createNamespaceIfMissing(centralNamespace); - createArgoCdCrds(); + createArgoCdCrds(namespace, namespace); config.getApplication().getNamespaces().getActiveNamespaces().forEach(this::createNamespaceIfMissing); @@ -1643,14 +1688,16 @@ private void prepareKubernetesObjectsForArgoCd() { } } - private void createArgoCdCrds() { + private void createArgoCdCrds(String releaseName, String releaseNamespace) { createNamespacedCrd( "appprojects.argoproj.io", "argoproj.io", "v1alpha1", "AppProject", "appprojects", - "appproject" + "appproject", + releaseName, + releaseNamespace ); createNamespacedCrd( "applications.argoproj.io", @@ -1658,7 +1705,9 @@ private void createArgoCdCrds() { "v1alpha1", "Application", "applications", - "application" + "application", + releaseName, + releaseNamespace ); createNamespacedCrd( "argocds.argoproj.io", @@ -1666,7 +1715,9 @@ private void createArgoCdCrds() { "v1beta1", "ArgoCD", "argocds", - "argocd" + "argocd", + releaseName, + releaseNamespace ); } @@ -1676,7 +1727,9 @@ private void createNamespacedCrd( String version, String kind, String plural, - String singular) { + String singular, + String releaseName, + String releaseNamespace) { if (client.apiextensions().v1().customResourceDefinitions().withName(name).get() != null) { return; } @@ -1684,6 +1737,8 @@ private void createNamespacedCrd( CustomResourceDefinition crd = new CustomResourceDefinitionBuilder() .withNewMetadata() .withName(name) + .addToAnnotations("meta.helm.sh/release-name", releaseName) + .addToAnnotations("meta.helm.sh/release-namespace", releaseNamespace) .endMetadata() .withNewSpec() .withGroup(group)