diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index d89fb9a..80811f7 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -38,7 +38,9 @@ jobs: { "example": "examples/gitopsstacks/eso.yaml" }, { "example": "examples/gitopsstacks/eso.yaml", "observed_resources": "examples/test/mocks/observed-resources/eso/steps/1/" }, { "example": "examples/gitopsstacks/eso.yaml", "observed_resources": "examples/test/mocks/observed-resources/eso/steps/2/" }, - { "example": "examples/gitopsstacks/eso.yaml", "observed_resources": "examples/test/mocks/observed-resources/eso/steps/3/" } + { "example": "examples/gitopsstacks/eso.yaml", "observed_resources": "examples/test/mocks/observed-resources/eso/steps/3/" }, + { "example": "examples/gitopsstacks/zitadel-oidc.yaml" }, + { "example": "examples/gitopsstacks/zitadel-oidc.yaml", "observed_resources": "examples/test/mocks/observed-resources/zitadel-oidc/steps/1/" } ] api_path: apis/gitopsstacks error_on_missing_schemas: true diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index ec1aa65..f10021c 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -34,7 +34,9 @@ jobs: { "example": "examples/gitopsstacks/eso.yaml" }, { "example": "examples/gitopsstacks/eso.yaml", "observed_resources": "examples/test/mocks/observed-resources/eso/steps/1/" }, { "example": "examples/gitopsstacks/eso.yaml", "observed_resources": "examples/test/mocks/observed-resources/eso/steps/2/" }, - { "example": "examples/gitopsstacks/eso.yaml", "observed_resources": "examples/test/mocks/observed-resources/eso/steps/3/" } + { "example": "examples/gitopsstacks/eso.yaml", "observed_resources": "examples/test/mocks/observed-resources/eso/steps/3/" }, + { "example": "examples/gitopsstacks/zitadel-oidc.yaml" }, + { "example": "examples/gitopsstacks/zitadel-oidc.yaml", "observed_resources": "examples/test/mocks/observed-resources/zitadel-oidc/steps/1/" } ] api_path: apis/gitopsstacks error_on_missing_schemas: true diff --git a/Makefile b/Makefile index e5a4095..2c745bb 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,9 @@ EXAMPLES := \ examples/gitopsstacks/eso.yaml:: \ examples/gitopsstacks/eso.yaml::examples/test/mocks/observed-resources/eso/steps/1/ \ examples/gitopsstacks/eso.yaml::examples/test/mocks/observed-resources/eso/steps/2/ \ - examples/gitopsstacks/eso.yaml::examples/test/mocks/observed-resources/eso/steps/3/ + examples/gitopsstacks/eso.yaml::examples/test/mocks/observed-resources/eso/steps/3/ \ + examples/gitopsstacks/zitadel-oidc.yaml:: \ + examples/gitopsstacks/zitadel-oidc.yaml::examples/test/mocks/observed-resources/zitadel-oidc/steps/1/ # Render all examples (parallel execution, output shown per-job when complete) render\:all: diff --git a/README.md b/README.md index 57c38f8..9f5ca08 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,14 @@ A single Crossplane resource that provisions a complete GitOps foundation: ArgoC └── only if crossplane.enabled ──┘ ``` -**Up to 7 composed resources:** 1 Helm Release + 1 GitHub Repository + up to 4 Kubernetes Objects + 1 Usage protection +The base stack composes a Helm Release, GitHub Repository, ArgoCD Applications, +and deletion-ordering Usages. Optional OIDC adds target-cluster objects for the +Zitadel credentials, ProviderConfig, Project, OIDC application, and labeled +client Secret. | Resource | Type | Purpose | |----------|------|---------| -| ArgoCD | Helm Release (`argo-cd` v9.4.3) | Continuous delivery platform | +| ArgoCD | Helm Release (`argo-cd` v9.7.1) | Continuous delivery platform | | GitHub Repository | `repo.github.m.upbound.io` | GitOps source repository | | Projects Application | Kubernetes Object (ArgoCD Application) | Syncs ArgoCD projects from the repo | | Deletion Usage | Usage | Ensures projects app deletes before ArgoCD | @@ -171,6 +174,45 @@ spec: `overrideAllValues` replaces **all** defaults — chart defaults, monitoring config, everything. Use `values` for additive changes instead. +### Stage 6: Gateway exposure and Zitadel OIDC + +Expose ArgoCD through an existing Gateway listener and configure ArgoCD's +native OIDC support. GitopsStack creates a dedicated Zitadel Project and OIDC +application in the target cluster. The generated client secret stays in +Kubernetes and is referenced by name from `argocd-cm`; it is never stored in +the XR or Helm values. + +```yaml +spec: + exposure: + enabled: true + hostname: argocd.example.com + gatewayRef: + name: platform + namespace: istio-ingress + sectionName: https + auth: + oidc: + enabled: true + issuerURL: https://auth.example.com + rbac: + adminEmails: + - platform-admin@example.com + zitadel: + # AuthStack status.providerConfig.awsSecretsManagerPath + awsSecretsManagerPath: push/my-cluster/zitadel-credentials +``` + +Prerequisites are Gateway API, External Secrets Operator with the configured +ClusterSecretStore, Crossplane's namespaced Zitadel provider in the target +cluster, and an AuthStack-published management access token. TLS terminates at +the Gateway, so the generated HTTPRoute sends cleartext HTTP to ArgoCD inside +the cluster. ArgoCD CLI users should use `--grpc-web` through this HTTPRoute. + +When enabled, typed exposure and OIDC settings take precedence over conflicting +raw Helm values so the public URL, callback, and client remain consistent. +Local ArgoCD admin login remains enabled as a break-glass path. + ## Creation Order Resources are created as their dependencies become ready: @@ -232,8 +274,16 @@ The Usage ensures ArgoCD CRDs stay alive until all ArgoCD Application CRs are cl | `argocd.namespace` | string | no | `namespace` | Per-component namespace override | | `argocd.values` | object | no | `{}` | Helm values merged with defaults | | `argocd.overrideAllValues` | object | no | — | Helm values replacing all defaults | +| `exposure.enabled` | boolean | no | `false` | Create an ArgoCD HTTPRoute | +| `exposure.hostname` | string | with exposure | — | Public ArgoCD hostname | +| `exposure.gatewayRef.*` | object | no | `platform` / `istio-ingress` / `https` | Existing Gateway listener | +| `auth.oidc.enabled` | boolean | no | `false` | Enable native ArgoCD OIDC with Zitadel | +| `auth.oidc.issuerURL` | string | with OIDC | — | Zitadel issuer URL | +| `auth.oidc.rbac.adminEmails` | []string | no | `[]` | Explicit email-to-admin mappings | +| `auth.oidc.zitadel.awsSecretsManagerPath` | string | with OIDC | — | AuthStack-published PAT path | | `repository.org` | string | yes | — | GitHub organization | | `repository.name` | string | no | `{clusterName}-gitops` | Repository name | +| `repository.externalName` | string | no | — | Existing repository name to import instead of creating a new repository | | `repository.description` | string | no | auto-generated | Repository description | | `repository.visibility` | string | no | `private` | `public`, `private`, or `internal` | | `repository.autoInit` | boolean | no | `true` | Create initial commit (ignored when template is set) | @@ -241,6 +291,9 @@ The Usage ensures ArgoCD CRDs stay alive until all ArgoCD Application CRs are cl | `repository.template.repository` | string | no | — | Template repo name | | `repository.topics` | []string | no | `[]` | Repository topics | | `repository.deleteBranchOnMerge` | boolean | no | `true` | Auto-delete head branches on merge | +| `externalSecrets.githubToken.secretPath` | string | no | — | AWS Secrets Manager secret containing a GitHub token | +| `externalSecrets.githubToken.tokenKey` | string | no | `token` | JSON key containing the token | +| `externalSecrets.githubToken.username` | string | no | `x-access-token` | HTTPS username for ArgoCD repository credentials | | `applications.apps.enabled` | boolean | no | `true` | Deploy the root app-of-apps Application | | `applications.apps.path` | string | no | `apps` | Path in repo to sync | | `applications.crossplane.enabled` | boolean | no | `false` | Deploy Crossplane integration apps | @@ -262,7 +315,7 @@ global: | Field | Type | Description | |-------|------|-------------| | `status.ready` | boolean | `true` when all composed resources report Ready | -| `status.repository.url` | string | Full URL of the created GitHub repository | +| `status.repository.url` | string | Full URL of the managed GitHub repository | ## Dependencies diff --git a/apis/gitopsstacks/definition.yaml b/apis/gitopsstacks/definition.yaml index 132aff0..20044bb 100644 --- a/apis/gitopsstacks/definition.yaml +++ b/apis/gitopsstacks/definition.yaml @@ -125,6 +125,94 @@ spec: description: Helm values that replace all defaults. type: object x-kubernetes-preserve-unknown-fields: true + exposure: + description: Optional public exposure of the ArgoCD server through an existing Gateway API Gateway. + type: object + properties: + enabled: + description: Enable an HTTPRoute for the ArgoCD server. Defaults to false. + type: boolean + default: false + hostname: + description: Public DNS hostname for ArgoCD. + type: string + minLength: 1 + gatewayRef: + description: Existing Gateway listener that accepts the ArgoCD HTTPRoute. + type: object + properties: + name: + description: Gateway name. Defaults to platform. + type: string + namespace: + description: Gateway namespace. Defaults to istio-ingress. + type: string + sectionName: + description: Gateway listener section name. Defaults to https. + type: string + x-kubernetes-validations: + - rule: "!self.enabled || has(self.hostname)" + message: spec.exposure.hostname is required when exposure is enabled + auth: + description: Authentication configuration for ArgoCD. + type: object + properties: + oidc: + description: Native ArgoCD OIDC backed by a Zitadel application managed in the target cluster. + type: object + properties: + enabled: + description: Enable native ArgoCD OIDC with Zitadel. Defaults to false. + type: boolean + default: false + name: + description: Identity provider display name. Defaults to Zitadel. + type: string + issuerURL: + description: Public Zitadel OIDC issuer URL. + type: string + pattern: '^https://[^/?#[:space:]]+(?:/[^?#[:space:]]*)?$' + requestedScopes: + description: OIDC scopes requested by ArgoCD. Defaults to openid, profile, and email. + type: array + minItems: 1 + items: + type: string + rbac: + description: Minimal ArgoCD RBAC mappings for OIDC identities. + type: object + properties: + adminEmails: + description: Email addresses explicitly mapped to ArgoCD's built-in admin role. + type: array + items: + type: string + format: email + zitadel: + description: Zitadel management inputs used to create the Project and OIDC application. + type: object + properties: + awsSecretsManagerPath: + description: AWS Secrets Manager path containing the AuthStack access token. + type: string + minLength: 1 + accessTokenProperty: + description: JSON property containing the access token. Defaults to access_token. + type: string + secretStoreName: + description: ClusterSecretStore used to read the access token. Defaults to default. + type: string + projectName: + description: Zitadel Project name. Defaults to {clusterName}-argocd. + type: string + applicationName: + description: Zitadel OIDC application name. Defaults to {clusterName}-argocd. + type: string + required: + - awsSecretsManagerPath + x-kubernetes-validations: + - rule: "!self.enabled || (has(self.issuerURL) && has(self.zitadel))" + message: spec.auth.oidc.issuerURL and spec.auth.oidc.zitadel are required when OIDC is enabled repository: description: Configuration for the GitOps GitHub repository. type: object @@ -135,6 +223,9 @@ spec: name: description: Repository name. Defaults to {clusterName}-gitops. type: string + externalName: + description: Existing GitHub repository name to import instead of creating a new repository. + type: string description: description: Repository description. type: string @@ -177,7 +268,7 @@ spec: type: object properties: enabled: - description: Enable ESO integration. Creates an ExternalSecret to pull GitHub App credentials from AWS Secrets Manager into ArgoCD repo-creds. + description: Enable ESO integration. Creates an ExternalSecret to pull GitHub App or token credentials from AWS Secrets Manager into ArgoCD repo-creds. type: boolean secretStoreName: description: Name of the ClusterSecretStore created by SecretStack. Defaults to "default". @@ -200,6 +291,21 @@ spec: type: string required: - secretPath + githubToken: + description: GitHub token credential reference in AWS Secrets Manager. Used instead of githubApp when configured. + type: object + properties: + secretPath: + description: AWS Secrets Manager secret name containing the GitHub token. + type: string + tokenKey: + description: JSON key for the GitHub token within the secret. Defaults to "token". + type: string + username: + description: HTTPS username written to the ArgoCD repo-creds Secret. Defaults to "x-access-token". + type: string + required: + - secretPath applications: description: ArgoCD Application configuration. type: object @@ -223,6 +329,9 @@ spec: type: boolean required: - clusterName + x-kubernetes-validations: + - rule: "!has(self.auth) || !has(self.auth.oidc) || !self.auth.oidc.enabled || (has(self.exposure) && self.exposure.enabled)" + message: spec.exposure.enabled must be true when native OIDC is enabled status: description: GitopsStackStatus defines the observed state. type: object @@ -237,5 +346,12 @@ spec: url: description: Full URL of the gitops repository. type: string + oidc: + description: Observed ArgoCD OIDC client state. + type: object + properties: + clientId: + description: Zitadel client ID observed from the managed OIDC application. + type: string required: - spec diff --git a/examples/gitopsstacks/zitadel-oidc.yaml b/examples/gitopsstacks/zitadel-oidc.yaml new file mode 100644 index 0000000..a330be0 --- /dev/null +++ b/examples/gitopsstacks/zitadel-oidc.yaml @@ -0,0 +1,31 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: GitopsStack +metadata: + name: gitops + namespace: example-env +spec: + clusterName: example-cluster + labels: + team: platform + repository: + org: hops-ops + name: example-cluster-gitops + exposure: + enabled: true + hostname: argocd.example.com + gatewayRef: + name: platform + namespace: istio-ingress + sectionName: https + auth: + oidc: + enabled: true + issuerURL: https://auth.example.com + rbac: + adminEmails: + - platform-admin@example.com + zitadel: + awsSecretsManagerPath: push/example-cluster/zitadel-credentials + applications: + apps: + enabled: true diff --git a/examples/test/mocks/observed-resources/zitadel-oidc/steps/1/zitadel-oidc-client.yaml b/examples/test/mocks/observed-resources/zitadel-oidc/steps/1/zitadel-oidc-client.yaml new file mode 100644 index 0000000..4b8d789 --- /dev/null +++ b/examples/test/mocks/observed-resources/zitadel-oidc/steps/1/zitadel-oidc-client.yaml @@ -0,0 +1,14 @@ +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: gitops-zitadel-oidc-client + namespace: example-env + annotations: + crossplane.io/composition-resource-name: zitadel-oidc-client + gotemplating.fn.crossplane.io/composition-resource-name: zitadel-oidc-client +status: + conditions: + - type: Ready + status: "True" + - type: Synced + status: "True" diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl index 65cec23..b2b1b0d 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -78,6 +78,46 @@ # ============================================================================== {{- $argocdSpec := $spec.argocd | default dict }} +# ============================================================================== +# Public exposure +# ============================================================================== +{{- $exposureSpec := $spec.exposure | default dict }} +{{- $exposureEnabled := false }} +{{- if hasKey $exposureSpec "enabled" }} + {{- $exposureEnabled = $exposureSpec.enabled }} +{{- end }} +{{- $exposureHostname := $exposureSpec.hostname | default "" }} +{{- $gatewayRefSpec := $exposureSpec.gatewayRef | default dict }} +{{- if and $exposureEnabled (eq $exposureHostname "") }} + {{- fail "spec.exposure.hostname is required when spec.exposure.enabled is true" }} +{{- end }} + +# ============================================================================== +# Native OIDC backed by Zitadel +# ============================================================================== +{{- $authSpec := $spec.auth | default dict }} +{{- $oidcSpec := $authSpec.oidc | default dict }} +{{- $oidcEnabled := false }} +{{- if hasKey $oidcSpec "enabled" }} + {{- $oidcEnabled = $oidcSpec.enabled }} +{{- end }} +{{- $zitadelSpec := $oidcSpec.zitadel | default dict }} +{{- $oidcIssuerURL := $oidcSpec.issuerURL | default "" }} +{{- $zitadelDomain := $oidcIssuerURL | trimPrefix "https://" | trimPrefix "http://" }} +{{- $zitadelDomain = (splitn "/" 2 $zitadelDomain)._0 }} +{{- $oidcRbacSpec := $oidcSpec.rbac | default dict }} +{{- if $oidcEnabled }} + {{- if not $exposureEnabled }} + {{- fail "spec.exposure.enabled must be true when spec.auth.oidc.enabled is true" }} + {{- end }} + {{- if eq $oidcIssuerURL "" }} + {{- fail "spec.auth.oidc.issuerURL is required when OIDC is enabled" }} + {{- end }} + {{- if eq ($zitadelSpec.awsSecretsManagerPath | default "") "" }} + {{- fail "spec.auth.oidc.zitadel.awsSecretsManagerPath is required when OIDC is enabled" }} + {{- end }} +{{- end }} + # ============================================================================== # Repository configuration # ============================================================================== @@ -85,6 +125,14 @@ {{- $repoOrg := $repoSpec.org }} {{- $repoName := $repoSpec.name | default (printf "%s-gitops" $clusterName) }} {{- $repoUrl := printf "https://github.com/%s/%s.git" $repoOrg $repoName }} +{{- $repoAutoInit := true }} +{{- if hasKey $repoSpec "autoInit" }} + {{- $repoAutoInit = $repoSpec.autoInit }} +{{- end }} +{{- $repoDeleteBranchOnMerge := true }} +{{- if hasKey $repoSpec "deleteBranchOnMerge" }} + {{- $repoDeleteBranchOnMerge = $repoSpec.deleteBranchOnMerge }} +{{- end }} # Template config (optional) {{- $repoTemplate := $repoSpec.template | default dict }} @@ -94,6 +142,7 @@ # ============================================================================== {{- $esoSpec := $spec.externalSecrets | default dict }} {{- $ghAppSpec := $esoSpec.githubApp | default dict }} +{{- $ghTokenSpec := $esoSpec.githubToken | default dict }} # ============================================================================== # Applications configuration @@ -101,6 +150,10 @@ {{- $appsSpec := $spec.applications | default dict }} {{- $appsAppsSpec := $appsSpec.apps | default dict }} {{- $crossplaneSpec := $appsSpec.crossplane | default dict }} +{{- $appsAppsEnabled := true }} +{{- if hasKey $appsAppsSpec "enabled" }} + {{- $appsAppsEnabled = $appsAppsSpec.enabled }} +{{- end }} # ============================================================================== # Initialize $state @@ -132,15 +185,49 @@ "values" ($argocdSpec.values | default dict) "overrideAllValues" ($argocdSpec.overrideAllValues | default dict) ) + "exposure" (dict + "enabled" $exposureEnabled + "hostname" $exposureHostname + "gatewayRef" (dict + "name" ($gatewayRefSpec.name | default "platform") + "namespace" ($gatewayRefSpec.namespace | default "istio-ingress") + "sectionName" ($gatewayRefSpec.sectionName | default "https") + ) + ) + "auth" (dict + "oidc" (dict + "enabled" $oidcEnabled + "name" ($oidcSpec.name | default "Zitadel") + "issuerURL" $oidcIssuerURL + "requestedScopes" ($oidcSpec.requestedScopes | default (list "openid" "profile" "email")) + "rbac" (dict + "adminEmails" ($oidcRbacSpec.adminEmails | default list) + ) + "zitadel" (dict + "domain" $zitadelDomain + "awsSecretsManagerPath" ($zitadelSpec.awsSecretsManagerPath | default "") + "accessTokenProperty" ($zitadelSpec.accessTokenProperty | default "access_token") + "secretStoreName" ($zitadelSpec.secretStoreName | default "default") + "projectName" ($zitadelSpec.projectName | default (printf "%s-argocd" $clusterName)) + "applicationName" ($zitadelSpec.applicationName | default (printf "%s-argocd" $clusterName)) + "providerConfigName" (printf "%s-zitadel" $name) + "credentialsSecretName" (printf "%s-zitadel-credentials" $name) + "projectResourceName" (printf "%s-argocd" $name) + "oidcResourceName" (printf "%s-argocd" $name) + "clientSecretName" (printf "%s-argocd-oidc-client" $name) + ) + ) + ) "repository" (dict "org" $repoOrg "name" $repoName + "externalName" ($repoSpec.externalName | default "") "description" ($repoSpec.description | default (printf "GitOps repository for %s cluster" $clusterName)) "visibility" ($repoSpec.visibility | default "private") "template" $repoTemplate - "autoInit" ($repoSpec.autoInit | default true) + "autoInit" $repoAutoInit "topics" ($repoSpec.topics | default list) - "deleteBranchOnMerge" ($repoSpec.deleteBranchOnMerge | default true) + "deleteBranchOnMerge" $repoDeleteBranchOnMerge "allowDelete" ($repoSpec.allowDelete | default false) "url" $repoUrl ) @@ -153,10 +240,15 @@ "installationIdKey" ($ghAppSpec.installationIdKey | default "") "privateKeyKey" ($ghAppSpec.privateKeyKey | default "privateKey") ) + "githubToken" (dict + "secretPath" ($ghTokenSpec.secretPath | default "") + "tokenKey" ($ghTokenSpec.tokenKey | default "token") + "username" ($ghTokenSpec.username | default "x-access-token") + ) ) "applications" (dict "apps" (dict - "enabled" ($appsAppsSpec.enabled | default true) + "enabled" $appsAppsEnabled "path" ($appsAppsSpec.path | default "apps") ) "crossplane" (dict diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index 48f0da6..d027525 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -10,7 +10,8 @@ # ============================================================================== {{- $checkReady := dict }} -{{- range $key := list "helm-release-argocd" "github-repository" "argocd-apps" "crossplane-app" "external-secret-argocd-repo-creds" }} +{{- $checkExists := dict }} +{{- range $key := list "nodepool-gitops" "helm-release-argocd" "github-repository" "argocd-apps" "crossplane-app" "external-secret-argocd-repo-creds" "zitadel-credentials" "zitadel-provider-config" "zitadel-project" "zitadel-oidc-client" "zitadel-client-secret" }} {{- $entry := get $observed $key | default dict }} {{- $resource := $entry.resource | default dict }} {{- $status := $resource.status | default dict }} @@ -21,6 +22,19 @@ {{- end }} {{- end }} {{- $checkReady = set $checkReady $key $ready }} + {{- $checkExists = set $checkExists $key (gt (len $resource) 0) }} +{{- end }} + +# Extract the generated OAuth client ID published by provider-kubernetes. The +# value in the function request is base64-encoded connection data. Do not use +# the target OIDC resource's status.atProvider.id here; that is the Zitadel +# application resource ID, not the OAuth client ID accepted by /oauth/v2/auth. +{{- $oidcEntry := get $observed "zitadel-oidc-client" | default dict }} +{{- $oidcConnectionDetails := get $oidcEntry "connectionDetails" | default dict }} +{{- $oidcClientIdEncoded := get $oidcConnectionDetails "client_id" | default "" }} +{{- $oidcClientId := "" }} +{{- if $oidcClientIdEncoded }} + {{- $oidcClientId = $oidcClientIdEncoded | b64dec }} {{- end }} # Extract repository URL from observed state @@ -38,24 +52,75 @@ # Set observed state # ============================================================================== {{- $state = set $state "observed" (dict - "argocd" (dict "ready" (get $checkReady "helm-release-argocd")) + "nodePool" (dict + "ready" (get $checkReady "nodepool-gitops") + "exists" (get $checkExists "nodepool-gitops") + ) + "argocd" (dict + "ready" (get $checkReady "helm-release-argocd") + "exists" (get $checkExists "helm-release-argocd") + ) "repository" (dict "ready" (get $checkReady "github-repository") + "exists" (get $checkExists "github-repository") "url" $observedRepoUrl ) - "repoCreds" (dict "ready" (get $checkReady "external-secret-argocd-repo-creds")) - "appsApp" (dict "ready" (get $checkReady "argocd-apps")) - "crossplaneApp" (dict "ready" (get $checkReady "crossplane-app")) + "repoCreds" (dict + "ready" (get $checkReady "external-secret-argocd-repo-creds") + "exists" (get $checkExists "external-secret-argocd-repo-creds") + ) + "appsApp" (dict + "ready" (get $checkReady "argocd-apps") + "exists" (get $checkExists "argocd-apps") + ) + "crossplaneApp" (dict + "ready" (get $checkReady "crossplane-app") + "exists" (get $checkExists "crossplane-app") + ) + "zitadel" (dict + "credentialsReady" (get $checkReady "zitadel-credentials") + "providerConfigReady" (get $checkReady "zitadel-provider-config") + "projectReady" (get $checkReady "zitadel-project") + "oidcReady" (get $checkReady "zitadel-oidc-client") + "clientSecretReady" (get $checkReady "zitadel-client-secret") + "clientId" $oidcClientId + ) ) }} # ============================================================================== # Compute status (readiness determined by function-auto-ready) # ============================================================================== {{- $repoUrl := $state.observed.repository.url | default $state.repository.url }} +{{- $ready := and $state.observed.argocd.ready $state.observed.repository.ready }} +{{- if $state.nodePool.enabled }} + {{- $ready = and $ready $state.observed.nodePool.ready }} +{{- end }} +{{- if $state.externalSecrets.enabled }} + {{- $ready = and $ready $state.observed.repoCreds.ready }} +{{- end }} +{{- if $state.applications.apps.enabled }} + {{- $ready = and $ready $state.observed.appsApp.ready }} +{{- end }} +{{- if $state.applications.crossplane.enabled }} + {{- $ready = and $ready $state.observed.crossplaneApp.ready }} +{{- end }} +{{- if $state.auth.oidc.enabled }} + {{- $ready = and + $ready + $state.observed.zitadel.credentialsReady + $state.observed.zitadel.providerConfigReady + $state.observed.zitadel.projectReady + $state.observed.zitadel.oidcReady + $state.observed.zitadel.clientSecretReady + }} +{{- end }} {{- $state = set $state "status" (dict - "ready" false + "ready" $ready "repository" (dict "url" $repoUrl ) + "oidc" (dict + "clientId" $oidcClientId + ) ) }} diff --git a/functions/render/200-helm-release-argocd.yaml.gotmpl b/functions/render/200-helm-release-argocd.yaml.gotmpl index 223c5bd..91640c1 100644 --- a/functions/render/200-helm-release-argocd.yaml.gotmpl +++ b/functions/render/200-helm-release-argocd.yaml.gotmpl @@ -25,9 +25,9 @@ spec: repository: https://argoproj.github.io/argo-helm version: 9.7.1 namespace: {{ $argo.namespace }} + {{- $baseValues := dict }} {{- if $argo.overrideAllValues }} - values: - {{- toYaml $argo.overrideAllValues | nindent 6 }} + {{- $baseValues = deepCopy $argo.overrideAllValues }} {{- else }} {{- /* Resource defaults. application-controller is the StatefulSet that actually does reconciliation (heaviest component, holds the full @@ -79,10 +79,71 @@ spec: "dex" (dict "resources" $smallControllerResources) "redis" (dict "resources" $redisResources) }} - {{- $mergedValues := mergeOverwrite $chartDefaults ($argo.values | default dict) }} - values: - {{- toYaml $mergedValues | nindent 6 }} + {{- $baseValues = mergeOverwrite $chartDefaults ($argo.values | default dict) }} + {{- end }} + + {{- /* Typed exposure and auth are integrations, not chart defaults. When + enabled they intentionally take precedence over raw values so the + Gateway callback URL and OIDC client cannot drift apart. */}} + {{- $effectiveValues := deepCopy $baseValues }} + {{- if $state.exposure.enabled }} + {{- $exposureValues := dict + "global" (dict "domain" $state.exposure.hostname) + "configs" (dict + "cm" (dict "url" (printf "https://%s" $state.exposure.hostname)) + "params" (dict "server.insecure" "true") + ) + "server" (dict + "httproute" (dict + "enabled" true + "parentRefs" (list (dict + "name" $state.exposure.gatewayRef.name + "namespace" $state.exposure.gatewayRef.namespace + "sectionName" $state.exposure.gatewayRef.sectionName + )) + "hostnames" (list $state.exposure.hostname) + ) + ) + }} + {{- $effectiveValues = mergeOverwrite $effectiveValues $exposureValues }} + {{- end }} + + {{- $oidc := $state.auth.oidc }} + {{- if and $oidc.enabled $state.observed.zitadel.clientId }} + {{- $oidcConfig := dict + "name" $oidc.name + "issuer" $oidc.issuerURL + "clientID" $state.observed.zitadel.clientId + "clientSecret" (printf "$%s:attribute.client_secret" $oidc.zitadel.clientSecretName) + "requestedScopes" $oidc.requestedScopes + }} + {{- $oidcValues := dict "configs" (dict "cm" (dict "oidc.config" (toYaml $oidcConfig))) }} + + {{- if gt (len $oidc.rbac.adminEmails) 0 }} + {{- $adminPolicy := "" }} + {{- range $email := $oidc.rbac.adminEmails }} + {{- $line := printf "g, %s, role:admin" $email }} + {{- $adminPolicy = ternary $line (printf "%s\n%s" $adminPolicy $line) (eq $adminPolicy "") }} + {{- end }} + {{- $existingPolicy := dig "configs" "rbac" "policy.csv" "" $effectiveValues }} + {{- if $existingPolicy }} + {{- $adminPolicy = printf "%s\n%s" $existingPolicy $adminPolicy }} + {{- end }} + {{- $existingScopes := dig "configs" "rbac" "scopes" "[groups]" $effectiveValues | fromYaml | default list }} + {{- if not (has "email" $existingScopes) }} + {{- $existingScopes = append $existingScopes "email" }} + {{- end }} + {{- $oidcValues = mergeOverwrite $oidcValues (dict "configs" (dict "rbac" (dict + "policy.csv" $adminPolicy + "scopes" ($existingScopes | toJson) + ))) }} + {{- end }} + + {{- $effectiveValues = mergeOverwrite $effectiveValues $oidcValues }} {{- end }} + + values: + {{- toYaml $effectiveValues | nindent 6 }} rollbackLimit: 3 providerConfigRef: name: {{ $state.helmProviderConfigRef.name }} diff --git a/functions/render/210-github-repository.yaml.gotmpl b/functions/render/210-github-repository.yaml.gotmpl index 5620072..d7e7fa1 100644 --- a/functions/render/210-github-repository.yaml.gotmpl +++ b/functions/render/210-github-repository.yaml.gotmpl @@ -12,6 +12,9 @@ metadata: name: {{ $repo.name }} annotations: {{ setResourceNameAnnotation "github-repository" }} + {{- if $repo.externalName }} + crossplane.io/external-name: {{ $repo.externalName }} + {{- end }} labels: {{ $state.labels | toJson }} spec: {{- if $repo.allowDelete }} diff --git a/functions/render/220-argocd-projects-app.yaml.gotmpl b/functions/render/220-argocd-projects-app.yaml.gotmpl index df462d1..8bfe13f 100644 --- a/functions/render/220-argocd-projects-app.yaml.gotmpl +++ b/functions/render/220-argocd-projects-app.yaml.gotmpl @@ -12,10 +12,10 @@ {{- $repoCredsReady := true }} {{- if $state.externalSecrets.enabled }} - {{- $repoCredsReady = $obs.repoCreds.ready }} + {{- $repoCredsReady = or $obs.repoCreds.ready $obs.appsApp.exists }} {{- end }} -{{- if and $apps.apps.enabled $obs.argocd.ready $repoCredsReady }} +{{- if and $apps.apps.enabled (or $obs.argocd.ready $obs.appsApp.exists) $repoCredsReady }} --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object @@ -55,7 +55,7 @@ spec: name: {{ $state.kubernetesProviderConfigRef.name }} kind: {{ $state.kubernetesProviderConfigRef.kind }} -{{- if $obs.argocd.ready }} +{{- if or $obs.argocd.ready $obs.appsApp.exists }} --- apiVersion: protection.crossplane.io/v1beta1 kind: Usage diff --git a/functions/render/230-argocd-crossplane-apps.yaml.gotmpl b/functions/render/230-argocd-crossplane-apps.yaml.gotmpl index e9f6dd1..ca23883 100644 --- a/functions/render/230-argocd-crossplane-apps.yaml.gotmpl +++ b/functions/render/230-argocd-crossplane-apps.yaml.gotmpl @@ -12,10 +12,10 @@ {{- $repoCredsReady := true }} {{- if $state.externalSecrets.enabled }} - {{- $repoCredsReady = $obs.repoCreds.ready }} + {{- $repoCredsReady = or $obs.repoCreds.ready $obs.crossplaneApp.exists }} {{- end }} -{{- if and $apps.crossplane.enabled $obs.argocd.ready $repoCredsReady }} +{{- if and $apps.crossplane.enabled (or $obs.argocd.ready $obs.crossplaneApp.exists) $repoCredsReady }} --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object diff --git a/functions/render/240-external-secret-argocd-repo.yaml.gotmpl b/functions/render/240-external-secret-argocd-repo.yaml.gotmpl index 1a20e3d..e6429af 100644 --- a/functions/render/240-external-secret-argocd-repo.yaml.gotmpl +++ b/functions/render/240-external-secret-argocd-repo.yaml.gotmpl @@ -1,10 +1,10 @@ # code: language=yaml # -# ExternalSecret: ArgoCD repository credentials via GitHub App +# ExternalSecret: ArgoCD repository credentials # -# Creates an ExternalSecret that pulls GitHub App credentials from +# Creates an ExternalSecret that pulls GitHub App or token credentials from # AWS Secrets Manager and creates an ArgoCD repo-creds Secret. -# Gated on: externalSecrets.enabled AND argocd ready (namespace must exist) +# Gated on ArgoCD readiness until the composed resource exists. # # Prerequisite: SecretStack must be installed (provides ESO + ClusterSecretStore) # @@ -12,7 +12,7 @@ {{- $eso := $state.externalSecrets }} {{- $obs := $state.observed }} -{{- if and $eso.enabled $obs.argocd.ready }} +{{- if and $eso.enabled (or $obs.argocd.ready $obs.repoCreds.exists) }} --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object @@ -46,12 +46,23 @@ spec: data: type: git url: https://github.com/{{ $state.repository.org }} + {{- if $eso.githubToken.secretPath }} + username: {{ $eso.githubToken.username }} + password: {{ `"{{ .githubToken }}"` }} + {{- else }} githubAppID: {{ `"{{ .githubAppID }}"` }} {{- if $eso.githubApp.installationIdKey }} githubAppInstallationID: {{ `"{{ .githubAppInstallationID }}"` }} {{- end }} githubAppPrivateKey: {{ `"{{ .githubAppPrivateKey }}"` }} + {{- end }} data: + {{- if $eso.githubToken.secretPath }} + - secretKey: githubToken + remoteRef: + key: {{ $eso.githubToken.secretPath }} + property: {{ $eso.githubToken.tokenKey }} + {{- else }} - secretKey: githubAppID remoteRef: key: {{ $eso.githubApp.secretPath }} @@ -66,11 +77,12 @@ spec: remoteRef: key: {{ $eso.githubApp.secretPath }} property: {{ $eso.githubApp.privateKeyKey }} + {{- end }} providerConfigRef: name: {{ $state.kubernetesProviderConfigRef.name }} kind: {{ $state.kubernetesProviderConfigRef.kind }} -{{- if $obs.repoCreds.ready }} +{{- if or $obs.repoCreds.ready $obs.repoCreds.exists }} --- apiVersion: protection.crossplane.io/v1beta1 kind: Usage diff --git a/functions/render/400-zitadel-credentials.yaml.gotmpl b/functions/render/400-zitadel-credentials.yaml.gotmpl new file mode 100644 index 0000000..e76133c --- /dev/null +++ b/functions/render/400-zitadel-credentials.yaml.gotmpl @@ -0,0 +1,56 @@ +# code: language=yaml +# +# Pull the AuthStack management PAT into the target cluster and shape it for +# provider-upjet-zitadel. The PAT remains in AWS Secrets Manager and the target +# Secret; it never enters the GitopsStack XR or Helm values. + +{{- $oidc := $state.auth.oidc }} +{{- $z := $oidc.zitadel }} +{{- if $oidc.enabled }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-zitadel-credentials + annotations: + {{ setResourceNameAnnotation "zitadel-credentials" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromObject + forProvider: + manifest: + apiVersion: external-secrets.io/v1 + kind: ExternalSecret + metadata: + name: {{ $z.credentialsSecretName }} + namespace: {{ $state.argocd.namespace }} + labels: {{ $state.labels | toJson }} + spec: + refreshInterval: 1h + secretStoreRef: + name: {{ $z.secretStoreName }} + kind: ClusterSecretStore + target: + name: {{ $z.credentialsSecretName }} + creationPolicy: Owner + template: + engineVersion: v2 + data: + credentials: | + { + "access_token": "{{`{{ .access_token | trim }}`}}", + "domain": {{ $z.domain | quote }}, + "port": "443", + "insecure": false + } + data: + - secretKey: access_token + remoteRef: + key: {{ $z.awsSecretsManagerPath | quote }} + property: {{ $z.accessTokenProperty | quote }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} diff --git a/functions/render/410-zitadel-provider-config.yaml.gotmpl b/functions/render/410-zitadel-provider-config.yaml.gotmpl new file mode 100644 index 0000000..2878351 --- /dev/null +++ b/functions/render/410-zitadel-provider-config.yaml.gotmpl @@ -0,0 +1,59 @@ +# code: language=yaml +# +# Namespaced Zitadel ProviderConfig in the target ArgoCD namespace. + +{{- $oidc := $state.auth.oidc }} +{{- $z := $oidc.zitadel }} +{{- if $oidc.enabled }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-zitadel-provider-config + annotations: + {{ setResourceNameAnnotation "zitadel-provider-config" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: zitadel.m.crossplane.io/v1beta1 + kind: ProviderConfig + metadata: + name: {{ $z.providerConfigName }} + namespace: {{ $state.argocd.namespace }} + labels: {{ $state.labels | toJson }} + spec: + credentials: + source: Secret + secretRef: + name: {{ $z.credentialsSecretName }} + namespace: {{ $state.argocd.namespace }} + key: credentials + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +{{- if and $state.observed.zitadel.credentialsReady $state.observed.zitadel.providerConfigReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-zitadel-provider-before-credentials + annotations: + {{ setResourceNameAnnotation "usage-zitadel-provider-credentials" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-credentials + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-provider-config +{{- end }} +{{- end }} diff --git a/functions/render/420-zitadel-project.yaml.gotmpl b/functions/render/420-zitadel-project.yaml.gotmpl new file mode 100644 index 0000000..1a3ee18 --- /dev/null +++ b/functions/render/420-zitadel-project.yaml.gotmpl @@ -0,0 +1,64 @@ +# code: language=yaml +# +# Dedicated Zitadel Project for the ArgoCD OIDC application. + +{{- $oidc := $state.auth.oidc }} +{{- $z := $oidc.zitadel }} +{{- if $oidc.enabled }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-zitadel-project + annotations: + {{ setResourceNameAnnotation "zitadel-project" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromObject + forProvider: + manifest: + apiVersion: project.zitadel.m.crossplane.io/v1alpha1 + kind: Project + metadata: + name: {{ $z.projectResourceName }} + namespace: {{ $state.argocd.namespace }} + labels: {{ $state.labels | toJson }} + spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + name: {{ $z.projectName | quote }} + hasProjectCheck: false + projectRoleAssertion: true + projectRoleCheck: false + providerConfigRef: + name: {{ $z.providerConfigName }} + kind: ProviderConfig + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +{{- if and $state.observed.zitadel.providerConfigReady $state.observed.zitadel.projectReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-zitadel-project-before-provider + annotations: + {{ setResourceNameAnnotation "usage-zitadel-project-provider" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-provider-config + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-project +{{- end }} +{{- end }} diff --git a/functions/render/430-zitadel-oidc-client.yaml.gotmpl b/functions/render/430-zitadel-oidc-client.yaml.gotmpl new file mode 100644 index 0000000..bf3c491 --- /dev/null +++ b/functions/render/430-zitadel-oidc-client.yaml.gotmpl @@ -0,0 +1,139 @@ +# code: language=yaml +# +# Confidential web client used by ArgoCD's native OIDC support. The callback is +# ArgoCD's direct OIDC callback, not the embedded Dex callback. + +{{- $oidc := $state.auth.oidc }} +{{- $z := $oidc.zitadel }} +{{- if $oidc.enabled }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-zitadel-oidc-client + annotations: + {{ setResourceNameAnnotation "zitadel-oidc-client" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromObject + # Export only the generated OAuth client ID to this composed resource's + # connection details. The client secret stays in the target cluster Secret. + connectionDetails: + - apiVersion: v1 + kind: Secret + name: {{ $z.clientSecretName }} + namespace: {{ $state.argocd.namespace }} + fieldPath: data[attribute.client_id] + toConnectionSecretKey: client_id + writeConnectionSecretToRef: + name: {{ $state.name }}-zitadel-oidc-client-id + forProvider: + manifest: + apiVersion: application.zitadel.m.crossplane.io/v1alpha1 + kind: Oidc + metadata: + name: {{ $z.oidcResourceName }} + namespace: {{ $state.argocd.namespace }} + labels: {{ $state.labels | toJson }} + spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + name: {{ $z.applicationName | quote }} + projectIdRef: + name: {{ $z.projectResourceName }} + appType: OIDC_APP_TYPE_WEB + authMethodType: OIDC_AUTH_METHOD_TYPE_BASIC + grantTypes: + - OIDC_GRANT_TYPE_AUTHORIZATION_CODE + - OIDC_GRANT_TYPE_REFRESH_TOKEN + responseTypes: + - OIDC_RESPONSE_TYPE_CODE + redirectUris: + - {{ printf "https://%s/auth/callback" $state.exposure.hostname | quote }} + postLogoutRedirectUris: + - {{ printf "https://%s/" $state.exposure.hostname | quote }} + accessTokenType: OIDC_TOKEN_TYPE_BEARER + accessTokenRoleAssertion: true + idTokenRoleAssertion: true + idTokenUserinfoAssertion: true + devMode: false + writeConnectionSecretToRef: + name: {{ $z.clientSecretName }} + providerConfigRef: + name: {{ $z.providerConfigName }} + kind: ProviderConfig + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +{{- if and $state.observed.zitadel.projectReady $state.observed.zitadel.oidcReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-zitadel-oidc-before-project + annotations: + {{ setResourceNameAnnotation "usage-zitadel-oidc-project" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-project + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-oidc-client +{{- end }} + +{{- if and $state.observed.zitadel.providerConfigReady $state.observed.zitadel.oidcReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-zitadel-oidc-before-provider + annotations: + {{ setResourceNameAnnotation "usage-zitadel-oidc-provider" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-provider-config + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-oidc-client +{{- end }} + +{{- if and $state.observed.argocd.ready $state.observed.zitadel.oidcReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-argocd-before-zitadel-oidc + annotations: + {{ setResourceNameAnnotation "usage-argocd-zitadel-oidc" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-oidc-client + by: + apiVersion: helm.m.crossplane.io/v1beta1 + kind: Release + resourceRef: + name: {{ $state.argocd.name }} +{{- end }} +{{- end }} diff --git a/functions/render/440-zitadel-client-secret.yaml.gotmpl b/functions/render/440-zitadel-client-secret.yaml.gotmpl new file mode 100644 index 0000000..7442e17 --- /dev/null +++ b/functions/render/440-zitadel-client-secret.yaml.gotmpl @@ -0,0 +1,57 @@ +# code: language=yaml +# +# Add ArgoCD's discovery label after provider-upjet-zitadel creates the +# connection Secret. This Object owns only the label field through SSA; the +# OIDC resource remains responsible for the Secret lifecycle and data. + +{{- $oidc := $state.auth.oidc }} +{{- $z := $oidc.zitadel }} +{{- if and $oidc.enabled $state.observed.zitadel.clientId }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-zitadel-client-secret + annotations: + {{ setResourceNameAnnotation "zitadel-client-secret" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: + - Observe + - Update + forProvider: + manifest: + apiVersion: v1 + kind: Secret + metadata: + name: {{ $z.clientSecretName }} + namespace: {{ $state.argocd.namespace }} + labels: + app.kubernetes.io/part-of: argocd + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +{{- if $state.observed.zitadel.clientSecretReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-zitadel-oidc-before-client-secret + annotations: + {{ setResourceNameAnnotation "usage-zitadel-oidc-client-secret" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-client-secret + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-zitadel-oidc-client +{{- end }} +{{- end }} diff --git a/functions/render/999-status.yaml.gotmpl b/functions/render/999-status.yaml.gotmpl index 685bd68..6a6caa5 100644 --- a/functions/render/999-status.yaml.gotmpl +++ b/functions/render/999-status.yaml.gotmpl @@ -11,3 +11,7 @@ status: ready: {{ $state.status.ready }} repository: url: {{ $state.status.repository.url }} + {{- if $state.status.oidc.clientId }} + oidc: + clientId: {{ $state.status.oidc.clientId | quote }} + {{- end }} diff --git a/tests/test-render/main.k b/tests/test-render/main.k index 52fbe51..705a945 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -830,6 +830,84 @@ _items = [ ] } } + + # ========================================================================== + # Test 19: Gateway exposure and Zitadel OIDC render target-cluster resources + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "gateway-and-zitadel-oidc" + spec = { + compositionPath = "apis/gitopsstacks/composition.yaml" + xrdPath = "apis/gitopsstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.GitopsStack { + metadata.name = "sso-test" + spec = { + clusterName = "test-cluster" + repository.org = "hops-ops" + exposure = { + enabled = True + hostname = "argocd.example.com" + gatewayRef = { + name = "platform" + namespace = "istio-ingress" + sectionName = "https" + } + } + auth.oidc = { + enabled = True + issuerURL = "https://auth.example.com" + rbac.adminEmails = ["admin@example.com"] + zitadel.awsSecretsManagerPath = "push/test/zitadel-credentials" + } + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "argocd" + spec.forProvider.values = { + global.domain = "argocd.example.com" + configs.cm.url = "https://argocd.example.com" + configs.params = {"server.insecure" = "true"} + server.httproute = { + enabled = True + hostnames = ["argocd.example.com"] + parentRefs = [{name = "platform", namespace = "istio-ingress", sectionName = "https"}] + } + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "sso-test-zitadel-project" + spec.readiness.policy = "DeriveFromObject" + spec.forProvider.manifest.spec.forProvider.name = "test-cluster-argocd" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "sso-test-zitadel-oidc-client" + spec.readiness.policy = "DeriveFromObject" + spec.connectionDetails = [{ + apiVersion = "v1" + kind = "Secret" + name = "sso-test-argocd-oidc-client" + namespace = "argocd" + fieldPath = "data[attribute.client_id]" + toConnectionSecretKey = "client_id" + }] + spec.writeConnectionSecretToRef.name = "sso-test-zitadel-oidc-client-id" + spec.forProvider.manifest.spec.forProvider = { + projectIdRef.name = "sso-test-argocd" + redirectUris = ["https://argocd.example.com/auth/callback"] + } + } + ] + } + } ] items = _items