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/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..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={}", @@ -235,31 +237,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..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,15 +107,19 @@ protected void preDeploy() { deploymentMode.generateRBAC(); } else { mergeHelmValuesIfConfigured(); + disableSharedArgoCdCrdsIfRequired(); } } @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(); } @@ -224,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() { @@ -270,6 +282,45 @@ private void updateAdminPasswordForOperator() { updateBcryptAdminPassword(); } + 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(); @@ -281,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 cc2c9dc87..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 @@ -62,6 +62,106 @@ 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 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() + .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..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 @@ -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(); @@ -282,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 @@ -653,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 @@ -1609,7 +1667,7 @@ private void prepareKubernetesObjectsForArgoCd() { createNamespaceIfMissing(namespace); createNamespaceIfMissing(centralNamespace); - createArgoCdCrds(); + createArgoCdCrds(namespace, namespace); config.getApplication().getNamespaces().getActiveNamespaces().forEach(this::createNamespaceIfMissing); @@ -1630,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", @@ -1645,7 +1705,9 @@ private void createArgoCdCrds() { "v1alpha1", "Application", "applications", - "application" + "application", + releaseName, + releaseNamespace ); createNamespacedCrd( "argocds.argoproj.io", @@ -1653,7 +1715,9 @@ private void createArgoCdCrds() { "v1beta1", "ArgoCD", "argocds", - "argocd" + "argocd", + releaseName, + releaseNamespace ); } @@ -1663,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; } @@ -1671,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)