Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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}
</#if>
# needed to sync the operator/rbac folder
<#if config.features.argocd.operator>
directory:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand All @@ -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));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<? extends HasMetadata> 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).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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={}",
Expand All @@ -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<String, Object> 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<GenericKubernetesResource, GenericKubernetesResourceList,
// Resource<GenericKubernetesResource>>; 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<String, Object> match) {
Expand Down
57 changes: 54 additions & 3 deletions src/main/java/com/cloudogu/gitops/tools/core/argocd/ArgoCD.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public class ArgoCD extends AbstractMappedTool<ArgoCDToolConfig> 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;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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.<String, Object>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();

Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ========================================
Expand Down
Loading
Loading