|
| 1 | +#!/usr/bin/env bats |
| 2 | + |
| 3 | +# Per-cluster OIDC provisioning on a tenant Kubernetes CR. Creates a |
| 4 | +# control-plane-only Kubernetes CR with spec.oidc.enabled=true and |
| 5 | +# asserts: Kamaji apiserver extraArgs include the OIDC flags, the |
| 6 | +# per-cluster KeycloakClient + KeycloakRealmGroup reconcile, the |
| 7 | +# post-install Job applies the cluster-admin ClusterRoleBinding inside |
| 8 | +# the tenant cluster, and the pre-delete Job removes it on uninstall. |
| 9 | +# |
| 10 | +# Self-contained: force-toggles Tenant.spec.oidc.enabled=true so we |
| 11 | +# don't wait the 5m Helm `lookup` loop for the per-tenant realm; bats |
| 12 | +# tests don't guarantee ordering, so this test must NOT depend on |
| 13 | +# tenant-oidc.bats having run first. |
| 14 | + |
| 15 | +# Shared across all @test blocks in this file. Bats evaluates file-level |
| 16 | +# statements before any @test runs, so these assignments are visible |
| 17 | +# inside the test bodies. Match the kubernetes-latest port pool (59991) |
| 18 | +# with an offset so parallel runs don't collide. |
| 19 | +CLUSTER_NAME=oidc-test |
| 20 | +PORT=59992 |
| 21 | +RELEASE_NAME=kubernetes-${CLUSTER_NAME} |
| 22 | +TENANT_NS=tenant-test |
| 23 | +KUBECONFIG_FILE=tenantkubeconfig-${CLUSTER_NAME} |
| 24 | + |
| 25 | +@test "Kubernetes OIDC: force-provision per-tenant realm" { |
| 26 | + # Patch tenant-test to oidc.enabled=true so the realm is provisioned |
| 27 | + # immediately on the next tenant HR reconcile (without waiting for |
| 28 | + # the helm-controller `lookup` loop to pick up the Kubernetes CR). |
| 29 | + kubectl patch tenants/test -n tenant-root --type merge \ |
| 30 | + --patch '{"spec":{"oidc":{"enabled":true}}}' |
| 31 | + kubectl wait hr/tenant-test -n tenant-root \ |
| 32 | + --timeout=5m --for=condition=ready |
| 33 | + |
| 34 | + timeout 60 sh -ec 'until kubectl get clusterkeycloakrealm/tenant-test >/dev/null 2>&1; do sleep 2; done' |
| 35 | + kubectl wait clusterkeycloakrealm/tenant-test \ |
| 36 | + --timeout=3m --for=jsonpath='{.status.value}'=OK |
| 37 | +} |
| 38 | + |
| 39 | +@test "Kubernetes OIDC: create Kubernetes CR with oidc.enabled=true" { |
| 40 | + # Clean up stale resources from a previous failed retry. |
| 41 | + kubectl -n ${TENANT_NS} delete kuberneteses.apps.cozystack.io "${CLUSTER_NAME}" \ |
| 42 | + --ignore-not-found --wait=false 2>/dev/null || true |
| 43 | + kubectl -n ${TENANT_NS} wait kuberneteses.apps.cozystack.io "${CLUSTER_NAME}" \ |
| 44 | + --for=delete --timeout=2m 2>/dev/null || true |
| 45 | + |
| 46 | + local k8s_version |
| 47 | + k8s_version=$(yq 'keys | sort_by(.) | .[-1]' \ |
| 48 | + packages/apps/kubernetes/files/versions.yaml) |
| 49 | + |
| 50 | + # Control-plane only, minimal addons. Workers are not required to |
| 51 | + # exercise the OIDC provisioning path - the post-install Job applies |
| 52 | + # the CRB through the Kamaji admin kubeconfig, which becomes available |
| 53 | + # once the apiserver reaches Ready (no workers needed). |
| 54 | + kubectl apply -f - <<EOF |
| 55 | +apiVersion: apps.cozystack.io/v1alpha1 |
| 56 | +kind: Kubernetes |
| 57 | +metadata: |
| 58 | + name: "${CLUSTER_NAME}" |
| 59 | + namespace: ${TENANT_NS} |
| 60 | +spec: |
| 61 | + oidc: |
| 62 | + enabled: true |
| 63 | + addons: |
| 64 | + certManager: {enabled: false, valuesOverride: {}} |
| 65 | + cilium: {valuesOverride: {}} |
| 66 | + fluxcd: {enabled: false, valuesOverride: {}} |
| 67 | + gatewayAPI: {enabled: false} |
| 68 | + gpuOperator: {enabled: false, valuesOverride: {}} |
| 69 | + ingressNginx: {enabled: false, hosts: [], valuesOverride: {}} |
| 70 | + monitoringAgents: {enabled: false, valuesOverride: {}} |
| 71 | + verticalPodAutoscaler: {valuesOverride: {}} |
| 72 | + controlPlane: |
| 73 | + apiServer: {resources: {}, resourcesPreset: small} |
| 74 | + controllerManager: {resources: {}, resourcesPreset: micro} |
| 75 | + konnectivity: |
| 76 | + server: {resources: {}, resourcesPreset: micro} |
| 77 | + replicas: 1 |
| 78 | + scheduler: {resources: {}, resourcesPreset: micro} |
| 79 | + host: "" |
| 80 | + nodeGroups: {} |
| 81 | + storageClass: replicated |
| 82 | + version: "${k8s_version}" |
| 83 | +EOF |
| 84 | + |
| 85 | + # Wait for the KamajiControlPlane CR to appear and the |
| 86 | + # TenantControlPlaneCreated condition. |
| 87 | + timeout 2m sh -ec 'until kubectl get kamajicontrolplane -n '"${TENANT_NS}"' '"${RELEASE_NAME}"'; do sleep 2; done' |
| 88 | + kubectl wait --for=condition=TenantControlPlaneCreated \ |
| 89 | + kamajicontrolplane -n ${TENANT_NS} ${RELEASE_NAME} --timeout=5m |
| 90 | + kubectl wait tcp -n ${TENANT_NS} ${RELEASE_NAME} --timeout=10m \ |
| 91 | + --for=jsonpath='{.status.kubernetesResources.version.status}'=Ready |
| 92 | +} |
| 93 | + |
| 94 | +@test "Kubernetes OIDC: apiserver extraArgs include OIDC flags" { |
| 95 | + # The kubernetes chart injects --oidc-issuer-url, --oidc-client-id, |
| 96 | + # --oidc-username-claim, --oidc-groups-claim into |
| 97 | + # KCP.spec.apiServer.extraArgs (a list of strings). Verify all four. |
| 98 | + args=$(kubectl get kamajicontrolplane -n ${TENANT_NS} ${RELEASE_NAME} \ |
| 99 | + -o jsonpath='{range .spec.apiServer.extraArgs[*]}{@}{"\n"}{end}') |
| 100 | + echo "apiServer extraArgs:"; echo "${args}" |
| 101 | + echo "${args}" | grep -qx -- '--oidc-issuer-url=https://keycloak.example.org/realms/tenant-test' |
| 102 | + echo "${args}" | grep -qx -- "--oidc-client-id=${RELEASE_NAME}" |
| 103 | + echo "${args}" | grep -qx -- '--oidc-username-claim=preferred_username' |
| 104 | + echo "${args}" | grep -qx -- '--oidc-groups-claim=groups' |
| 105 | +} |
| 106 | + |
| 107 | +@test "Kubernetes OIDC: per-cluster KeycloakClient and RealmGroup ready" { |
| 108 | + timeout 2m sh -ec 'until kubectl get keycloakclient/'"${RELEASE_NAME}"' -n '"${TENANT_NS}"' >/dev/null 2>&1; do sleep 2; done' |
| 109 | + kubectl wait keycloakclient/${RELEASE_NAME} -n ${TENANT_NS} \ |
| 110 | + --timeout=3m --for=jsonpath='{.status.value}'=OK |
| 111 | + |
| 112 | + timeout 2m sh -ec 'until kubectl get keycloakrealmgroup/'"${CLUSTER_NAME}"' -n '"${TENANT_NS}"' >/dev/null 2>&1; do sleep 2; done' |
| 113 | + kubectl wait keycloakrealmgroup/${CLUSTER_NAME} -n ${TENANT_NS} \ |
| 114 | + --timeout=3m --for=jsonpath='{.status.value}'=OK |
| 115 | +} |
| 116 | + |
| 117 | +@test "Kubernetes OIDC: post-install Job applied ClusterRoleBinding inside tenant cluster" { |
| 118 | + # Wait for the HR itself to flip Ready: Flux helm-controller only marks |
| 119 | + # the HelmRelease Ready=True after the helm install action AND its |
| 120 | + # post-install hooks return successfully. The post-install Job carries |
| 121 | + # hook-delete-policy=hook-succeeded so it is garbage-collected the |
| 122 | + # moment it completes; polling on the Job directly races against that |
| 123 | + # cleanup. The CRB it creates is the durable outcome to assert against. |
| 124 | + kubectl wait hr/${RELEASE_NAME} -n ${TENANT_NS} \ |
| 125 | + --timeout=15m --for=condition=ready |
| 126 | + |
| 127 | + # Extract the admin kubeconfig and verify the CRB exists inside the |
| 128 | + # tenant cluster. |
| 129 | + kubectl get secret ${RELEASE_NAME}-admin-kubeconfig -n ${TENANT_NS} \ |
| 130 | + -o jsonpath='{.data.super-admin\.conf}' | base64 -d > "${KUBECONFIG_FILE}" |
| 131 | + yq -i ".clusters[0].cluster.server = \"https://localhost:${PORT}\"" \ |
| 132 | + "${KUBECONFIG_FILE}" |
| 133 | + |
| 134 | + # Kill any stale port-forward on this port from a previous retry, then |
| 135 | + # start a fresh one to the tenant apiserver Service. |
| 136 | + pkill -f "port-forward.*${PORT}:" 2>/dev/null || true |
| 137 | + sleep 1 |
| 138 | + kubectl port-forward service/${RELEASE_NAME} -n ${TENANT_NS} \ |
| 139 | + "${PORT}":6443 > /dev/null 2>&1 & |
| 140 | + timeout 15 sh -ec 'until curl -sk https://localhost:'"${PORT}"' >/dev/null 2>&1; do sleep 1; done' |
| 141 | + |
| 142 | + # CRB shape: name oidc-<cluster>-cluster-admin, label |
| 143 | + # cozystack.io/oidc-cluster=<cluster>, subject Group <cluster>, |
| 144 | + # roleRef cluster-admin. |
| 145 | + crb=oidc-${CLUSTER_NAME}-cluster-admin |
| 146 | + kubectl --kubeconfig "${KUBECONFIG_FILE}" get clusterrolebinding ${crb} -o yaml |
| 147 | + kubectl --kubeconfig "${KUBECONFIG_FILE}" get clusterrolebinding ${crb} \ |
| 148 | + -o jsonpath='{.metadata.labels.cozystack\.io/oidc-cluster}' | grep -qx "${CLUSTER_NAME}" |
| 149 | + kubectl --kubeconfig "${KUBECONFIG_FILE}" get clusterrolebinding ${crb} \ |
| 150 | + -o jsonpath='{.subjects[0].kind}/{.subjects[0].name}' | grep -qx "Group/${CLUSTER_NAME}" |
| 151 | + kubectl --kubeconfig "${KUBECONFIG_FILE}" get clusterrolebinding ${crb} \ |
| 152 | + -o jsonpath='{.roleRef.kind}/{.roleRef.name}' | grep -qx 'ClusterRole/cluster-admin' |
| 153 | + |
| 154 | + # Cleanup port-forward for this test; the delete-flow test starts its |
| 155 | + # own port-forward after deletion to verify the CRB is gone. |
| 156 | + pkill -f "port-forward.*${PORT}:" 2>/dev/null || true |
| 157 | +} |
| 158 | + |
| 159 | +@test "Kubernetes OIDC: pre-delete Job removes ClusterRoleBinding" { |
| 160 | + # The pre-delete hook re-creates the SA/Role/RoleBinding (post-install |
| 161 | + # hooks are pruned with hook-succeeded), runs the cleanup Job which |
| 162 | + # label-selects cozystack.io/oidc-cluster=<cluster> and deletes the |
| 163 | + # CRB inside the tenant cluster. Trigger the deletion and verify. |
| 164 | + |
| 165 | + # Start a fresh port-forward BEFORE deletion - once Kamaji tears down |
| 166 | + # the apiserver pods, the Service still routes, but the kubeconfig |
| 167 | + # Secret may be deleted later in the cascade. Watch the CRB |
| 168 | + # disappear via that port-forward. |
| 169 | + pkill -f "port-forward.*${PORT}:" 2>/dev/null || true |
| 170 | + sleep 1 |
| 171 | + kubectl port-forward service/${RELEASE_NAME} -n ${TENANT_NS} \ |
| 172 | + "${PORT}":6443 > /dev/null 2>&1 & |
| 173 | + timeout 15 sh -ec 'until curl -sk https://localhost:'"${PORT}"' >/dev/null 2>&1; do sleep 1; done' |
| 174 | + |
| 175 | + # Sanity: CRB exists right before delete. |
| 176 | + crb=oidc-${CLUSTER_NAME}-cluster-admin |
| 177 | + kubectl --kubeconfig "${KUBECONFIG_FILE}" get clusterrolebinding ${crb} |
| 178 | + |
| 179 | + # Delete the Kubernetes CR - Flux uninstalls the HR, which fires the |
| 180 | + # pre-delete cleanup Job (template name <release>-oidc-rbac-cleanup) |
| 181 | + # before tearing down release resources. Both this cleanup Job and |
| 182 | + # the in-tenant-cluster admin-kubeconfig Secret have transient |
| 183 | + # lifetimes: the Job carries hook-delete-policy=hook-succeeded so it |
| 184 | + # is garbage-collected the moment it completes, and Kamaji deletes |
| 185 | + # the admin kubeconfig Secret shortly after the Kubernetes CR is |
| 186 | + # removed. Both paths converge on the same observable outcome - the |
| 187 | + # ClusterRoleBinding stops being visible from the tenant kubeconfig - |
| 188 | + # so poll on that signal rather than on either intermediate object. |
| 189 | + kubectl -n ${TENANT_NS} delete kuberneteses.apps.cozystack.io "${CLUSTER_NAME}" --wait=false |
| 190 | + |
| 191 | + # Poll: CRB must vanish, or the apiserver/Secret/Service stops being |
| 192 | + # reachable (the broader teardown). Both are valid happy outcomes; |
| 193 | + # only "CRB still visible with the right label after 3m" is a failure. |
| 194 | + timeout 3m sh -ec ' |
| 195 | + crb=oidc-'"${CLUSTER_NAME}"'-cluster-admin |
| 196 | + until ! kubectl --kubeconfig "'"${KUBECONFIG_FILE}"'" get clusterrolebinding ${crb} 2>&1 \ |
| 197 | + | grep -q "cozystack.io/oidc-cluster='"${CLUSTER_NAME}"'\|^${crb}\s"; do |
| 198 | + sleep 5 |
| 199 | + done |
| 200 | + ' |
| 201 | + |
| 202 | + # Final tenant CR wait. |
| 203 | + kubectl -n ${TENANT_NS} wait kuberneteses.apps.cozystack.io "${CLUSTER_NAME}" \ |
| 204 | + --for=delete --timeout=5m 2>/dev/null || true |
| 205 | + |
| 206 | + # Cleanup port-forward + temp files. Flip Tenant oidc back to false so |
| 207 | + # other bats files run against a clean slate. |
| 208 | + pkill -f "port-forward.*${PORT}:" 2>/dev/null || true |
| 209 | + rm -f "${KUBECONFIG_FILE}" /tmp/oidc-cleanup.log |
| 210 | + kubectl patch tenants/test -n tenant-root --type merge \ |
| 211 | + --patch '{"spec":{"oidc":{"enabled":false}}}' || true |
| 212 | +} |
0 commit comments