diff --git a/Makefile b/Makefile index 4732473..0fd59dd 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,7 @@ EXAMPLES := \ examples/authstacks/minimal.yaml:: \ examples/authstacks/standard.yaml:: \ examples/authstacks/local-colima.yaml:: \ + examples/authstacks/with-smtp.yaml:: \ examples/machineusers/minimal.yaml:: \ examples/machineusers/with-pat.yaml:: \ examples/machineusers/with-pat-push.yaml:: \ diff --git a/apis/authstacks/definition.yaml b/apis/authstacks/definition.yaml index e5b934a..ab6aebe 100644 --- a/apis/authstacks/definition.yaml +++ b/apis/authstacks/definition.yaml @@ -415,6 +415,52 @@ spec: type: string sectionName: type: string + smtp: + description: | + Configure Zitadel's SMTP provider from a platform SMTPSender + (aws-smtp-sender). When enabled, AuthStack observes the referenced + SMTPSender XR for host/port/username, pulls the SES SMTP password + from AWS Secrets Manager via ESO, and composes a + smtp.zitadel.m.crossplane.io Config MR driving Zitadel's live + management API. See [[specs/smtpsender-consumer-pattern]]. + type: object + properties: + enabled: + description: Compose the Zitadel SMTP wiring. Defaults to false. + type: boolean + smtpSenderRef: + description: The SMTPSender XR (on the control plane) to read host/port/username/awsSecretsManagerPath from. + type: object + properties: + name: + description: SMTPSender metadata.name. Required when enabled. + type: string + namespace: + description: SMTPSender namespace. Defaults to this AuthStack XR's own namespace. + type: string + fromAddress: + description: From address for Zitadel mail. MUST be on the SMTPSender's verified SES domain. Required when enabled. + type: string + fromName: + description: Display name for the From address. Defaults to the domain. + type: string + replyToAddress: + description: Optional Reply-To address. + type: string + tls: + description: Use STARTTLS (SES SMTP on port 587). Defaults to true. + type: boolean + default: true + setActive: + description: Mark this SMTP config active in Zitadel. Defaults to true. + type: boolean + default: true + secretStoreName: + description: ClusterSecretStore to pull the SMTP password from. Defaults to "hops-aws-secrets-manager". + type: string + smtpSecretName: + description: Name of the K8s Secret ESO materializes for the SMTP password. Defaults to "zitadel-smtp". + type: string chartValues: description: Escape hatch — Helm values merged on top of stack-rendered values. Prefer typed top-level fields when a knob proves load-bearing. type: object @@ -494,5 +540,27 @@ spec: type: string key: type: string + smtp: + description: SMTP wiring status, populated when spec.smtp.enabled and the SMTPSender is observed. + type: object + properties: + sourceSmtpSender: + description: The observed SMTPSender (namespace/name). + type: string + host: + type: string + port: + type: integer + username: + type: string + awsSecretsManagerPath: + description: AWS SM path holding the SES SMTP password. + type: string + dkimVerified: + description: Whether the observed SMTPSender reports DKIM verified. + type: boolean + ready: + description: Whether the SMTP Config MR is composed (the referenced SMTPSender has been observed with a published SMTP secret path). + type: boolean required: - spec diff --git a/examples/authstacks/with-smtp.yaml b/examples/authstacks/with-smtp.yaml new file mode 100644 index 0000000..c64f504 --- /dev/null +++ b/examples/authstacks/with-smtp.yaml @@ -0,0 +1,43 @@ +# AuthStack consuming a platform SMTPSender to drive Zitadel's outbound email. +# +# spec.smtp.enabled wires the whole consumer chain declaratively: +# - Observes the referenced SMTPSender for host/port/username + DKIM status +# - Pulls the SES SMTP password from AWS Secrets Manager via ESO +# - Publishes this stack's iam-admin PAT (auto-enabled) and assembles a +# zitadel ProviderConfig from it +# - Drives the running Zitadel's SMTP provider via a Config MR +# +# The operator only expresses intent — no helm values, no secret plumbing. +apiVersion: hops.ops.com.ai/v1alpha1 +kind: AuthStack +metadata: + name: auth + namespace: example-env +spec: + clusterName: example-cluster + domain: auth.example.com + externalSecure: true + firstInstance: + org: hops-ops + iamAdmin: + username: platform-admin + masterkey: + secretRef: + name: zitadel-masterkey + database: + external: + dsnSecretRef: + name: zitadel-db + key: dsn + gateway: + enabled: true + parentRef: + name: platform-gateway + namespace: istio-system + smtp: + enabled: true + smtpSenderRef: + name: ops + fromAddress: no-reply@auth.example.com + fromName: Hops Ops + replyToAddress: support@auth.example.com diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl index ac301a8..ada0d37 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -176,6 +176,33 @@ {{- $externalSecure = $spec.externalSecure }} {{- end }} +# ============================================================================== +# SMTP — consume a platform SMTPSender (see specs/smtpsender-consumer-pattern). +# Observe the SMTPSender for host/port/username, pull the SES password from AWS +# SM via ESO, and drive Zitadel's SMTP via a smtp.zitadel.m.crossplane.io Config. +# ============================================================================== +{{- $smtp := $spec.smtp | default dict }} +{{- $smtpEnabled := false }} +{{- if hasKey $smtp "enabled" }} + {{- $smtpEnabled = $smtp.enabled }} +{{- end }} +# SMTP self-contains: enabling it also publishes the iam-admin PAT (the SMTP +# ProviderConfig is just another consumer of that pushed PAT), so the operator +# only flips spec.smtp.enabled — no separate pushCredentials toggle required. +{{- if $smtpEnabled }}{{- $pushCredsEnabled = true }}{{- end }} +{{- $smtpSenderRef := $smtp.smtpSenderRef | default dict }} +{{- $smtpSenderName := $smtpSenderRef.name | default "" }} +{{- $smtpSenderNamespace := $smtpSenderRef.namespace | default ($metadata.namespace | default "default") }} +{{- $smtpFromAddress := $smtp.fromAddress | default "" }} +{{- $smtpFromName := $smtp.fromName | default $domain }} +{{- $smtpReplyTo := $smtp.replyToAddress | default "" }} +{{- $smtpTls := true }} +{{- if hasKey $smtp "tls" }}{{- $smtpTls = $smtp.tls }}{{- end }} +{{- $smtpSetActive := true }} +{{- if hasKey $smtp "setActive" }}{{- $smtpSetActive = $smtp.setActive }}{{- end }} +{{- $smtpStoreName := $smtp.secretStoreName | default "hops-aws-secrets-manager" }} +{{- $smtpSecretName := $smtp.smtpSecretName | default "zitadel-smtp" }} + # ============================================================================== # Initialize $state # ============================================================================== @@ -268,6 +295,19 @@ "sectionName" ($gwParentRef.sectionName | default "") ) ) + "smtp" (dict + "enabled" $smtpEnabled + "smtpSenderRef" (dict "name" $smtpSenderName "namespace" $smtpSenderNamespace) + "fromAddress" $smtpFromAddress + "fromName" $smtpFromName + "replyToAddress" $smtpReplyTo + "tls" $smtpTls + "setActive" $smtpSetActive + "secretStoreName" $smtpStoreName + "secretName" $smtpSecretName + "observed" (dict) + "render" false + ) "observed" (dict) "status" (dict) }} diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index af77acb..3ea1889 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -30,6 +30,48 @@ "helmRelease" (dict "ready" $relReady) ) }} +# ============================================================================== +# Observed SMTPSender (when spec.smtp.enabled). Read status.smtp.* + dkim from the +# Observe Object's atProvider.manifest. Gate all SMTP render on the observed +# awsSecretsManagerPath (composition-gate). Validate required fields early. +# ============================================================================== +{{- $smtp := $state.smtp }} +{{- if $smtp.enabled }} + {{- if not $smtp.smtpSenderRef.name }}{{- fail "spec.smtp.enabled requires spec.smtp.smtpSenderRef.name" }}{{- end }} + {{- if not $smtp.fromAddress }}{{- fail "spec.smtp.enabled requires spec.smtp.fromAddress (must be on the SMTPSender's verified SES domain)" }}{{- end }} +{{- end }} +{{- $ssEntry := get $observed "observed-smtp-sender" | default dict }} +{{- $ssManifest := dig "resource" "status" "atProvider" "manifest" dict $ssEntry }} +{{- $ssStatus := $ssManifest.status | default dict }} +{{- $ssSmtp := $ssStatus.smtp | default dict }} +{{- $ssDkim := $ssStatus.dkim | default dict }} +# Once the SMTPSender is observed, enforce the XRD contract that fromAddress is on +# its verified SES domain (or a subdomain) — otherwise SES silently rejects mail +# and the only signal is in Zitadel's logs. Skipped until the domain is observed, +# so it never blocks first-cycle bootstrap. +{{- if $smtp.enabled }} + {{- $ssDomain := dig "spec" "domain" "" $ssManifest }} + {{- $atParts := splitList "@" $smtp.fromAddress }} + {{- if and $ssDomain (eq (len $atParts) 2) }} + {{- $fromDomain := last $atParts }} + {{- if not (or (eq $fromDomain $ssDomain) (hasSuffix (printf ".%s" $ssDomain) $fromDomain)) }} + {{- fail (printf "spec.smtp.fromAddress (%s) must be on the SMTPSender's verified SES domain %q (or a subdomain of it)" $smtp.fromAddress $ssDomain) }} + {{- end }} + {{- end }} +{{- end }} +{{- $smtpObserved := dict + "host" ($ssSmtp.host | default "") + "port" ($ssSmtp.port | default 587) + "username" ($ssSmtp.username | default "") + "awsSecretsManagerPath" ($ssSmtp.awsSecretsManagerPath | default "") + "dkimVerified" ($ssDkim.verified | default false) +}} +{{- $smtpRender := and $smtp.enabled $smtp.smtpSenderRef.name (ne $smtpObserved.awsSecretsManagerPath "") }} +# mergeOverwrite (not merge): 000 pre-seeds smtp.observed={}/render=false, and +# plain `merge` gives precedence to the DEST, so the seeded zero-values would win +# and the real observed values would never propagate. Source must win here. +{{- $state = set $state "smtp" (mergeOverwrite $state.smtp (dict "observed" $smtpObserved "render" $smtpRender)) }} + # ============================================================================== # providerConfig status surface — consumer contract. # @@ -94,4 +136,13 @@ "discoveryURL" $discovery ) "bootstrap" $bootstrap + "smtp" (dict + "sourceSmtpSender" (ternary (printf "%s/%s" $smtp.smtpSenderRef.namespace $smtp.smtpSenderRef.name) "" $smtp.enabled) + "host" $smtp.observed.host + "port" $smtp.observed.port + "username" $smtp.observed.username + "awsSecretsManagerPath" $smtp.observed.awsSecretsManagerPath + "dkimVerified" $smtp.observed.dkimVerified + "ready" $smtp.render + ) ) }} diff --git a/functions/render/040-observed-smtp-sender.yaml.gotmpl b/functions/render/040-observed-smtp-sender.yaml.gotmpl new file mode 100644 index 0000000..157b8ad --- /dev/null +++ b/functions/render/040-observed-smtp-sender.yaml.gotmpl @@ -0,0 +1,33 @@ +# code: language=yaml +# +# Observe the platform SMTPSender XR to read its status.smtp.{host,port,username, +# awsSecretsManagerPath} and status.dkim.verified (see specs/smtpsender-consumer-pattern). +# +# The SMTPSender lives on the colima CONTROL PLANE, NOT the workload cluster — so this +# Object MUST use the in-cluster `default` kubernetes ProviderConfig (InjectedIdentity), +# NOT kubernetesProviderConfigRef (which targets EKS). managementPolicies: [Observe] +# means we only read the SMTPSender, never create/modify it. +# +{{- $smtp := $state.smtp }} +{{- if and $smtp.enabled $smtp.smtpSenderRef.name }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-observed-smtp-sender + annotations: + {{ setResourceNameAnnotation "observed-smtp-sender" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: ["Observe"] + forProvider: + manifest: + apiVersion: aws.hops.ops.com.ai/v1alpha1 + kind: SMTPSender + metadata: + name: {{ $smtp.smtpSenderRef.name }} + namespace: {{ $smtp.smtpSenderRef.namespace }} + providerConfigRef: + name: default + kind: ProviderConfig +{{- end }} diff --git a/functions/render/160-external-secret-smtp-password.yaml.gotmpl b/functions/render/160-external-secret-smtp-password.yaml.gotmpl new file mode 100644 index 0000000..310d801 --- /dev/null +++ b/functions/render/160-external-secret-smtp-password.yaml.gotmpl @@ -0,0 +1,55 @@ +# code: language=yaml +# +# ExternalSecret: SES SMTP password. +# +# Pulls the SES SMTP password from the SMTPSender's AWS Secrets Manager path +# (observed in 010) into a K8s Secret in the Zitadel install namespace. The +# SMTPSender stores the password as a BARE string (not JSON), so this pulls the +# whole secret value with NO `property`. The Zitadel SMTP Config MR (180) +# consumes it via passwordSecretRef. See specs/smtpsender-consumer-pattern. +# +{{- $smtp := $state.smtp }} +{{- /* Gate on stable user intent, NOT $smtp.render. The password Secret must be + durable: gating on the observed awsSecretsManagerPath would DROP (delete) the + Secret on any transient Observe miss, breaking live mail and forcing a full + re-sync (see reference_gotmpl_conditional_omit_deletes). remoteRef.key still + comes from the observed path; before it is observed ESO simply reports + SecretSyncedError and retains the last value rather than the resource vanishing. + Mirrors email-marketing 400. */ -}} +{{- if and $smtp.enabled $smtp.smtpSenderRef.name }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-external-secret-smtp-password + annotations: + {{ setResourceNameAnnotation "external-secret-smtp-password" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: external-secrets.io/v1 + kind: ExternalSecret + metadata: + name: {{ $smtp.secretName }} + namespace: {{ $state.namespace }} + labels: {{ $state.labels | toJson }} + spec: + refreshInterval: 1h + secretStoreRef: + name: {{ $smtp.secretStoreName }} + kind: ClusterSecretStore + target: + name: {{ $smtp.secretName }} + creationPolicy: Owner + data: + - secretKey: smtp-password + remoteRef: + # No `property`: the SMTPSender stores the bare SES SMTP password + # string at this path, not a JSON document. + key: {{ $smtp.observed.awsSecretsManagerPath | quote }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} diff --git a/functions/render/170-zitadel-smtp-credentials.yaml.gotmpl b/functions/render/170-zitadel-smtp-credentials.yaml.gotmpl new file mode 100644 index 0000000..ec91186 --- /dev/null +++ b/functions/render/170-zitadel-smtp-credentials.yaml.gotmpl @@ -0,0 +1,63 @@ +# code: language=yaml +# +# ExternalSecret: assemble the Zitadel ProviderConfig credentials JSON for the +# SMTP Config MR from the iam-admin PAT this stack publishes to AWS SM. +# +# spec.smtp.enabled forces the PAT PushSecret on (300), so the PAT is available +# at the push path. This pulls it and templates the credentials JSON the +# zitadel ProviderConfig (175) consumes — exactly the email-marketing 400/410 +# consumer pattern, here AuthStack consuming its own published PAT to configure +# its own Zitadel instance's SMTP. +# +{{- $smtp := $state.smtp }} +{{- $push := $state.externalSecrets.pushCredentials }} +{{- /* Stable-intent gate (not $smtp.render): the PAT path is derived from + clusterName, not observed, so this has no observed dependency — gating on + render would needlessly delete the credentials Secret + break the PC auth + chain on a transient Observe miss. Mirrors email-marketing 410. */ -}} +{{- if and $smtp.enabled $smtp.smtpSenderRef.name }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-zitadel-smtp-credentials + annotations: + {{ setResourceNameAnnotation "zitadel-smtp-credentials" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: external-secrets.io/v1 + kind: ExternalSecret + metadata: + name: zitadel-smtp-credentials + namespace: {{ $state.namespace }} + labels: {{ $state.labels | toJson }} + spec: + refreshInterval: 1h + secretStoreRef: + name: {{ $smtp.secretStoreName }} + kind: ClusterSecretStore + target: + name: zitadel-smtp-credentials + creationPolicy: Owner + template: + engineVersion: v2 + data: + credentials: | + { + "access_token": "{{`{{ .access_token | trim }}`}}", + "domain": {{ $state.domain | quote }}, + "port": "443", + "insecure": false + } + data: + - secretKey: access_token + remoteRef: + key: {{ $push.path | quote }} + property: {{ $push.accessTokenProperty | quote }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} diff --git a/functions/render/175-zitadel-smtp-providerconfig.yaml.gotmpl b/functions/render/175-zitadel-smtp-providerconfig.yaml.gotmpl new file mode 100644 index 0000000..8eea440 --- /dev/null +++ b/functions/render/175-zitadel-smtp-providerconfig.yaml.gotmpl @@ -0,0 +1,40 @@ +# code: language=yaml +# +# Zitadel ProviderConfig (install namespace) for the SMTP Config MR, sourcing the +# credentials assembled in 170. Mirrors email-marketing 410. Same-namespace as +# the Config MR (175 + 180 both live in $state.namespace on the workload cluster). +# +{{- $smtp := $state.smtp }} +{{- /* Stable-intent gate (not $smtp.render): the PC must be durable so the + Config MR (180) always has auth; deleting it on a transient Observe miss + would strand the Config MR. Mirrors email-marketing 410. */ -}} +{{- if and $smtp.enabled $smtp.smtpSenderRef.name }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-zitadel-smtp-providerconfig + annotations: + {{ setResourceNameAnnotation "zitadel-smtp-providerconfig" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: zitadel.m.crossplane.io/v1beta1 + kind: ProviderConfig + metadata: + name: {{ $state.name }}-smtp + namespace: {{ $state.namespace }} + labels: {{ $state.labels | toJson }} + spec: + credentials: + source: Secret + secretRef: + name: zitadel-smtp-credentials + namespace: {{ $state.namespace }} + key: credentials + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} diff --git a/functions/render/180-zitadel-smtp-config.yaml.gotmpl b/functions/render/180-zitadel-smtp-config.yaml.gotmpl new file mode 100644 index 0000000..fd70fc2 --- /dev/null +++ b/functions/render/180-zitadel-smtp-config.yaml.gotmpl @@ -0,0 +1,65 @@ +# code: language=yaml +# +# Zitadel SMTP Config MR — configures the running Zitadel instance's SMTP +# provider via the management API (smtp.zitadel.m.crossplane.io/v1alpha1 Config), +# authenticating through the SMTP ProviderConfig (175). host/user come from the +# observed SMTPSender status (literals); the password comes from the +# ExternalSecret-synthesized Secret (160). Wrapped in a provider-kubernetes +# Object on the workload cluster, mirroring the email-marketing Oidc MR (420). +# +# Gated ONLY on $smtp.render (= smtp.enabled + smtpSenderRef + a non-empty +# observed awsSecretsManagerPath). This is an existence-gate on the observed +# host/user/password (plain-string deps the Config MR cannot fabricate) — the one +# sanctioned use of a render gate. We deliberately do NOT gate on the Zitadel +# release's Ready condition (a .ready gate would DELETE this Config MR on every +# chart upgrade, wiping the live SMTP config — see +# reference_gotmpl_conditional_omit_deletes) nor on dkimVerified (DKIM signing +# follows the published DNS records, not SES's verification-status reporting, +# which can flip). upjet retries the apply if Zitadel is momentarily unreachable. +# +{{- $smtp := $state.smtp }} +{{- if $smtp.render }} +{{- $o := $smtp.observed }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-zitadel-smtp-config + annotations: + {{ setResourceNameAnnotation "zitadel-smtp-config" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: smtp.zitadel.m.crossplane.io/v1alpha1 + kind: Config + metadata: + name: {{ $state.name }}-smtp + namespace: {{ $state.namespace }} + labels: {{ $state.labels | toJson }} + spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + host: {{ printf "%s:%d" $o.host (int $o.port) | quote }} + user: {{ $o.username | quote }} + # passwordSecretRef is {name,key} only (no namespace in the CRD schema); + # the provider reads it from the Config MR's own namespace, where 160 + # synthesizes the Secret. + passwordSecretRef: + name: {{ $smtp.secretName }} + key: smtp-password + senderAddress: {{ $smtp.fromAddress | quote }} + senderName: {{ $smtp.fromName | quote }} + {{- if $smtp.replyToAddress }} + replyToAddress: {{ $smtp.replyToAddress | quote }} + {{- end }} + tls: {{ $smtp.tls }} + setActive: {{ $smtp.setActive }} + providerConfigRef: + name: {{ $state.name }}-smtp + kind: ProviderConfig + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} diff --git a/functions/render/999-status.yaml.gotmpl b/functions/render/999-status.yaml.gotmpl index bad5e1d..5685800 100644 --- a/functions/render/999-status.yaml.gotmpl +++ b/functions/render/999-status.yaml.gotmpl @@ -33,3 +33,13 @@ status: namespace: {{ $s.bootstrap.loginClientPatSecretRef.namespace }} key: {{ $s.bootstrap.loginClientPatSecretRef.key }} {{- end }} + {{- if $state.smtp.enabled }} + smtp: + sourceSmtpSender: {{ $s.smtp.sourceSmtpSender | quote }} + host: {{ $s.smtp.host | quote }} + port: {{ $s.smtp.port }} + username: {{ $s.smtp.username | quote }} + awsSecretsManagerPath: {{ $s.smtp.awsSecretsManagerPath | quote }} + dkimVerified: {{ $s.smtp.dkimVerified }} + ready: {{ $s.smtp.ready }} + {{- end }} diff --git a/tests/test-render/main.k b/tests/test-render/main.k index 0425b7e..e6e867b 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -1,5 +1,10 @@ import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 +_ready_conditions = [ + {type = "Ready", status = "True"} + {type = "Synced", status = "True"} +] + # ============================================================================== # Unit tests for AuthStack XRD # @@ -675,6 +680,201 @@ _items = [ ] } } + + # ========================================================================== + # Test 14: spec.smtp wires the SMTPSender consumer chain + # + # Given an observed SMTPSender (host/port/username + DKIM verified) and a + # Ready Zitadel release, spec.smtp.enabled composes the SES password + # ExternalSecret (whole-secret pull, NO property), the iam-admin PAT -> + # credentials ExternalSecret (push auto-enabled), the zitadel SMTP + # ProviderConfig, and the zitadel SMTP Config MR (host:port from observed). + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "smtp-wires-smtpsender-consumer-chain" + spec = { + compositionPath = "apis/authstacks/composition.yaml" + xrdPath = "apis/authstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "AuthStack" + metadata.name = "smtp-auth" + spec = { + clusterName = "test-cluster" + domain = "auth.example.com" + firstInstance = { + org = "hops-ops" + iamAdmin.username = "platform-admin" + masterkey.secretRef.name = "zitadel-masterkey" + } + database.external.dsnSecretRef = { + name = "zitadel-db" + key = "dsn" + } + smtp = { + enabled = True + smtpSenderRef.name = "ops" + fromAddress = "no-reply@auth.example.com" + fromName = "Hops Ops" + replyToAddress = "support@auth.example.com" + } + } + } + observedResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "smtp-auth-observed-smtp-sender" + metadata.annotations = { + "crossplane.io/composition-resource-name" = "observed-smtp-sender" + } + status.atProvider.manifest = { + apiVersion = "aws.hops.ops.com.ai/v1alpha1" + kind = "SMTPSender" + metadata.name = "ops" + status = { + smtp = { + host = "email-smtp.us-east-2.amazonaws.com" + port = 587 + username = "AKIAIOSFODNN7EXAMPLE" + awsSecretsManagerPath = "push/test-cluster/ops-smtp" + } + dkim.verified = True + } + } + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "smtp-auth-zitadel" + metadata.annotations = { + "crossplane.io/composition-resource-name" = "helm-release-zitadel" + } + status.conditions = _ready_conditions + } + ] + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "smtp-auth-external-secret-smtp-password" + spec.forProvider.manifest = { + apiVersion = "external-secrets.io/v1" + kind = "ExternalSecret" + metadata.name = "zitadel-smtp" + # Whole-secret pull: remoteRef has key but NO property. + spec.data = [{ + secretKey = "smtp-password" + remoteRef.key = "push/test-cluster/ops-smtp" + }] + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "smtp-auth-zitadel-smtp-credentials" + spec.forProvider.manifest = { + apiVersion = "external-secrets.io/v1" + kind = "ExternalSecret" + metadata.name = "zitadel-smtp-credentials" + spec.data = [{ + secretKey = "access_token" + remoteRef = {key = "push/test-cluster/zitadel-credentials", property = "access_token"} + }] + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "smtp-auth-zitadel-smtp-providerconfig" + spec.forProvider.manifest = { + apiVersion = "zitadel.m.crossplane.io/v1beta1" + kind = "ProviderConfig" + metadata.name = "smtp-auth-smtp" + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "smtp-auth-zitadel-smtp-config" + spec.forProvider.manifest = { + apiVersion = "smtp.zitadel.m.crossplane.io/v1alpha1" + kind = "Config" + spec.forProvider = { + host = "email-smtp.us-east-2.amazonaws.com:587" + user = "AKIAIOSFODNN7EXAMPLE" + senderAddress = "no-reply@auth.example.com" + senderName = "Hops Ops" + replyToAddress = "support@auth.example.com" + } + } + } + ] + } + } + + # ========================================================================== + # Test 15: SMTP durable resources render on intent alone (no observed yet) + # + # The password ES, credentials ES, and ProviderConfig gate on stable user + # intent (smtp.enabled + smtpSenderRef.name), NOT on observed state, so they + # render even before the SMTPSender is observed and therefore cannot vanish + # on a transient Observe miss (destructive-omit guard). The Config MR (180) + # gates on $smtp.render and so stays absent until the secret path is observed + # (proven present in Test 14). + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "smtp-durable-resources-render-without-observed" + spec = { + compositionPath = "apis/authstacks/composition.yaml" + xrdPath = "apis/authstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "AuthStack" + metadata.name = "smtp-noobs" + spec = { + clusterName = "test-cluster" + domain = "auth.example.com" + firstInstance = { + org = "hops-ops" + iamAdmin.username = "platform-admin" + masterkey.secretRef.name = "zitadel-masterkey" + } + database.external.dsnSecretRef = { + name = "zitadel-db" + key = "dsn" + } + smtp = { + enabled = True + smtpSenderRef.name = "ops" + fromAddress = "no-reply@auth.example.com" + } + } + } + # Deliberately NO observedResources: the SMTPSender is not yet observed. + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "smtp-noobs-external-secret-smtp-password" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "smtp-noobs-zitadel-smtp-credentials" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "smtp-noobs-zitadel-smtp-providerconfig" + } + ] + } + } ] items = _items