From 2fe4c042b48d2f2258189a5eb91b085cda91a56b Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 7 Aug 2026 21:37:05 -0500 Subject: [PATCH 1/9] feat: platform SecretStack with AWS and Vault backends Break the API group to hops.ops.com.ai and select secrets backend via spec.backend (aws|vault). Gate PodIdentity and SM stores on aws; add optional Vault Helm install plus Vault SecretStore. Gate dependents on sticky helm revision existence rather than Ready. Dogfooded locally with hops config install --path against dory (ESO + Vault Releases Ready). --- .github/workflows/on-pr.yaml | 8 +- .github/workflows/on-push-main.yaml | 8 +- .gitignore | 3 + Makefile | 8 +- README.md | 113 +++++---- apis/secretstacks/composition.yaml | 6 +- apis/secretstacks/definition.yaml | 156 +++++++++--- examples/secretstacks/minimal.yaml | 3 +- examples/secretstacks/standard.yaml | 3 +- examples/secretstacks/vault-external.yaml | 23 ++ examples/secretstacks/vault.yaml | 25 ++ .../steps/1/helm-external-secrets.yaml | 3 + .../vault/steps/1/helm-external-secrets.yaml | 15 ++ .../vault/steps/1/helm-vault.yaml | 15 ++ .../vault/steps/2/helm-external-secrets.yaml | 15 ++ .../vault/steps/2/helm-vault.yaml | 15 ++ .../vault/steps/2/secret-store.yaml | 12 + functions/render/000-state-init.yaml.gotmpl | 66 ++++- functions/render/010-state-status.yaml.gotmpl | 86 +++++-- ...-helm-release-external-secrets.yaml.gotmpl | 2 +- .../render/201-helm-release-vault.yaml.gotmpl | 55 +++++ .../render/210-aws-pod-identity.yaml.gotmpl | 11 +- functions/render/230-secret-store.yaml.gotmpl | 109 ++++++++- functions/render/999-status.yaml.gotmpl | 14 +- tests/e2etest-secretstacks/main.k | 24 +- tests/test-render/main.k | 229 +++++++++--------- upbound.yaml | 23 +- 27 files changed, 783 insertions(+), 267 deletions(-) create mode 100644 examples/secretstacks/vault-external.yaml create mode 100644 examples/secretstacks/vault.yaml create mode 100644 examples/test/mocks/observed-resources/vault/steps/1/helm-external-secrets.yaml create mode 100644 examples/test/mocks/observed-resources/vault/steps/1/helm-vault.yaml create mode 100644 examples/test/mocks/observed-resources/vault/steps/2/helm-external-secrets.yaml create mode 100644 examples/test/mocks/observed-resources/vault/steps/2/helm-vault.yaml create mode 100644 examples/test/mocks/observed-resources/vault/steps/2/secret-store.yaml create mode 100644 functions/render/201-helm-release-vault.yaml.gotmpl diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 5cfb85f..9a8f607 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -33,8 +33,12 @@ jobs: [ { "example": "examples/secretstacks/minimal.yaml" }, { "example": "examples/secretstacks/standard.yaml" }, + { "example": "examples/secretstacks/vault.yaml" }, + { "example": "examples/secretstacks/vault-external.yaml" }, { "example": "examples/secretstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/1/" }, - { "example": "examples/secretstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/2/" } + { "example": "examples/secretstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/2/" }, + { "example": "examples/secretstacks/vault.yaml", "observed_resources": "examples/test/mocks/observed-resources/vault/steps/1/" }, + { "example": "examples/secretstacks/vault.yaml", "observed_resources": "examples/test/mocks/observed-resources/vault/steps/2/" } ] api_path: apis/secretstacks error_on_missing_schemas: true @@ -59,7 +63,7 @@ jobs: } debug-resource-types: | [ - "secretstacks.aws.hops.ops.com.ai" + "secretstacks.hops.ops.com.ai" ] delete-extra-resources: | [ diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index 1e9756d..8476f47 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -29,8 +29,12 @@ jobs: [ { "example": "examples/secretstacks/minimal.yaml" }, { "example": "examples/secretstacks/standard.yaml" }, + { "example": "examples/secretstacks/vault.yaml" }, + { "example": "examples/secretstacks/vault-external.yaml" }, { "example": "examples/secretstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/1/" }, - { "example": "examples/secretstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/2/" } + { "example": "examples/secretstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/2/" }, + { "example": "examples/secretstacks/vault.yaml", "observed_resources": "examples/test/mocks/observed-resources/vault/steps/1/" }, + { "example": "examples/secretstacks/vault.yaml", "observed_resources": "examples/test/mocks/observed-resources/vault/steps/2/" } ] api_path: apis/secretstacks error_on_missing_schemas: true @@ -55,7 +59,7 @@ jobs: } debug-resource-types: | [ - "secretstacks.aws.hops.ops.com.ai" + "secretstacks.hops.ops.com.ai" ] delete-extra-resources: | [ diff --git a/.gitignore b/.gitignore index de0ee3c..c8692cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Build output _output/ .up/ +**/target/ + +# Rust experimental compose crate lock is fine to commit; ignore build dir only # Virtual environments .venv/ diff --git a/Makefile b/Makefile index 7f1bcbe..c0a937a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /bin/bash -PACKAGE ?= aws-secret-stack +PACKAGE ?= secret-stack XRD_DIR := apis/secretstacks COMPOSITION := $(XRD_DIR)/composition.yaml DEFINITION := $(XRD_DIR)/definition.yaml @@ -26,8 +26,12 @@ generate-configuration: EXAMPLES := \ examples/secretstacks/minimal.yaml:: \ examples/secretstacks/standard.yaml:: \ + examples/secretstacks/vault.yaml:: \ + examples/secretstacks/vault-external.yaml:: \ examples/secretstacks/standard.yaml::examples/test/mocks/observed-resources/standard/steps/1/ \ - examples/secretstacks/standard.yaml::examples/test/mocks/observed-resources/standard/steps/2/ + examples/secretstacks/standard.yaml::examples/test/mocks/observed-resources/standard/steps/2/ \ + examples/secretstacks/vault.yaml::examples/test/mocks/observed-resources/vault/steps/1/ \ + examples/secretstacks/vault.yaml::examples/test/mocks/observed-resources/vault/steps/2/ # Render all examples (parallel execution, output shown per-job when complete) render\:all: diff --git a/README.md b/README.md index cb33132..80dcc89 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,100 @@ -# aws-secret-stack +# secret-stack -Installs external-secrets with AWS Pod Identity for Secrets Manager and SSM Parameter Store access. Optionally creates a SecretStore. +Installs [External Secrets Operator](https://external-secrets.io/) and wires a +SecretStore to either **AWS Secrets Manager** or **HashiCorp Vault**. -## Overview +**API group:** `hops.ops.com.ai` (platform-neutral; no longer `aws.hops.ops.com.ai`). -Composes a `helm.m.crossplane.io/Release` for external-secrets with `aws.hops.ops.com.ai/PodIdentity`. -Automatically provisions IAM role and Pod Identity association for external-secrets' service account. +## Backend selection -Additionally: -- Creates a **SecretStore** (namespaced by default) or **ClusterSecretStore** (opt-in) pointing to AWS Secrets Manager, so ExternalSecrets can pull secrets immediately +| `spec.backend` | What gets created | +|----------------|-------------------| +| `aws` (default) | ESO Helm + AWS PodIdentity + Secrets Manager SecretStore | +| `vault` | ESO Helm + Vault SecretStore; optional in-cluster Vault Helm (`vault.install`) | ## Usage -Minimal — installs ESO, PodIdentity, and SecretStore: +### AWS (production EKS) ```yaml -apiVersion: aws.hops.ops.com.ai/v1alpha1 +apiVersion: hops.ops.com.ai/v1alpha1 kind: SecretStack metadata: name: external-secrets namespace: default spec: clusterName: my-cluster + backend: aws aws: region: us-east-1 ``` -With custom values and role prefix: +### Vault in-cluster (local / dory) ```yaml -apiVersion: aws.hops.ops.com.ai/v1alpha1 +apiVersion: hops.ops.com.ai/v1alpha1 kind: SecretStack metadata: name: external-secrets namespace: default spec: - clusterName: production-cluster - namespace: external-secrets - values: - serviceAccount: - create: true - aws: - region: us-west-2 - rolePrefix: prod- - tags: - environment: production -``` - -ClusterSecretStore (cluster-wide access): - -```yaml -apiVersion: aws.hops.ops.com.ai/v1alpha1 -kind: SecretStack -metadata: - name: external-secrets - namespace: default -spec: - clusterName: my-cluster + clusterName: dory + backend: vault secretStore: scope: Cluster - aws: - region: us-east-1 + name: vault + vault: + install: true + # server defaults to http://vault.vault.svc.cluster.local:8200 + auth: + method: kubernetes + role: external-secrets ``` -ESO only — no SecretStore: +### External Vault (token auth) ```yaml -apiVersion: aws.hops.ops.com.ai/v1alpha1 +apiVersion: hops.ops.com.ai/v1alpha1 kind: SecretStack metadata: name: external-secrets namespace: default spec: clusterName: my-cluster - secretStore: - enabled: false - aws: - region: us-east-1 + backend: vault + vault: + install: false + server: https://vault.example.com:8200 + auth: + method: token + tokenSecretRef: + name: vault-token + key: token ``` ## What Gets Created | Resource | Condition | Description | |----------|-----------|-------------| -| `helm.m.crossplane.io/Release` | Always | external-secrets Helm release (chart v2.2.0) | -| `aws.hops.ops.com.ai/PodIdentity` | Always | IAM role + Pod Identity with Secrets Manager, SSM, and KMS permissions | -| `kubernetes.m.crossplane.io/Object` (SecretStore) | `secretStore.enabled` (default true) | ClusterSecretStore or SecretStore wired to AWS Secrets Manager via PodIdentity JWT auth | +| `helm.m.crossplane.io/Release` (external-secrets) | Always | ESO chart | +| `helm.m.crossplane.io/Release` (vault) | `backend=vault` + `vault.install` | Official Vault chart (dev-friendly defaults) | +| `aws.hops.ops.com.ai/PodIdentity` | `backend=aws` | IAM role + Pod Identity for SM/SSM/KMS | +| `kubernetes.m.crossplane.io/Object` (SecretStore) | `secretStore.enabled` (default true) | Backend-specific SecretStore / ClusterSecretStore | ## SecretStore Options | Field | Default | Description | |-------|---------|-------------| | `secretStore.enabled` | `true` | Create a SecretStore resource | -| `secretStore.scope` | `Namespaced` | `Namespaced` for SecretStore, `Cluster` for ClusterSecretStore | +| `secretStore.scope` | `Namespaced` | `Namespaced` or `Cluster` | | `secretStore.name` | `default` | Name of the SecretStore resource | -## Status +## Breaking change (from aws-secret-stack) -| Field | Description | -|-------|-------------| -| `ready` | Overall stack readiness | +- API group: `aws.hops.ops.com.ai` → `hops.ops.com.ai` +- Package: `aws-secret-stack` → `secret-stack` +- `spec.aws` is only required when `backend=aws` +- New `spec.backend` and `spec.vault` ## Development @@ -107,5 +102,23 @@ spec: make render # render all examples make validate # validate rendered output make test # run KCL unit tests -make e2e # run E2E tests (requires AWS credentials) +``` + +### Experimental: Rust layered-gate compose + +A prototype re-expresses this stack’s render graph (existence gates + Ready +Usages) in Rust. Production still uses `functions/render/*.gotmpl`. + +```bash +cd experimental/rust-compose && cargo test +``` + +See [experimental/rust-compose/README.md](experimental/rust-compose/README.md). + +### Local install (source) + +```bash +# Confirm with the user before targeting a cluster +hops config install --path xrs/stacks/aws/secret +kubectl apply -f local/secretstack.yaml ``` diff --git a/apis/secretstacks/composition.yaml b/apis/secretstacks/composition.yaml index d5bfd8c..47f0239 100644 --- a/apis/secretstacks/composition.yaml +++ b/apis/secretstacks/composition.yaml @@ -1,15 +1,15 @@ apiVersion: apiextensions.crossplane.io/v1 kind: Composition metadata: - name: secretstacks.aws.hops.ops.com.ai + name: secretstacks.hops.ops.com.ai spec: compositeTypeRef: - apiVersion: aws.hops.ops.com.ai/v1alpha1 + apiVersion: hops.ops.com.ai/v1alpha1 kind: SecretStack mode: Pipeline pipeline: - functionRef: - name: hops-ops-aws-secret-stackrender + name: hops-ops-secret-stackrender step: render - functionRef: name: crossplane-contrib-function-auto-ready diff --git a/apis/secretstacks/definition.yaml b/apis/secretstacks/definition.yaml index e38a498..bfc3e88 100644 --- a/apis/secretstacks/definition.yaml +++ b/apis/secretstacks/definition.yaml @@ -1,9 +1,9 @@ apiVersion: apiextensions.crossplane.io/v2 kind: CompositeResourceDefinition metadata: - name: secretstacks.aws.hops.ops.com.ai + name: secretstacks.hops.ops.com.ai spec: - group: aws.hops.ops.com.ai + group: hops.ops.com.ai names: kind: SecretStack plural: secretstacks @@ -15,7 +15,10 @@ spec: served: true schema: openAPIV3Schema: - description: SecretStack installs external-secrets with AWS Pod Identity for Secrets Manager and SSM Parameter Store access. + description: | + SecretStack installs External Secrets Operator and wires a SecretStore + to either AWS Secrets Manager or HashiCorp Vault. Platform-neutral + (hops.ops.com.ai). Backend-specific resources are gated by spec.backend. type: object properties: spec: @@ -25,6 +28,16 @@ spec: clusterName: description: Name of the target cluster. Used for provider config defaults and resource naming. type: string + backend: + description: | + Secrets backend for the SecretStore. "aws" creates PodIdentity + + AWS Secrets Manager SecretStore. "vault" creates a Vault + SecretStore and optionally installs Vault via Helm. + type: string + enum: + - aws + - vault + default: aws managementPolicies: description: Management policies for all composed resources. Defaults to ["*"]. type: array @@ -51,8 +64,21 @@ spec: enum: - ProviderConfig - ClusterProviderConfig + kubernetesProviderConfigRef: + description: Reference to the Kubernetes ProviderConfig for creating Object resources. Defaults to clusterName. + type: object + properties: + name: + description: Name of the Kubernetes ProviderConfig. + type: string + kind: + description: Kind of the ProviderConfig. Defaults to ProviderConfig. + type: string + enum: + - ProviderConfig + - ClusterProviderConfig awsProviderConfigRef: - description: Reference to the AWS ProviderConfig. Defaults to "default". + description: Reference to the AWS ProviderConfig. Used when backend=aws. Defaults to "default". type: object properties: name: @@ -62,34 +88,21 @@ spec: description: Kind of the ProviderConfig. Defaults to ProviderConfig. type: string namespace: - description: Namespace for the Helm release. Defaults to external-secrets. + description: Namespace for the external-secrets Helm release. Defaults to external-secrets. type: string name: - description: Helm release name. Defaults to the XR metadata.name. + description: Helm release name for external-secrets. Defaults to "external-secrets". type: string values: - description: Helm values merged with defaults. + description: external-secrets Helm values merged with defaults. type: object x-kubernetes-preserve-unknown-fields: true overrideAllValues: - description: Helm values that replace all defaults. + description: external-secrets Helm values that replace all defaults. type: object x-kubernetes-preserve-unknown-fields: true - kubernetesProviderConfigRef: - description: Reference to the Kubernetes ProviderConfig for creating Object resources. Defaults to clusterName. - type: object - properties: - name: - description: Name of the Kubernetes ProviderConfig. - type: string - kind: - description: Kind of the ProviderConfig. Defaults to ProviderConfig. - type: string - enum: - - ProviderConfig - - ClusterProviderConfig secretStore: - description: ESO SecretStore configuration. Creates a ClusterSecretStore or namespaced SecretStore pointing to AWS Secrets Manager. + description: ESO SecretStore configuration. Provider block is derived from backend. type: object properties: enabled: @@ -105,11 +118,11 @@ spec: description: Name of the SecretStore resource. Defaults to "default". type: string aws: - description: AWS configuration for Pod Identity. + description: AWS configuration. Required when backend=aws (region). type: object properties: region: - description: AWS region for the EKS cluster. + description: AWS region for the EKS cluster and Secrets Manager. type: string permissionsBoundaryArn: description: ARN of the IAM permissions boundary to apply to the role. @@ -123,11 +136,77 @@ spec: additionalProperties: type: string x-kubernetes-preserve-unknown-fields: true - required: - - region + vault: + description: Vault configuration. Used when backend=vault. + type: object + properties: + install: + description: | + Install HashiCorp Vault via Helm in-cluster. Defaults to false. + When true, deploys the official vault chart (dev-friendly defaults; + override via values for HA/production). + type: boolean + namespace: + description: Namespace for the Vault Helm release. Defaults to "vault". + type: string + releaseName: + description: Vault Helm release name. Defaults to "vault". + type: string + values: + description: Vault Helm values merged with defaults (only when install=true). + type: object + x-kubernetes-preserve-unknown-fields: true + overrideAllValues: + description: Vault Helm values that replace all defaults (only when install=true). + type: object + x-kubernetes-preserve-unknown-fields: true + server: + description: | + Vault server URL for the SecretStore. Defaults to + http://vault.vault.svc.cluster.local:8200 when install=true, + otherwise required for SecretStore. + type: string + path: + description: Vault KV secrets engine mount path. Defaults to "secret". + type: string + version: + description: Vault KV engine version. Defaults to "v2". + type: string + enum: + - v1 + - v2 + auth: + description: | + Vault auth for ESO. Prefer kubernetes (SA JWT) in-cluster; + use tokenSecretRef for local/dev bootstrap. + type: object + properties: + method: + description: Auth method. Defaults to kubernetes when install=true, else token. + type: string + enum: + - kubernetes + - token + mountPath: + description: Vault auth mount path. Defaults to "kubernetes" (k8s method) or unused for token. + type: string + role: + description: Vault role for kubernetes auth. Defaults to "external-secrets". + type: string + tokenSecretRef: + description: K8s Secret holding a Vault token (auth.method=token). + type: object + properties: + name: + type: string + key: + type: string + default: token + namespace: + description: Namespace of the token secret. Defaults to the ESO namespace. + type: string required: - clusterName - - aws status: description: SecretStackStatus defines the observed state. type: object @@ -135,5 +214,28 @@ spec: ready: description: Overall readiness. type: boolean + backend: + description: Active secrets backend. + type: string + secretStore: + description: Observed SecretStore identity. + type: object + properties: + name: + type: string + scope: + type: string + ready: + type: boolean + vault: + description: Observed Vault release (when vault.install). + type: object + properties: + releaseName: + type: string + namespace: + type: string + ready: + type: boolean required: - spec diff --git a/examples/secretstacks/minimal.yaml b/examples/secretstacks/minimal.yaml index eba0b8e..28fc940 100644 --- a/examples/secretstacks/minimal.yaml +++ b/examples/secretstacks/minimal.yaml @@ -1,9 +1,10 @@ -apiVersion: aws.hops.ops.com.ai/v1alpha1 +apiVersion: hops.ops.com.ai/v1alpha1 kind: SecretStack metadata: name: external-secrets namespace: default spec: clusterName: my-cluster + backend: aws aws: region: us-east-1 diff --git a/examples/secretstacks/standard.yaml b/examples/secretstacks/standard.yaml index f48e2c9..a6d0a85 100644 --- a/examples/secretstacks/standard.yaml +++ b/examples/secretstacks/standard.yaml @@ -1,10 +1,11 @@ -apiVersion: aws.hops.ops.com.ai/v1alpha1 +apiVersion: hops.ops.com.ai/v1alpha1 kind: SecretStack metadata: name: external-secrets namespace: default spec: clusterName: production-cluster + backend: aws namespace: external-secrets labels: team: platform diff --git a/examples/secretstacks/vault-external.yaml b/examples/secretstacks/vault-external.yaml new file mode 100644 index 0000000..fd1d31d --- /dev/null +++ b/examples/secretstacks/vault-external.yaml @@ -0,0 +1,23 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: SecretStack +metadata: + name: external-secrets + namespace: default +spec: + clusterName: my-cluster + backend: vault + namespace: external-secrets + secretStore: + enabled: true + scope: Namespaced + name: vault + vault: + install: false + server: https://vault.example.com:8200 + path: secret + version: v2 + auth: + method: token + tokenSecretRef: + name: vault-token + key: token diff --git a/examples/secretstacks/vault.yaml b/examples/secretstacks/vault.yaml new file mode 100644 index 0000000..cb95583 --- /dev/null +++ b/examples/secretstacks/vault.yaml @@ -0,0 +1,25 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: SecretStack +metadata: + name: external-secrets + namespace: default +spec: + clusterName: dory + backend: vault + namespace: external-secrets + labels: + team: platform + secretStore: + enabled: true + scope: Cluster + name: vault + vault: + install: true + namespace: vault + # server defaults to http://vault.vault.svc.cluster.local:8200 when install=true + path: secret + version: v2 + auth: + method: kubernetes + mountPath: kubernetes + role: external-secrets diff --git a/examples/test/mocks/observed-resources/standard/steps/1/helm-external-secrets.yaml b/examples/test/mocks/observed-resources/standard/steps/1/helm-external-secrets.yaml index ab1c309..a2239f0 100644 --- a/examples/test/mocks/observed-resources/standard/steps/1/helm-external-secrets.yaml +++ b/examples/test/mocks/observed-resources/standard/steps/1/helm-external-secrets.yaml @@ -5,6 +5,9 @@ metadata: annotations: crossplane.io/composition-resource-name: helm-external-secrets status: + atProvider: + revision: 1 + state: deployed conditions: - type: Ready status: "True" diff --git a/examples/test/mocks/observed-resources/vault/steps/1/helm-external-secrets.yaml b/examples/test/mocks/observed-resources/vault/steps/1/helm-external-secrets.yaml new file mode 100644 index 0000000..a2239f0 --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/1/helm-external-secrets.yaml @@ -0,0 +1,15 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: external-secrets + annotations: + crossplane.io/composition-resource-name: helm-external-secrets +status: + atProvider: + revision: 1 + state: deployed + conditions: + - type: Ready + status: "True" + - type: Synced + status: "True" diff --git a/examples/test/mocks/observed-resources/vault/steps/1/helm-vault.yaml b/examples/test/mocks/observed-resources/vault/steps/1/helm-vault.yaml new file mode 100644 index 0000000..1baeedc --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/1/helm-vault.yaml @@ -0,0 +1,15 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: vault + annotations: + crossplane.io/composition-resource-name: helm-vault +status: + atProvider: + revision: 1 + state: deployed + conditions: + - type: Ready + status: "True" + - type: Synced + status: "True" diff --git a/examples/test/mocks/observed-resources/vault/steps/2/helm-external-secrets.yaml b/examples/test/mocks/observed-resources/vault/steps/2/helm-external-secrets.yaml new file mode 100644 index 0000000..a2239f0 --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/2/helm-external-secrets.yaml @@ -0,0 +1,15 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: external-secrets + annotations: + crossplane.io/composition-resource-name: helm-external-secrets +status: + atProvider: + revision: 1 + state: deployed + conditions: + - type: Ready + status: "True" + - type: Synced + status: "True" diff --git a/examples/test/mocks/observed-resources/vault/steps/2/helm-vault.yaml b/examples/test/mocks/observed-resources/vault/steps/2/helm-vault.yaml new file mode 100644 index 0000000..1baeedc --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/2/helm-vault.yaml @@ -0,0 +1,15 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: vault + annotations: + crossplane.io/composition-resource-name: helm-vault +status: + atProvider: + revision: 1 + state: deployed + conditions: + - type: Ready + status: "True" + - type: Synced + status: "True" diff --git a/examples/test/mocks/observed-resources/vault/steps/2/secret-store.yaml b/examples/test/mocks/observed-resources/vault/steps/2/secret-store.yaml new file mode 100644 index 0000000..401a642 --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/2/secret-store.yaml @@ -0,0 +1,12 @@ +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: external-secrets-cluster-secret-store + annotations: + crossplane.io/composition-resource-name: secret-store +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 0f3b29c..ade3139 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -14,6 +14,7 @@ {{- $clusterName := $spec.clusterName | default $name }} {{- $namespace := $spec.namespace | default "external-secrets" }} {{- $managementPolicies := $spec.managementPolicies | default (list "*") }} +{{- $backend := $spec.backend | default "aws" }} # Labels {{- $defaultLabels := dict @@ -44,44 +45,97 @@ }} # ============================================================================== -# AWS defaults +# Backend: AWS # ============================================================================== {{- $aws := $spec.aws | default dict }} -{{- $awsRegion := $aws.region }} - -# Default tags +{{- $awsRegion := $aws.region | default "" }} {{- $defaultTags := dict "hops.ops.com.ai/managed" "true" (printf "hops.ops.com.ai/%s" (lower $xr.kind)) $name }} {{- $awsTags := merge $defaultTags ($aws.tags | default dict) }} +{{- $awsEnabled := eq $backend "aws" }} # ============================================================================== -# Initialize $state +# Backend: Vault +# ============================================================================== +{{- $vault := $spec.vault | default dict }} +{{- $vaultInstall := $vault.install | default false }} +{{- $vaultNamespace := $vault.namespace | default "vault" }} +{{- $vaultReleaseName := $vault.releaseName | default "vault" }} +{{- $vaultDefaultServer := printf "http://%s.%s.svc.cluster.local:8200" $vaultReleaseName $vaultNamespace }} +{{- $vaultServer := $vault.server | default "" }} +{{- if and (eq $vaultServer "") $vaultInstall }} + {{- $vaultServer = $vaultDefaultServer }} +{{- end }} +{{- $vaultPath := $vault.path | default "secret" }} +{{- $vaultVersion := $vault.version | default "v2" }} +{{- $vaultAuth := $vault.auth | default dict }} +{{- $vaultAuthMethod := $vaultAuth.method | default "" }} +{{- if eq $vaultAuthMethod "" }} + {{- if $vaultInstall }} + {{- $vaultAuthMethod = "kubernetes" }} + {{- else }} + {{- $vaultAuthMethod = "token" }} + {{- end }} +{{- end }} +{{- $vaultAuthMountPath := $vaultAuth.mountPath | default "kubernetes" }} +{{- $vaultAuthRole := $vaultAuth.role | default "external-secrets" }} +{{- $tokenSecretRef := $vaultAuth.tokenSecretRef | default dict }} +{{- $vaultEnabled := eq $backend "vault" }} + +# ============================================================================== +# SecretStore # ============================================================================== {{- $secretStoreSpec := $spec.secretStore | default dict }} +# ============================================================================== +# Initialize $state +# ============================================================================== {{- $state := dict "name" $name "clusterName" $clusterName "namespace" $namespace "managementPolicies" $managementPolicies "labels" $labels + "backend" $backend "helm" (dict "providerConfigRef" $helmProviderConfigRef - "releaseName" "external-secrets" + "releaseName" ($spec.name | default "external-secrets") "namespace" $namespace "values" ($spec.values | default dict) "overrideAllValues" ($spec.overrideAllValues | default dict) ) "kubernetesProviderConfigRef" $k8sProviderConfigRef "aws" (dict + "enabled" $awsEnabled "region" $awsRegion "providerConfigRef" $awsProviderConfigRef "permissionsBoundaryArn" ($aws.permissionsBoundaryArn | default "") "rolePrefix" ($aws.rolePrefix | default "") "tags" $awsTags ) + "vault" (dict + "enabled" $vaultEnabled + "install" (and $vaultEnabled $vaultInstall) + "namespace" $vaultNamespace + "releaseName" $vaultReleaseName + "server" $vaultServer + "path" $vaultPath + "version" $vaultVersion + "auth" (dict + "method" $vaultAuthMethod + "mountPath" $vaultAuthMountPath + "role" $vaultAuthRole + "tokenSecretRef" (dict + "name" ($tokenSecretRef.name | default "vault-token") + "key" ($tokenSecretRef.key | default "token") + "namespace" ($tokenSecretRef.namespace | default $namespace) + ) + ) + "values" ($vault.values | default dict) + "overrideAllValues" ($vault.overrideAllValues | default dict) + ) "secretStore" (dict "enabled" ($secretStoreSpec.enabled | default true) "scope" ($secretStoreSpec.scope | default "Namespaced") diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index 0467d15..d8c7ced 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -1,6 +1,13 @@ # code: language=yaml # -# Compute status values from observed state +# Compute status + observed slices from composed resources. +# +# Capture both: +# - ready → status UX + Usage gates only +# - exists → sticky render gates for dependents (never use ready for that) +# +# Helm sticky signal: atProvider.revision > 0 (installed at least once). +# See xrd-authoring references/observed-state-pattern.md # # ============================================================================== @@ -8,41 +15,60 @@ # ============================================================================== {{- $observed := $.observed.resources | default dict }} -# Check if helm-external-secrets is ready +# --- helm-external-secrets --- {{- $helmExternalSecretsObs := get $observed "helm-external-secrets" | default dict }} {{- $helmExternalSecretsResource := $helmExternalSecretsObs.resource | default dict }} {{- $helmExternalSecretsStatus := $helmExternalSecretsResource.status | default dict }} -{{- $helmExternalSecretsConditions := $helmExternalSecretsStatus.conditions | default list }} +{{- $helmExternalSecretsAt := $helmExternalSecretsStatus.atProvider | default dict }} {{- $helmExternalSecretsReady := false }} -{{- range $cond := $helmExternalSecretsConditions }} +{{- range $cond := ($helmExternalSecretsStatus.conditions | default list) }} {{- if and (eq $cond.type "Ready") (eq $cond.status "True") }} {{- $helmExternalSecretsReady = true }} {{- end }} {{- end }} +{{- $helmExternalSecretsRevision := $helmExternalSecretsAt.revision | default 0 | int }} +{{- $helmExternalSecretsExists := gt $helmExternalSecretsRevision 0 }} -# Check if pod-identity is ready +# --- helm-vault (optional) --- +{{- $helmVaultObs := get $observed "helm-vault" | default dict }} +{{- $helmVaultResource := $helmVaultObs.resource | default dict }} +{{- $helmVaultStatus := $helmVaultResource.status | default dict }} +{{- $helmVaultAt := $helmVaultStatus.atProvider | default dict }} +{{- $helmVaultReady := false }} +{{- range $cond := ($helmVaultStatus.conditions | default list) }} + {{- if and (eq $cond.type "Ready") (eq $cond.status "True") }} + {{- $helmVaultReady = true }} + {{- end }} +{{- end }} +{{- $helmVaultRevision := $helmVaultAt.revision | default 0 | int }} +{{- $helmVaultExists := gt $helmVaultRevision 0 }} + +# --- pod-identity --- {{- $podIdentityObs := get $observed "pod-identity" | default dict }} {{- $podIdentityResource := $podIdentityObs.resource | default dict }} {{- $podIdentityStatus := $podIdentityResource.status | default dict }} -{{- $podIdentityConditions := $podIdentityStatus.conditions | default list }} {{- $podIdentityReady := false }} -{{- range $cond := $podIdentityConditions }} +{{- range $cond := ($podIdentityStatus.conditions | default list) }} {{- if and (eq $cond.type "Ready") (eq $cond.status "True") }} {{- $podIdentityReady = true }} {{- end }} {{- end }} +# Nested XR: treat observed entry with Ready OR any status payload as exists for +# Usage pairing only; PodIdentity is not a render gate for SecretStore on AWS +# (store only needs ESO CRDs). Keep ready for Usage. +{{- $podIdentityExists := or $podIdentityReady (not (empty $podIdentityResource)) }} -# Check if secret-store is ready +# --- secret-store --- {{- $secretStoreObs := get $observed "secret-store" | default dict }} {{- $secretStoreResource := $secretStoreObs.resource | default dict }} {{- $secretStoreStatus := $secretStoreResource.status | default dict }} -{{- $secretStoreConditions := $secretStoreStatus.conditions | default list }} {{- $secretStoreReady := false }} -{{- range $cond := $secretStoreConditions }} +{{- range $cond := ($secretStoreStatus.conditions | default list) }} {{- if and (eq $cond.type "Ready") (eq $cond.status "True") }} {{- $secretStoreReady = true }} {{- end }} {{- end }} +{{- $secretStoreExists := not (empty $secretStoreResource) }} # ============================================================================== # Compute status output @@ -52,11 +78,41 @@ {{- $ready := false }} {{- $state = set $state "observed" (dict - "helmExternalSecrets" (dict "ready" $helmExternalSecretsReady) - "podIdentity" (dict "ready" $podIdentityReady) - "secretStore" (dict "ready" $secretStoreReady) + "helmExternalSecrets" (dict + "ready" $helmExternalSecretsReady + "exists" $helmExternalSecretsExists + "revision" $helmExternalSecretsRevision + ) + "helmVault" (dict + "ready" $helmVaultReady + "exists" $helmVaultExists + "revision" $helmVaultRevision + ) + "podIdentity" (dict + "ready" $podIdentityReady + "exists" $podIdentityExists + ) + "secretStore" (dict + "ready" $secretStoreReady + "exists" $secretStoreExists + ) ) }} -{{- $state = set $state "status" (dict +{{- $status := dict "ready" $ready -) }} + "backend" $state.backend + "secretStore" (dict + "name" $state.secretStore.name + "scope" $state.secretStore.scope + "ready" $secretStoreReady + ) +}} +{{- if $state.vault.install }} + {{- $status = merge $status (dict "vault" (dict + "releaseName" $state.vault.releaseName + "namespace" $state.vault.namespace + "ready" $helmVaultReady + )) }} +{{- end }} + +{{- $state = set $state "status" $status }} diff --git a/functions/render/200-helm-release-external-secrets.yaml.gotmpl b/functions/render/200-helm-release-external-secrets.yaml.gotmpl index fdf131d..d23e506 100644 --- a/functions/render/200-helm-release-external-secrets.yaml.gotmpl +++ b/functions/render/200-helm-release-external-secrets.yaml.gotmpl @@ -37,7 +37,7 @@ spec: version: "2.2.0" namespace: {{ $state.helm.namespace }} values: - {{- if gt (len $state.helm.overrideAllValues) 0 }} + {{- if $state.helm.overrideAllValues }} {{- toYaml $state.helm.overrideAllValues | nindent 6 }} {{- else }} {{- toYaml $mergedValues | nindent 6 }} diff --git a/functions/render/201-helm-release-vault.yaml.gotmpl b/functions/render/201-helm-release-vault.yaml.gotmpl new file mode 100644 index 0000000..b2929b3 --- /dev/null +++ b/functions/render/201-helm-release-vault.yaml.gotmpl @@ -0,0 +1,55 @@ +# code: language=yaml +# +# Optional HashiCorp Vault Helm release (backend=vault + vault.install=true) +# +# Chart name/repository/version are literals for Renovate detection. +# Defaults are local/dev-friendly (standalone + injector off); override via +# vault.values / vault.overrideAllValues for production HA. +# + +{{- if $state.vault.install }} + +{{- $chartDefaults := dict + "global" (dict "enabled" true "tlsDisable" true) + "injector" (dict "enabled" false) + "server" (dict + "dev" (dict "enabled" true) + "standalone" (dict "enabled" true) + "dataStorage" (dict "enabled" false) + "resources" (dict + "requests" (dict "cpu" "50m" "memory" "128Mi") + "limits" (dict "cpu" "500m" "memory" "512Mi") + ) + ) + "ui" (dict "enabled" true) +}} +{{- $mergedValues := mergeOverwrite $chartDefaults ($state.vault.values | default dict) }} + +--- +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: {{ $state.vault.releaseName }} + annotations: + {{ setResourceNameAnnotation "helm-vault" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + chart: + name: vault + repository: https://helm.releases.hashicorp.com + version: "0.30.0" + namespace: {{ $state.vault.namespace }} + values: + {{- if $state.vault.overrideAllValues }} + {{- toYaml $state.vault.overrideAllValues | nindent 6 }} + {{- else }} + {{- toYaml $mergedValues | nindent 6 }} + {{- end }} + rollbackLimit: 3 + providerConfigRef: + name: {{ $state.helm.providerConfigRef.name }} + kind: {{ $state.helm.providerConfigRef.kind }} + +{{- end }} diff --git a/functions/render/210-aws-pod-identity.yaml.gotmpl b/functions/render/210-aws-pod-identity.yaml.gotmpl index 823740e..74f9a1c 100644 --- a/functions/render/210-aws-pod-identity.yaml.gotmpl +++ b/functions/render/210-aws-pod-identity.yaml.gotmpl @@ -1,8 +1,11 @@ # code: language=yaml # -# AWS Pod Identity for external-secrets Secrets Manager and SSM Parameter Store access +# AWS Pod Identity for external-secrets Secrets Manager and SSM Parameter Store access. +# Only rendered when backend=aws. # +{{- if $state.aws.enabled }} + --- apiVersion: aws.hops.ops.com.ai/v1alpha1 kind: PodIdentity @@ -70,11 +73,7 @@ spec: } tags: {{ $state.aws.tags | toJson }} -# ============================================================================== # Usage: Protect PodIdentity from deletion until Helm release is deleted -# ============================================================================== -# external-secrets pods depend on the PodIdentity for AWS credentials. -# Deleting PodIdentity first would break external-secrets' ability to sync secrets. {{- if and $state.observed.helmExternalSecrets.ready $state.observed.podIdentity.ready }} --- apiVersion: protection.crossplane.io/v1beta1 @@ -97,3 +96,5 @@ spec: name: {{ $state.helm.releaseName }} replayDeletion: true {{- end }} + +{{- end }} diff --git a/functions/render/230-secret-store.yaml.gotmpl b/functions/render/230-secret-store.yaml.gotmpl index fae8522..8a596a0 100644 --- a/functions/render/230-secret-store.yaml.gotmpl +++ b/functions/render/230-secret-store.yaml.gotmpl @@ -1,15 +1,74 @@ # code: language=yaml # -# SecretStore: AWS Secrets Manager +# SecretStore / ClusterSecretStore for ESO. +# Provider block is backend-specific (AWS Secrets Manager or Vault). # -# Creates a ClusterSecretStore or namespaced SecretStore pointing to -# AWS Secrets Manager using the external-secrets service account -# (which has PodIdentity). Gated on ESO being ready (CRDs must exist). +# Gating (sticky existence — NEVER .ready): +# - ESO Helm must have installed once (revision > 0 → exists) so CRDs exist. +# - Vault + vault.install: Vault Helm must have installed once (exists). +# Ready can flip False on chart upgrades; existence (revision) stays set, so +# un-render cannot delete a live SecretStore mid-upgrade. +# Usages still gate on .ready (deliberate exception). # {{- $ss := $state.secretStore }} +{{- $esoExists := $state.observed.helmExternalSecrets.exists }} +{{- $vaultExistsIfNeeded := true }} +{{- if $state.vault.install }} + {{- $vaultExistsIfNeeded = $state.observed.helmVault.exists }} +{{- end }} +{{- $shouldRender := and $ss.enabled $esoExists }} +{{- if and $shouldRender $state.vault.enabled }} + {{- $shouldRender = and $shouldRender $vaultExistsIfNeeded (ne $state.vault.server "") }} +{{- end }} +{{- if and $shouldRender $state.aws.enabled }} + {{- $shouldRender = and $shouldRender (ne $state.aws.region "") }} +{{- end }} -{{- if and $ss.enabled $state.observed.helmExternalSecrets.ready }} +{{- if $shouldRender }} + +{{- /* Build provider manifest fragment */}} +{{- $provider := dict }} +{{- if $state.aws.enabled }} + {{- $provider = dict "aws" (dict + "service" "SecretsManager" + "region" $state.aws.region + ) }} +{{- else if $state.vault.enabled }} + {{- $vaultProvider := dict + "server" $state.vault.server + "path" $state.vault.path + "version" $state.vault.version + }} + {{- if eq $state.vault.auth.method "kubernetes" }} + {{- $saRef := dict "name" $state.serviceAccount.name }} + {{- /* ClusterSecretStore requires SA namespace; namespaced SecretStore inherits */}} + {{- if eq $ss.scope "Cluster" }} + {{- $saRef = merge $saRef (dict "namespace" $state.serviceAccount.namespace) }} + {{- end }} + {{- $vaultProvider = merge $vaultProvider (dict "auth" (dict + "kubernetes" (dict + "mountPath" $state.vault.auth.mountPath + "role" $state.vault.auth.role + "serviceAccountRef" $saRef + ) + )) }} + {{- else }} + {{- $tokenRef := dict + "name" $state.vault.auth.tokenSecretRef.name + "key" $state.vault.auth.tokenSecretRef.key + }} + {{- /* ClusterSecretStore tokenSecretRef needs namespace */}} + {{- if or (eq $ss.scope "Cluster") $state.vault.auth.tokenSecretRef.namespace }} + {{- $tokenNs := $state.vault.auth.tokenSecretRef.namespace | default $state.namespace }} + {{- $tokenRef = merge $tokenRef (dict "namespace" $tokenNs) }} + {{- end }} + {{- $vaultProvider = merge $vaultProvider (dict "auth" (dict + "tokenSecretRef" $tokenRef + )) }} + {{- end }} + {{- $provider = dict "vault" $vaultProvider }} +{{- end }} {{- if eq $ss.scope "Cluster" }} --- @@ -30,9 +89,7 @@ spec: name: {{ $ss.name }} spec: provider: - aws: - service: SecretsManager - region: {{ $state.aws.region }} + {{- toYaml $provider | nindent 10 }} providerConfigRef: name: {{ $state.kubernetesProviderConfigRef.name }} kind: {{ $state.kubernetesProviderConfigRef.kind }} @@ -56,16 +113,13 @@ spec: namespace: {{ $state.namespace }} spec: provider: - aws: - service: SecretsManager - region: {{ $state.aws.region }} + {{- toYaml $provider | nindent 10 }} providerConfigRef: name: {{ $state.kubernetesProviderConfigRef.name }} kind: {{ $state.kubernetesProviderConfigRef.kind }} {{- end }} -# Usage: delete SecretStore before ESO Helm release -# (SecretStore CRD is owned by the Helm chart) +# Usage: delete SecretStore before ESO Helm release (CRD owned by chart) {{- if $state.observed.secretStore.ready }} --- apiVersion: protection.crossplane.io/v1beta1 @@ -93,4 +147,33 @@ spec: {{- end }} {{- end }} +# Usage: when Vault is installed by this stack, delete SecretStore before Vault +# (SecretStore talks to Vault; vault teardown first would break ESO reconciliation) +{{- if and $state.vault.install $state.observed.secretStore.ready $state.observed.helmVault.ready }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-secret-store-before-vault + annotations: + {{ setResourceNameAnnotation "usage-secret-store-vault" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: helm.m.crossplane.io/v1beta1 + kind: Release + resourceRef: + name: {{ $state.vault.releaseName }} + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + {{- if eq $ss.scope "Cluster" }} + name: {{ $state.name }}-cluster-secret-store + {{- else }} + name: {{ $state.name }}-secret-store + {{- end }} +{{- end }} + {{- end }} diff --git a/functions/render/999-status.yaml.gotmpl b/functions/render/999-status.yaml.gotmpl index 2190763..1b2de26 100644 --- a/functions/render/999-status.yaml.gotmpl +++ b/functions/render/999-status.yaml.gotmpl @@ -4,8 +4,20 @@ # {{- $xr := getCompositeResource . }} +{{- $s := $state.status }} --- apiVersion: {{ $xr.apiVersion }} kind: {{ $xr.kind }} status: - ready: {{ $state.status.ready }} + ready: {{ $s.ready }} + backend: {{ $s.backend }} + secretStore: + name: {{ $s.secretStore.name }} + scope: {{ $s.secretStore.scope }} + ready: {{ $s.secretStore.ready }} + {{- if $s.vault }} + vault: + releaseName: {{ $s.vault.releaseName }} + namespace: {{ $s.vault.namespace }} + ready: {{ $s.vault.ready }} + {{- end }} diff --git a/tests/e2etest-secretstacks/main.k b/tests/e2etest-secretstacks/main.k index 32223da..326360b 100644 --- a/tests/e2etest-secretstacks/main.k +++ b/tests/e2etest-secretstacks/main.k @@ -1,11 +1,11 @@ -# e2etest-secretstacks - aws SecretStack E2E Test -# ================================================ +# e2etest-secretstacks - platform SecretStack E2E Test (AWS backend) +# ================================================================== # Tests installation of external-secrets with AWS Pod Identity -# and ClusterSecretStore. +# and ClusterSecretStore (backend=aws). # # This test creates: # 1. An AutoEKSCluster using subnet IDs from the persistent hops-test network -# 2. An aws SecretStack that composes: +# 2. A SecretStack that composes: # - helm.m.crossplane.io/Release (external-secrets Helm release) # - aws.hops.ops.com.ai/PodIdentity (IAM role + Pod Identity for Secrets Manager/SSM) # - kubernetes.m.crossplane.io/Object (SecretStore) @@ -30,7 +30,6 @@ import file import datetime import math import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.ai.com.ops.hops.aws.v1alpha1 as awsv1alpha1 # Read AWS credentials from local file (gitignored) _creds = file.read("secrets/aws-creds") @@ -232,18 +231,25 @@ _items = [ # manifests: SecretStack only — deleted during normal cleanup # Note: Uses persistent hops-test network - no Network manifest needed manifests = [ - awsv1alpha1.SecretStack { - metadata.name = "external-secrets" - metadata.namespace = _namespace + { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata = { + name = "external-secrets" + namespace = _namespace + } spec = { clusterName = _test_name + backend = "aws" # helmProviderConfigRef defaults to clusterName (uses ProviderConfig created by AutoEKSCluster) # awsProviderConfigRef defaults to "default" # kubernetesProviderConfigRef defaults to clusterName secretStore = { scope = "Cluster" } - aws.region = _region + aws = { + region = _region + } } } ] diff --git a/tests/test-render/main.k b/tests/test-render/main.k index 28eb67e..2e4c0b3 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -2,26 +2,27 @@ import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 import models.k8s.apimachinery.pkg.apis.meta.v1 as metav1 # ============================================================================== -# Unit tests for aws SecretStack XRD +# Unit tests for platform SecretStack XRD # ============================================================================== _items = [ # ========================================================================== - # Test 1: Minimal input renders Helm Release and Pod Identity + # Test 1: AWS minimal renders Helm Release and Pod Identity # ========================================================================== metav1alpha1.CompositionTest { - metadata.name = "minimal-renders-helm-and-pod-identity" + metadata.name = "aws-minimal-renders-helm-and-pod-identity" spec = { compositionPath = "apis/secretstacks/composition.yaml" xrdPath = "apis/secretstacks/definition.yaml" timeoutSeconds = 60 validate = False xr = { - apiVersion = "aws.hops.ops.com.ai/v1alpha1" + apiVersion = "hops.ops.com.ai/v1alpha1" kind = "SecretStack" metadata.name = "test" spec = { clusterName = "my-cluster" + backend = "aws" aws = { region = "us-east-1" } @@ -34,20 +35,6 @@ _items = [ metadata.name = "external-secrets" spec.forProvider = { chart.name = "external-secrets" - values = { - resources = { - requests = {cpu = "15m", memory = "100Mi"} - limits = {cpu = "100m", memory = "256Mi"} - } - certController.resources = { - requests = {cpu = "15m", memory = "100Mi"} - limits = {cpu = "100m", memory = "256Mi"} - } - webhook.resources = { - requests = {cpu = "15m", memory = "100Mi"} - limits = {cpu = "100m", memory = "256Mi"} - } - } } } { @@ -68,25 +55,24 @@ _items = [ } # ========================================================================== - # Test 2: Custom namespace propagates to both resources + # Test 2: Vault backend skips PodIdentity, installs Vault when asked # ========================================================================== metav1alpha1.CompositionTest { - metadata.name = "custom-namespace-propagates" + metadata.name = "vault-install-no-pod-identity" spec = { compositionPath = "apis/secretstacks/composition.yaml" xrdPath = "apis/secretstacks/definition.yaml" timeoutSeconds = 60 validate = False xr = { - apiVersion = "aws.hops.ops.com.ai/v1alpha1" + apiVersion = "hops.ops.com.ai/v1alpha1" kind = "SecretStack" metadata.name = "test" spec = { - clusterName = "prod-cluster" - namespace = "secrets-system" - aws = { - region = "us-west-2" - rolePrefix = "prod-" + clusterName = "dory" + backend = "vault" + vault = { + install = True } } } @@ -95,17 +81,14 @@ _items = [ apiVersion = "helm.m.crossplane.io/v1beta1" kind = "Release" metadata.name = "external-secrets" - spec.forProvider.namespace = "secrets-system" } { - apiVersion = "aws.hops.ops.com.ai/v1alpha1" - kind = "PodIdentity" - metadata.name = "test-external-secrets" - spec = { - rolePrefix = "prod-" - serviceAccount = { - namespace = "secrets-system" - } + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "vault" + spec.forProvider = { + chart.name = "vault" + namespace = "vault" } } ] @@ -113,22 +96,27 @@ _items = [ } # ========================================================================== - # Test 3: Release name stays external-secrets even when stack name differs + # Test 3: Custom namespace propagates (AWS) # ========================================================================== metav1alpha1.CompositionTest { - metadata.name = "release-name-stays-external-secrets" + metadata.name = "custom-namespace-propagates" spec = { compositionPath = "apis/secretstacks/composition.yaml" xrdPath = "apis/secretstacks/definition.yaml" timeoutSeconds = 60 validate = False xr = { - apiVersion = "aws.hops.ops.com.ai/v1alpha1" + apiVersion = "hops.ops.com.ai/v1alpha1" kind = "SecretStack" - metadata.name = "pat-local" + metadata.name = "test" spec = { - clusterName = "pat-local" - aws.region = "us-east-2" + clusterName = "prod-cluster" + backend = "aws" + namespace = "secrets-system" + aws = { + region = "us-west-2" + rolePrefix = "prod-" + } } } assertResources = [ @@ -136,15 +124,17 @@ _items = [ apiVersion = "helm.m.crossplane.io/v1beta1" kind = "Release" metadata.name = "external-secrets" - spec.forProvider.namespace = "external-secrets" + spec.forProvider.namespace = "secrets-system" } { apiVersion = "aws.hops.ops.com.ai/v1alpha1" kind = "PodIdentity" - metadata.name = "pat-local-external-secrets" + metadata.name = "test-external-secrets" spec = { - clusterName = "pat-local" - region = "us-east-2" + rolePrefix = "prod-" + serviceAccount = { + namespace = "secrets-system" + } } } ] @@ -152,21 +142,22 @@ _items = [ } # ========================================================================== - # Test 3: Namespaced SecretStore renders by default when ESO is ready + # Test 4: AWS namespaced SecretStore when ESO exists (revision > 0) # ========================================================================== metav1alpha1.CompositionTest { - metadata.name = "secret-store-default-namespaced" + metadata.name = "aws-secret-store-default-namespaced" spec = { compositionPath = "apis/secretstacks/composition.yaml" xrdPath = "apis/secretstacks/definition.yaml" timeoutSeconds = 60 validate = False xr = { - apiVersion = "aws.hops.ops.com.ai/v1alpha1" + apiVersion = "hops.ops.com.ai/v1alpha1" kind = "SecretStack" metadata.name = "test" spec = { clusterName = "my-cluster" + backend = "aws" aws.region = "us-east-1" } } @@ -178,10 +169,16 @@ _items = [ name = "external-secrets" annotations = {"crossplane.io/composition-resource-name" = "helm-external-secrets"} } - status.conditions = [ - {type = "Ready", status = "True"} - {type = "Synced", status = "True"} - ] + status = { + atProvider = { + revision = 1 + state = "deployed" + } + conditions = [ + {type = "Ready", status = "True"} + {type = "Synced", status = "True"} + ] + } } ] assertResources = [ @@ -207,23 +204,33 @@ _items = [ } # ========================================================================== - # Test 4: ClusterSecretStore when scope is Cluster (opt-in) + # Test 5: Vault ClusterSecretStore when ESO + Vault exist (revision > 0) # ========================================================================== metav1alpha1.CompositionTest { - metadata.name = "cluster-secret-store-opt-in" + metadata.name = "vault-cluster-secret-store" spec = { compositionPath = "apis/secretstacks/composition.yaml" xrdPath = "apis/secretstacks/definition.yaml" timeoutSeconds = 60 validate = False xr = { - apiVersion = "aws.hops.ops.com.ai/v1alpha1" + apiVersion = "hops.ops.com.ai/v1alpha1" kind = "SecretStack" metadata.name = "test" spec = { - clusterName = "my-cluster" - secretStore.scope = "Cluster" - aws.region = "us-east-1" + clusterName = "dory" + backend = "vault" + secretStore = { + scope = "Cluster" + name = "vault" + } + vault = { + install = True + auth = { + method = "kubernetes" + role = "external-secrets" + } + } } } observedResources = [ @@ -234,10 +241,34 @@ _items = [ name = "external-secrets" annotations = {"crossplane.io/composition-resource-name" = "helm-external-secrets"} } - status.conditions = [ - {type = "Ready", status = "True"} - {type = "Synced", status = "True"} - ] + status = { + atProvider = { + revision = 1 + state = "deployed" + } + conditions = [ + {type = "Ready", status = "True"} + {type = "Synced", status = "True"} + ] + } + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata = { + name = "vault" + annotations = {"crossplane.io/composition-resource-name" = "helm-vault"} + } + status = { + atProvider = { + revision = 1 + state = "deployed" + } + conditions = [ + {type = "Ready", status = "True"} + {type = "Synced", status = "True"} + ] + } } ] assertResources = [ @@ -248,10 +279,11 @@ _items = [ spec.forProvider.manifest = { apiVersion = "external-secrets.io/v1" kind = "ClusterSecretStore" - metadata.name = "default" - spec.provider.aws = { - service = "SecretsManager" - region = "us-east-1" + metadata.name = "vault" + spec.provider.vault = { + server = "http://vault.vault.svc.cluster.local:8200" + path = "secret" + version = "v2" } } } @@ -260,7 +292,7 @@ _items = [ } # ========================================================================== - # Test 5: SecretStore disabled renders nothing + # Test 6: SecretStore disabled renders nothing store-related # ========================================================================== metav1alpha1.CompositionTest { metadata.name = "secret-store-disabled" @@ -270,11 +302,12 @@ _items = [ timeoutSeconds = 60 validate = False xr = { - apiVersion = "aws.hops.ops.com.ai/v1alpha1" + apiVersion = "hops.ops.com.ai/v1alpha1" kind = "SecretStack" metadata.name = "no-store" spec = { clusterName = "my-cluster" + backend = "aws" secretStore.enabled = False aws.region = "us-east-1" } @@ -287,13 +320,18 @@ _items = [ name = "external-secrets" annotations = {"crossplane.io/composition-resource-name" = "helm-external-secrets"} } - status.conditions = [ - {type = "Ready", status = "True"} - {type = "Synced", status = "True"} - ] + status = { + atProvider = { + revision = 1 + state = "deployed" + } + conditions = [ + {type = "Ready", status = "True"} + {type = "Synced", status = "True"} + ] + } } ] - # Only Helm + PodIdentity + Usage, no SecretStore assertResources = [ { apiVersion = "helm.m.crossplane.io/v1beta1" @@ -308,51 +346,6 @@ _items = [ ] } } - - # ========================================================================== - # Test 6: Custom SecretStore name - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "custom-secret-store-name" - spec = { - compositionPath = "apis/secretstacks/composition.yaml" - xrdPath = "apis/secretstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = { - apiVersion = "aws.hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - spec = { - clusterName = "my-cluster" - secretStore.name = "aws-sm" - aws.region = "us-east-1" - } - } - observedResources = [ - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata = { - name = "external-secrets" - annotations = {"crossplane.io/composition-resource-name" = "helm-external-secrets"} - } - status.conditions = [ - {type = "Ready", status = "True"} - {type = "Synced", status = "True"} - ] - } - ] - assertResources = [ - { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - metadata.name = "test-secret-store" - spec.forProvider.manifest.metadata.name = "aws-sm" - } - ] - } - } ] items = _items diff --git a/upbound.yaml b/upbound.yaml index bcb9e72..5dfbbb0 100644 --- a/upbound.yaml +++ b/upbound.yaml @@ -1,7 +1,7 @@ apiVersion: meta.dev.upbound.io/v2alpha1 kind: Project metadata: - name: aws-secret-stack + name: secret-stack spec: dependsOn: - apiVersion: pkg.crossplane.io/v1 @@ -12,22 +12,23 @@ spec: kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-helm version: '>=v1' - - apiVersion: pkg.crossplane.io/v1 - kind: Configuration - package: ghcr.io/hops-ops/aws-pod-identity - version: '>=v0.13.0' - apiVersion: pkg.crossplane.io/v1 kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes version: '>=v1' - description: Installs external-secrets with AWS Pod Identity for Secrets Manager - and SSM Parameter Store access. Optionally creates a SecretStore for ESO. + - apiVersion: pkg.crossplane.io/v1 + kind: Configuration + package: ghcr.io/hops-ops/aws-pod-identity + version: '>=v0.13.0' + description: | + Installs external-secrets and wires a SecretStore to AWS Secrets Manager + or HashiCorp Vault. Optional in-cluster Vault Helm install for local/dev. license: Apache-2.0 maintainer: Patrick Lee Scott readme: | - # aws-secret-stack + # secret-stack - Installs external-secrets via Helm Release and configures AWS Pod Identity - for Secrets Manager and SSM Parameter Store access. - repository: ghcr.io/hops-ops/aws-secret-stack + Platform SecretStack (hops.ops.com.ai): External Secrets Operator plus a + backend-specific SecretStore (AWS Secrets Manager or Vault). + repository: ghcr.io/hops-ops/secret-stack source: github.com/hops-ops/aws-secret-stack From 07cc185be90188e197c3616170ffd5d9cb95fe0d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 19 Aug 2026 15:03:18 -0500 Subject: [PATCH 2/9] feat: prototype typed SecretStack composition gates --- experimental/rust-compose/Cargo.lock | 155 ++++++++ experimental/rust-compose/Cargo.toml | 21 + experimental/rust-compose/README.md | 74 ++++ .../assets/eso-inline-policy.json | 38 ++ experimental/rust-compose/src/compose.rs | 270 +++++++++++++ experimental/rust-compose/src/desired.rs | 112 ++++++ experimental/rust-compose/src/gate.rs | 108 +++++ experimental/rust-compose/src/lib.rs | 25 ++ experimental/rust-compose/src/resources.rs | 375 ++++++++++++++++++ experimental/rust-compose/src/state.rs | 340 ++++++++++++++++ 10 files changed, 1518 insertions(+) create mode 100644 experimental/rust-compose/Cargo.lock create mode 100644 experimental/rust-compose/Cargo.toml create mode 100644 experimental/rust-compose/README.md create mode 100644 experimental/rust-compose/assets/eso-inline-policy.json create mode 100644 experimental/rust-compose/src/compose.rs create mode 100644 experimental/rust-compose/src/desired.rs create mode 100644 experimental/rust-compose/src/gate.rs create mode 100644 experimental/rust-compose/src/lib.rs create mode 100644 experimental/rust-compose/src/resources.rs create mode 100644 experimental/rust-compose/src/state.rs diff --git a/experimental/rust-compose/Cargo.lock b/experimental/rust-compose/Cargo.lock new file mode 100644 index 0000000..1a70603 --- /dev/null +++ b/experimental/rust-compose/Cargo.lock @@ -0,0 +1,155 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "secret-stack-compose" +version = "0.1.0" +dependencies = [ + "indexmap", + "pretty_assertions", + "serde", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/experimental/rust-compose/Cargo.toml b/experimental/rust-compose/Cargo.toml new file mode 100644 index 0000000..ad21012 --- /dev/null +++ b/experimental/rust-compose/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "secret-stack-compose" +version = "0.1.0" +edition = "2021" +description = """ +Experimental layered-gate composition for SecretStack. + +Expresses the same render graph as functions/render/*.gotmpl using typed +Exists/Ready gates and under_exists layers (rusty alternative to go-templating). +Not yet wired into composition.yaml — prototype only. +""" +license = "Apache-2.0" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +indexmap = { version = "2", features = ["serde"] } + +[dev-dependencies] +pretty_assertions = "1" diff --git a/experimental/rust-compose/README.md b/experimental/rust-compose/README.md new file mode 100644 index 0000000..a0221e7 --- /dev/null +++ b/experimental/rust-compose/README.md @@ -0,0 +1,74 @@ +# experimental/rust-compose + +**Prototype only.** Re-expresses SecretStack’s go-templating composition as a +Rust layered-gate graph. Not wired into `composition.yaml`. + +## Why + +SecretStack already has clear layers: + +```text +always: helm-external-secrets + helm-vault (vault.install) + pod-identity (backend=aws) + +under exists(ESO revision>0) + [+ vault exists if install] + [+ region/server checks]: + secret-store + +Usages (Ready only): + usage-pod-identity + usage-secret-store-helm + usage-secret-store-vault +``` + +Gotmpl encodes that as `$state.observed.*.exists` + `if $shouldRender`. +This crate encodes the same graph as: + +```rust +d.emit("helm-external-secrets", eso); +d.under_exists(eso_exists & vault_exists_if_needed, |d| { + d.emit("secret-store", store); + d.usage_when_ready(store_ready, "usage-secret-store-helm", usage); +}); +``` + +`Exists` and `Ready` are distinct types so dependent MRs cannot accidentally +gate on Ready (the credential-rotation / un-render footgun). + +## Map to gotmpl + +| File | Rust | +|------|------| +| `functions/render/000-state-init.yaml.gotmpl` | `state::EffectiveState::from_spec` | +| `functions/render/010-state-status.yaml.gotmpl` | `state::Observed` + `compute_status` | +| `200-helm-release-external-secrets.yaml.gotmpl` | root `emit` | +| `201-helm-release-vault.yaml.gotmpl` | root `emit` if `vault.install` | +| `210-aws-pod-identity.yaml.gotmpl` | root `emit` if aws + `usage_when_ready` | +| `230-secret-store.yaml.gotmpl` | `under_exists` + usages | +| `999-status.yaml.gotmpl` | `StatusOut` | + +## Run tests + +```bash +cd experimental/rust-compose +cargo test +``` + +Tests cover: + +- AWS bootstrap (no SecretStore until ESO exists) +- ESO exists but Ready=False still unlocks SecretStore (upgrade blip) +- Full Ready emits Usages +- Vault install waits for **both** Helm exists signals +- Missing region / `secretStore.enabled=false` blocks store + +## Not included (next steps if we productize) + +1. Wire gRPC via `crossplane-fn-sdk-unofficial` (or official later) +2. Package as Function xpkg; point composition pipeline at it +3. Optional `compose!` / `under_exists!` macros for thinner call sites +4. Parity render tests against `make render` golden YAML + +Production path remains `functions/render/*.gotmpl` until those land. diff --git a/experimental/rust-compose/assets/eso-inline-policy.json b/experimental/rust-compose/assets/eso-inline-policy.json new file mode 100644 index 0000000..c9642a2 --- /dev/null +++ b/experimental/rust-compose/assets/eso-inline-policy.json @@ -0,0 +1,38 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ReadAllSecrets", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + "secretsmanager:ListSecrets" + ], + "Resource": "*" + }, + { + "Sid": "WritePushedSecrets", + "Effect": "Allow", + "Action": "secretsmanager:*", + "Resource": "arn:aws:secretsmanager:*:*:secret:push/*" + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": "*" + } + ] +} diff --git a/experimental/rust-compose/src/compose.rs b/experimental/rust-compose/src/compose.rs new file mode 100644 index 0000000..ca289e3 --- /dev/null +++ b/experimental/rust-compose/src/compose.rs @@ -0,0 +1,270 @@ +//! SecretStack layered composition. +//! +//! Gate graph (same as `functions/render/*.gotmpl`): +//! +//! ```text +//! [root — always] +//! helm-external-secrets +//! helm-vault if vault.install +//! pod-identity if backend=aws +//! usage-pod-identity if backend=aws AND both Ready +//! +//! [under ESO exists (revision > 0)] +//! + vault exists if vault.install +//! + aws.region non-empty if backend=aws +//! + vault.server non-empty if backend=vault +//! secret-store +//! usage-secret-store-helm if secret-store Ready +//! usage-secret-store-vault if vault.install AND both Ready +//! ``` +//! +//! Existence gates dependents; Ready gates Usages only. + +use crate::desired::Desired; +use crate::gate::Exists; +use crate::resources; +use crate::state::{all_ready, compute_status, EffectiveState, Observed, StatusOut}; + +/// Resource name constants — must stay stable across reconciles. +pub mod names { + pub const HELM_ESO: &str = "helm-external-secrets"; + pub const HELM_VAULT: &str = "helm-vault"; + pub const POD_IDENTITY: &str = "pod-identity"; + pub const USAGE_POD_IDENTITY: &str = "usage-pod-identity"; + pub const SECRET_STORE: &str = "secret-store"; + pub const USAGE_SECRET_STORE_HELM: &str = "usage-secret-store-helm"; + pub const USAGE_SECRET_STORE_VAULT: &str = "usage-secret-store-vault"; +} + +pub struct ComposeResult { + pub desired: Desired, + pub status: StatusOut, +} + +/// Compose desired resources for one SecretStack reconcile. +pub fn compose(state: &EffectiveState, obs: &Observed) -> ComposeResult { + let mut d = Desired::new(); + + // --- root layer (no parent existence gate) --- + d.emit(names::HELM_ESO, resources::helm_external_secrets(state)); + + if state.vault_install { + d.emit(names::HELM_VAULT, resources::helm_vault(state)); + } + + if state.aws_enabled { + d.emit(names::POD_IDENTITY, resources::pod_identity(state)); + // Usage: Ready-gated (delete ESO Helm before PodIdentity) + d.usage_when_ready( + obs.helm_external_secrets.ready & obs.pod_identity.ready, + names::USAGE_POD_IDENTITY, + resources::usage_pod_identity_protects_until_helm_gone(state), + ); + } + + // --- secret-store layer: sticky existence of ESO (+ vault if installed) --- + // Mirrors 230-secret-store.yaml.gotmpl $shouldRender + let eso_exists = obs.helm_external_secrets.exists; + let vault_exists_if_needed = if state.vault_install { + obs.helm_vault.exists + } else { + Exists::YES + }; + + let mut store_gate = + state.secret_store_enabled && eso_exists.is_set() && vault_exists_if_needed.is_set(); + + if store_gate && state.vault_enabled { + store_gate = store_gate && !state.vault_server.is_empty(); + } + if store_gate && state.aws_enabled { + store_gate = store_gate && !state.aws_region.is_empty(); + } + + d.under_exists(Exists(store_gate), |d| { + d.emit(names::SECRET_STORE, resources::secret_store_object(state)); + + // Usages: Ready only + d.usage_when_ready( + obs.secret_store.ready, + names::USAGE_SECRET_STORE_HELM, + resources::usage_secret_store_before_eso_helm(state), + ); + + if state.vault_install { + d.usage_when_ready( + all_ready(&[obs.secret_store.ready, obs.helm_vault.ready]), + names::USAGE_SECRET_STORE_VAULT, + resources::usage_secret_store_before_vault(state), + ); + } + }); + + let status = compute_status(state, obs); + ComposeResult { desired: d, status } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gate::{Exists, ObservedSlice, Ready}; + use crate::state::{Backend, SecretStackSpec, SecretStoreScope}; + use pretty_assertions::assert_eq; + + fn aws_spec() -> SecretStackSpec { + SecretStackSpec { + metadata_name: "external-secrets".into(), + cluster_name: "production-cluster".into(), + backend: Backend::Aws, + aws_region: Some("us-west-2".into()), + aws_role_prefix: Some("prod-".into()), + ..Default::default() + } + } + + fn vault_install_spec() -> SecretStackSpec { + SecretStackSpec { + metadata_name: "external-secrets".into(), + cluster_name: "dory".into(), + backend: Backend::Vault, + vault_install: true, + secret_store_scope: Some(SecretStoreScope::Cluster), + secret_store_name: Some("vault".into()), + ..Default::default() + } + } + + #[test] + fn aws_bootstrap_no_secret_store_yet() { + let state = EffectiveState::from_spec(&aws_spec()); + let obs = Observed::bootstrap(); + let r = compose(&state, &obs); + + assert!(r.desired.contains(names::HELM_ESO)); + assert!(r.desired.contains(names::POD_IDENTITY)); + assert!(!r.desired.contains(names::HELM_VAULT)); + assert!( + !r.desired.contains(names::SECRET_STORE), + "SecretStore must wait for ESO Helm exists (revision>0)" + ); + assert!(!r.desired.contains(names::USAGE_POD_IDENTITY)); + assert!(!r.desired.contains(names::USAGE_SECRET_STORE_HELM)); + } + + #[test] + fn aws_eso_exists_unlocks_secret_store_even_if_not_ready() { + // Chart upgrade blip: revision sticky, Ready false — store must stay + let state = EffectiveState::from_spec(&aws_spec()); + let obs = Observed { + helm_external_secrets: ObservedSlice::helm(2, false), + ..Observed::bootstrap() + }; + let r = compose(&state, &obs); + + assert!(r.desired.contains(names::SECRET_STORE)); + assert!( + !r.desired.contains(names::USAGE_SECRET_STORE_HELM), + "Usage still waits for secret-store Ready" + ); + } + + #[test] + fn aws_full_ready_emits_usages() { + let state = EffectiveState::from_spec(&aws_spec()); + let obs = Observed { + helm_external_secrets: ObservedSlice::helm(1, true), + pod_identity: ObservedSlice::new(Exists::YES, Ready::YES), + secret_store: ObservedSlice::new(Exists::YES, Ready::YES), + ..Observed::bootstrap() + }; + let r = compose(&state, &obs); + + let mut names = r.desired.names(); + names.sort(); + assert_eq!( + names, + vec![ + names::HELM_ESO, + names::POD_IDENTITY, + names::SECRET_STORE, + names::USAGE_POD_IDENTITY, + names::USAGE_SECRET_STORE_HELM, + ] + ); + } + + #[test] + fn vault_install_bootstrap_no_store_until_both_helms_exist() { + let state = EffectiveState::from_spec(&vault_install_spec()); + let obs = Observed { + helm_external_secrets: ObservedSlice::helm(1, true), + // vault not installed yet + helm_vault: ObservedSlice::missing(), + ..Observed::bootstrap() + }; + let r = compose(&state, &obs); + + assert!(r.desired.contains(names::HELM_ESO)); + assert!(r.desired.contains(names::HELM_VAULT)); + assert!(!r.desired.contains(names::POD_IDENTITY)); + assert!( + !r.desired.contains(names::SECRET_STORE), + "vault.install requires helm-vault exists before SecretStore" + ); + } + + #[test] + fn vault_install_both_exist_unlocks_store() { + let state = EffectiveState::from_spec(&vault_install_spec()); + let obs = Observed { + helm_external_secrets: ObservedSlice::helm(1, true), + helm_vault: ObservedSlice::helm(1, false), // not ready — still ok for render + ..Observed::bootstrap() + }; + let r = compose(&state, &obs); + + assert!(r.desired.contains(names::SECRET_STORE)); + assert!(!r.desired.contains(names::USAGE_SECRET_STORE_VAULT)); + } + + #[test] + fn vault_install_usages_when_ready() { + let state = EffectiveState::from_spec(&vault_install_spec()); + let obs = Observed { + helm_external_secrets: ObservedSlice::helm(1, true), + helm_vault: ObservedSlice::helm(1, true), + secret_store: ObservedSlice::new(Exists::YES, Ready::YES), + ..Observed::bootstrap() + }; + let r = compose(&state, &obs); + + assert!(r.desired.contains(names::USAGE_SECRET_STORE_HELM)); + assert!(r.desired.contains(names::USAGE_SECRET_STORE_VAULT)); + } + + #[test] + fn aws_missing_region_blocks_secret_store() { + let mut spec = aws_spec(); + spec.aws_region = Some("".into()); + let state = EffectiveState::from_spec(&spec); + let obs = Observed { + helm_external_secrets: ObservedSlice::helm(1, true), + ..Observed::bootstrap() + }; + let r = compose(&state, &obs); + assert!(!r.desired.contains(names::SECRET_STORE)); + } + + #[test] + fn secret_store_disabled() { + let mut spec = aws_spec(); + spec.secret_store_enabled = Some(false); + let state = EffectiveState::from_spec(&spec); + let obs = Observed { + helm_external_secrets: ObservedSlice::helm(1, true), + ..Observed::bootstrap() + }; + let r = compose(&state, &obs); + assert!(!r.desired.contains(names::SECRET_STORE)); + } +} diff --git a/experimental/rust-compose/src/desired.rs b/experimental/rust-compose/src/desired.rs new file mode 100644 index 0000000..afc1b34 --- /dev/null +++ b/experimental/rust-compose/src/desired.rs @@ -0,0 +1,112 @@ +//! Desired composed resources + layered existence gates. +//! +//! ```ignore +//! d.emit("helm-external-secrets", eso); +//! d.under_exists(obs.helm_external_secrets.exists, |d| { +//! d.emit("secret-store", store); +//! }); +//! d.usage_when_ready(obs.a.ready & obs.b.ready, "usage-name", usage); +//! ``` + +use indexmap::IndexMap; +use serde_json::Value; + +use crate::gate::{Exists, Ready}; + +/// Stable composition resource name (`crossplane.io/composition-resource-name`). +pub type ResourceName = String; + +/// One desired composed resource (unstructured JSON body for the prototype). +#[derive(Debug, Clone, PartialEq)] +pub struct DesiredResource { + pub body: Value, +} + +/// Accumulated desired set for one RunFunction response (prototype). +#[derive(Debug, Default, Clone)] +pub struct Desired { + resources: IndexMap, +} + +impl Desired { + pub fn new() -> Self { + Self::default() + } + + /// Always emit into desired (root layer — no parent gate). + pub fn emit(&mut self, name: impl Into, body: Value) { + self.resources.insert(name.into(), DesiredResource { body }); + } + + /// Layer: run `f` only when sticky existence is set. + /// + /// If `gate` is false, children are **not** added — matching un-render + /// semantics. Callers must use sticky Exists, never Ready. + pub fn under_exists(&mut self, gate: Exists, f: impl FnOnce(&mut Desired)) { + if gate.is_set() { + f(self); + } + } + + /// Usage / ordering lock: gate on Ready (deliberate exception). + pub fn usage_when_ready(&mut self, gate: Ready, name: impl Into, body: Value) { + if gate.is_set() { + self.emit(name, body); + } + } + + pub fn contains(&self, name: &str) -> bool { + self.resources.contains_key(name) + } + + pub fn names(&self) -> Vec<&str> { + self.resources.keys().map(|s| s.as_str()).collect() + } + + pub fn get(&self, name: &str) -> Option<&DesiredResource> { + self.resources.get(name) + } + + pub fn len(&self) -> usize { + self.resources.len() + } + + pub fn is_empty(&self) -> bool { + self.resources.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn under_exists_skips_children_when_missing() { + let mut d = Desired::new(); + d.emit("vpc", json!({"kind": "VPC"})); + d.under_exists(Exists::NO, |d| { + d.emit("subnet", json!({"kind": "Subnet"})); + }); + assert!(d.contains("vpc")); + assert!(!d.contains("subnet")); + } + + #[test] + fn under_exists_emits_children_when_present() { + let mut d = Desired::new(); + d.under_exists(Exists::YES, |d| { + d.emit("subnet", json!({"kind": "Subnet"})); + }); + assert!(d.contains("subnet")); + } + + #[test] + fn usage_when_ready_requires_ready() { + let mut d = Desired::new(); + d.usage_when_ready(Ready::NO, "usage-x", json!({"kind": "Usage"})); + assert!(!d.contains("usage-x")); + d.usage_when_ready(Ready::YES, "usage-x", json!({"kind": "Usage"})); + assert!(d.contains("usage-x")); + } +} diff --git a/experimental/rust-compose/src/gate.rs b/experimental/rust-compose/src/gate.rs new file mode 100644 index 0000000..fb09d0c --- /dev/null +++ b/experimental/rust-compose/src/gate.rs @@ -0,0 +1,108 @@ +//! Sticky existence vs readiness. +//! +//! Matches xrd-authoring observed-state pattern: +//! - **Exists** — sticky atProvider signal (Helm `revision > 0`, upjet id/arn, …). +//! Use only for **dependent resource render** gates. Un-render deletes MRs. +//! - **Ready** — condition Ready=True. Use for **status** and **Usage** only. + +/// Sticky existence. Safe default for dependent MR render gates. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Exists(pub bool); + +impl Exists { + pub const YES: Self = Self(true); + pub const NO: Self = Self(false); + + #[inline] + pub fn is_set(self) -> bool { + self.0 + } + + /// Helm sticky signal: revision > 0 means installed at least once. + pub fn from_helm_revision(revision: i64) -> Self { + Self(revision > 0) + } + + /// Non-empty sticky id/arn (upjet). + pub fn from_nonempty(s: Option<&str>) -> Self { + Self(s.map(|v| !v.is_empty()).unwrap_or(false)) + } + + /// Resource key present in observed map (weaker; prefer sticky fields). + pub fn from_observed_entry(present: bool) -> Self { + Self(present) + } +} + +impl std::ops::BitAnd for Exists { + type Output = Self; + fn bitand(self, rhs: Self) -> Self { + Self(self.0 && rhs.0) + } +} + +/// Condition Ready=True. Not for dependent MR render gates. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Ready(pub bool); + +impl Ready { + pub const YES: Self = Self(true); + pub const NO: Self = Self(false); + + #[inline] + pub fn is_set(self) -> bool { + self.0 + } +} + +impl std::ops::BitAnd for Ready { + type Output = Self; + fn bitand(self, rhs: Self) -> Self { + Self(self.0 && rhs.0) + } +} + +/// Observed slice for one composed resource: both signals, always. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ObservedSlice { + pub exists: Exists, + pub ready: Ready, +} + +impl ObservedSlice { + pub fn new(exists: Exists, ready: Ready) -> Self { + Self { exists, ready } + } + + pub fn missing() -> Self { + Self { + exists: Exists::NO, + ready: Ready::NO, + } + } + + pub fn helm(revision: i64, ready: bool) -> Self { + Self { + exists: Exists::from_helm_revision(revision), + ready: Ready(ready), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn helm_revision_zero_is_not_exists() { + assert!(!Exists::from_helm_revision(0).is_set()); + assert!(Exists::from_helm_revision(1).is_set()); + } + + #[test] + fn exists_and_ready_are_distinct() { + let mid_upgrade = ObservedSlice::helm(3, false); + assert!(mid_upgrade.exists.is_set()); + assert!(!mid_upgrade.ready.is_set()); + } +} diff --git a/experimental/rust-compose/src/lib.rs b/experimental/rust-compose/src/lib.rs new file mode 100644 index 0000000..c2896a4 --- /dev/null +++ b/experimental/rust-compose/src/lib.rs @@ -0,0 +1,25 @@ +//! Experimental SecretStack composition with layered existence gates. +//! +//! This crate re-expresses `functions/render/*.gotmpl` in Rust: +//! +//! | gotmpl | Rust | +//! |--------|------| +//! | `000-state-init` | [`state::EffectiveState::from_spec`] | +//! | `010-state-status` | [`state::Observed`] + [`state::compute_status`] | +//! | `200` / `201` / `210` / `230` | [`compose::compose`] + [`resources`] | +//! | `$shouldRender` / `if exists` | [`Desired::under_exists`](desired::Desired::under_exists) | +//! | Usage `if ready` | [`Desired::usage_when_ready`](desired::Desired::usage_when_ready) | +//! +//! **Not production.** Not wired to `composition.yaml`. No gRPC function package yet. +//! Goal: prove the gate DX on a real stack before investing in a function runtime. + +pub mod compose; +pub mod desired; +pub mod gate; +pub mod resources; +pub mod state; + +pub use compose::{compose, ComposeResult}; +pub use desired::Desired; +pub use gate::{Exists, ObservedSlice, Ready}; +pub use state::{Backend, EffectiveState, Observed, SecretStackSpec, SecretStoreScope}; diff --git a/experimental/rust-compose/src/resources.rs b/experimental/rust-compose/src/resources.rs new file mode 100644 index 0000000..b24fe79 --- /dev/null +++ b/experimental/rust-compose/src/resources.rs @@ -0,0 +1,375 @@ +//! Unstructured resource bodies — mirrors the YAML emitted by gotmpl files. +//! Bodies are JSON Value for the prototype; a full function would emit k8s objects. + +use serde_json::{json, Value}; + +use crate::state::{EffectiveState, SecretStoreScope}; + +const ESO_CHART_VERSION: &str = "2.2.0"; +const VAULT_CHART_VERSION: &str = "0.30.0"; + +fn labels(state: &EffectiveState) -> Value { + Value::Object(state.labels.clone()) +} + +fn management_policies(state: &EffectiveState) -> Value { + json!(state.management_policies) +} + +/// `200-helm-release-external-secrets.yaml.gotmpl` +pub fn helm_external_secrets(state: &EffectiveState) -> Value { + let values = if let Some(over) = &state.helm_override_all { + over.clone() + } else { + let mut defaults = json!({ + "serviceAccount": { + "create": true, + "name": state.service_account_name, + }, + "resources": { + "requests": { "cpu": "15m", "memory": "100Mi" }, + "limits": { "cpu": "100m", "memory": "256Mi" } + }, + "certController": { + "resources": { + "requests": { "cpu": "15m", "memory": "100Mi" }, + "limits": { "cpu": "100m", "memory": "256Mi" } + } + }, + "webhook": { + "resources": { + "requests": { "cpu": "15m", "memory": "100Mi" }, + "limits": { "cpu": "100m", "memory": "256Mi" } + } + } + }); + merge_objects(&mut defaults, &state.helm_values); + defaults + }; + + json!({ + "apiVersion": "helm.m.crossplane.io/v1beta1", + "kind": "Release", + "metadata": { + "name": state.helm_release_name, + "annotations": { + "crossplane.io/composition-resource-name": "helm-external-secrets" + }, + "labels": labels(state), + }, + "spec": { + "managementPolicies": management_policies(state), + "forProvider": { + "chart": { + "name": "external-secrets", + "repository": "https://charts.external-secrets.io", + "version": ESO_CHART_VERSION, + }, + "namespace": state.helm_namespace, + "values": values, + }, + "rollbackLimit": 3, + "providerConfigRef": { + "name": state.helm_provider.name, + "kind": state.helm_provider.kind, + } + } + }) +} + +/// `201-helm-release-vault.yaml.gotmpl` +pub fn helm_vault(state: &EffectiveState) -> Value { + let values = if let Some(over) = &state.vault_override_all { + over.clone() + } else { + let mut defaults = json!({ + "global": { "enabled": true, "tlsDisable": true }, + "injector": { "enabled": false }, + "server": { + "dev": { "enabled": true }, + "standalone": { "enabled": true }, + "dataStorage": { "enabled": false }, + "resources": { + "requests": { "cpu": "50m", "memory": "128Mi" }, + "limits": { "cpu": "500m", "memory": "512Mi" } + } + }, + "ui": { "enabled": true } + }); + merge_objects(&mut defaults, &state.vault_values); + defaults + }; + + json!({ + "apiVersion": "helm.m.crossplane.io/v1beta1", + "kind": "Release", + "metadata": { + "name": state.vault_release_name, + "annotations": { + "crossplane.io/composition-resource-name": "helm-vault" + }, + "labels": labels(state), + }, + "spec": { + "managementPolicies": management_policies(state), + "forProvider": { + "chart": { + "name": "vault", + "repository": "https://helm.releases.hashicorp.com", + "version": VAULT_CHART_VERSION, + }, + "namespace": state.vault_namespace, + "values": values, + }, + "rollbackLimit": 3, + "providerConfigRef": { + "name": state.helm_provider.name, + "kind": state.helm_provider.kind, + } + } + }) +} + +/// `210-aws-pod-identity.yaml.gotmpl` (body only; Usage separate). +pub fn pod_identity(state: &EffectiveState) -> Value { + let mut spec = json!({ + "managementPolicies": management_policies(state), + "clusterName": state.cluster_name, + "region": state.aws_region, + "providerConfigRef": { + "name": state.aws_provider.name, + "kind": state.aws_provider.kind, + }, + "serviceAccount": { + "name": state.service_account_name, + "namespace": state.service_account_namespace, + }, + "inlinePolicy": [{ + "name": "external-secrets", + "policy": include_str!("../assets/eso-inline-policy.json") + }], + "tags": Value::Object(state.aws_tags.clone()), + }); + if !state.aws_role_prefix.is_empty() { + spec["rolePrefix"] = json!(state.aws_role_prefix); + } + if !state.aws_permissions_boundary_arn.is_empty() { + spec["permissionsBoundaryArn"] = json!(state.aws_permissions_boundary_arn); + } + + json!({ + "apiVersion": "aws.hops.ops.com.ai/v1alpha1", + "kind": "PodIdentity", + "metadata": { + "name": state.pod_identity_name, + "annotations": { + "crossplane.io/composition-resource-name": "pod-identity" + }, + "labels": labels(state), + }, + "spec": spec, + }) +} + +pub fn usage_pod_identity_protects_until_helm_gone(state: &EffectiveState) -> Value { + json!({ + "apiVersion": "protection.crossplane.io/v1beta1", + "kind": "Usage", + "metadata": { + "name": format!("{}-delete-helm-eso-before-pod-identity", state.name), + "annotations": { + "crossplane.io/composition-resource-name": "usage-pod-identity" + }, + "labels": labels(state), + }, + "spec": { + "of": { + "apiVersion": "aws.hops.ops.com.ai/v1alpha1", + "kind": "PodIdentity", + "resourceRef": { "name": state.pod_identity_name } + }, + "by": { + "apiVersion": "helm.m.crossplane.io/v1beta1", + "kind": "Release", + "resourceRef": { "name": state.helm_release_name } + }, + "replayDeletion": true + } + }) +} + +/// Provider fragment for SecretStore / ClusterSecretStore. +fn secret_store_provider(state: &EffectiveState) -> Value { + if state.aws_enabled { + return json!({ + "aws": { + "service": "SecretsManager", + "region": state.aws_region, + } + }); + } + + // vault + let mut vault = json!({ + "server": state.vault_server, + "path": state.vault_path, + "version": state.vault_version, + }); + + if state.vault_auth_method == "kubernetes" { + let mut sa_ref = json!({ "name": state.service_account_name }); + if state.secret_store_scope == SecretStoreScope::Cluster { + sa_ref["namespace"] = json!(state.service_account_namespace); + } + vault["auth"] = json!({ + "kubernetes": { + "mountPath": state.vault_auth_mount_path, + "role": state.vault_auth_role, + "serviceAccountRef": sa_ref, + } + }); + } else { + let mut token_ref = json!({ + "name": state.vault_token_secret_name, + "key": state.vault_token_secret_key, + }); + if state.secret_store_scope == SecretStoreScope::Cluster + || !state.vault_token_secret_namespace.is_empty() + { + token_ref["namespace"] = json!(state.vault_token_secret_namespace); + } + vault["auth"] = json!({ "tokenSecretRef": token_ref }); + } + + json!({ "vault": vault }) +} + +/// `230-secret-store.yaml.gotmpl` Object (SecretStore or ClusterSecretStore). +pub fn secret_store_object(state: &EffectiveState) -> Value { + let provider = secret_store_provider(state); + let (meta_name, composition_name, manifest) = match state.secret_store_scope { + SecretStoreScope::Cluster => ( + format!("{}-cluster-secret-store", state.name), + "secret-store", + json!({ + "apiVersion": "external-secrets.io/v1", + "kind": "ClusterSecretStore", + "metadata": { "name": state.secret_store_name }, + "spec": { "provider": provider }, + }), + ), + SecretStoreScope::Namespaced => ( + format!("{}-secret-store", state.name), + "secret-store", + json!({ + "apiVersion": "external-secrets.io/v1", + "kind": "SecretStore", + "metadata": { + "name": state.secret_store_name, + "namespace": state.namespace, + }, + "spec": { "provider": provider }, + }), + ), + }; + + json!({ + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "metadata": { + "name": meta_name, + "annotations": { + "crossplane.io/composition-resource-name": composition_name + }, + "labels": labels(state), + }, + "spec": { + "managementPolicies": management_policies(state), + "forProvider": { "manifest": manifest }, + "providerConfigRef": { + "name": state.k8s_provider.name, + "kind": state.k8s_provider.kind, + } + } + }) +} + +fn secret_store_object_name(state: &EffectiveState) -> String { + match state.secret_store_scope { + SecretStoreScope::Cluster => format!("{}-cluster-secret-store", state.name), + SecretStoreScope::Namespaced => format!("{}-secret-store", state.name), + } +} + +pub fn usage_secret_store_before_eso_helm(state: &EffectiveState) -> Value { + json!({ + "apiVersion": "protection.crossplane.io/v1beta1", + "kind": "Usage", + "metadata": { + "name": format!("{}-delete-secret-store-before-helm", state.name), + "annotations": { + "crossplane.io/composition-resource-name": "usage-secret-store-helm" + }, + "labels": labels(state), + }, + "spec": { + "replayDeletion": true, + "of": { + "apiVersion": "helm.m.crossplane.io/v1beta1", + "kind": "Release", + "resourceRef": { "name": state.helm_release_name } + }, + "by": { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "resourceRef": { "name": secret_store_object_name(state) } + } + } + }) +} + +pub fn usage_secret_store_before_vault(state: &EffectiveState) -> Value { + json!({ + "apiVersion": "protection.crossplane.io/v1beta1", + "kind": "Usage", + "metadata": { + "name": format!("{}-delete-secret-store-before-vault", state.name), + "annotations": { + "crossplane.io/composition-resource-name": "usage-secret-store-vault" + }, + "labels": labels(state), + }, + "spec": { + "replayDeletion": true, + "of": { + "apiVersion": "helm.m.crossplane.io/v1beta1", + "kind": "Release", + "resourceRef": { "name": state.vault_release_name } + }, + "by": { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "resourceRef": { "name": secret_store_object_name(state) } + } + } + }) +} + +/// Shallow-ish recursive merge of object maps (gotmpl mergeOverwrite-ish for objects). +fn merge_objects(base: &mut Value, overlay: &Value) { + match (base, overlay) { + (Value::Object(b), Value::Object(o)) => { + for (k, v) in o { + match b.get_mut(k) { + Some(existing) => merge_objects(existing, v), + None => { + b.insert(k.clone(), v.clone()); + } + } + } + } + (base, overlay) => { + *base = overlay.clone(); + } + } +} diff --git a/experimental/rust-compose/src/state.rs b/experimental/rust-compose/src/state.rs new file mode 100644 index 0000000..78432a0 --- /dev/null +++ b/experimental/rust-compose/src/state.rs @@ -0,0 +1,340 @@ +//! Effective SecretStack state — mirrors `000-state-init.yaml.gotmpl`. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use crate::gate::{ObservedSlice, Ready}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + #[default] + Aws, + Vault, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum SecretStoreScope { + #[default] + Namespaced, + Cluster, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderConfigRef { + pub name: String, + pub kind: String, +} + +impl ProviderConfigRef { + pub fn new(name: impl Into, kind: impl Into) -> Self { + Self { + name: name.into(), + kind: kind.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct EffectiveState { + pub name: String, + pub cluster_name: String, + pub namespace: String, + pub management_policies: Vec, + pub labels: Map, + pub backend: Backend, + + pub helm_release_name: String, + pub helm_namespace: String, + pub helm_provider: ProviderConfigRef, + pub helm_values: Value, + pub helm_override_all: Option, + + pub k8s_provider: ProviderConfigRef, + + pub aws_enabled: bool, + pub aws_region: String, + pub aws_provider: ProviderConfigRef, + pub aws_permissions_boundary_arn: String, + pub aws_role_prefix: String, + pub aws_tags: Map, + + pub vault_enabled: bool, + pub vault_install: bool, + pub vault_namespace: String, + pub vault_release_name: String, + pub vault_server: String, + pub vault_path: String, + pub vault_version: String, + pub vault_auth_method: String, + pub vault_auth_mount_path: String, + pub vault_auth_role: String, + pub vault_token_secret_name: String, + pub vault_token_secret_key: String, + pub vault_token_secret_namespace: String, + pub vault_values: Value, + pub vault_override_all: Option, + + pub secret_store_enabled: bool, + pub secret_store_scope: SecretStoreScope, + pub secret_store_name: String, + + pub pod_identity_name: String, + pub service_account_name: String, + pub service_account_namespace: String, +} + +/// Minimal XR input for effective-state construction (subset of XRD). +#[derive(Debug, Clone, Default)] +pub struct SecretStackSpec { + pub metadata_name: String, + pub cluster_name: String, + pub backend: Backend, + pub namespace: Option, + pub release_name: Option, + pub labels: Map, + pub management_policies: Option>, + pub values: Value, + pub override_all_values: Option, + + pub helm_provider_name: Option, + pub k8s_provider_name: Option, + pub aws_provider_name: Option, + + pub aws_region: Option, + pub aws_permissions_boundary_arn: Option, + pub aws_role_prefix: Option, + pub aws_tags: Map, + + pub vault_install: bool, + pub vault_namespace: Option, + pub vault_release_name: Option, + pub vault_server: Option, + pub vault_path: Option, + pub vault_version: Option, + pub vault_auth_method: Option, + pub vault_auth_mount_path: Option, + pub vault_auth_role: Option, + pub vault_token_secret_name: Option, + pub vault_token_secret_key: Option, + pub vault_token_secret_namespace: Option, + pub vault_values: Value, + pub vault_override_all: Option, + + pub secret_store_enabled: Option, + pub secret_store_scope: Option, + pub secret_store_name: Option, +} + +impl EffectiveState { + pub fn from_spec(spec: &SecretStackSpec) -> Self { + let name = if spec.metadata_name.is_empty() { + "external-secrets".into() + } else { + spec.metadata_name.clone() + }; + let cluster_name = if spec.cluster_name.is_empty() { + name.clone() + } else { + spec.cluster_name.clone() + }; + let namespace = spec + .namespace + .clone() + .unwrap_or_else(|| "external-secrets".into()); + let backend = spec.backend; + + let mut labels = Map::new(); + labels.insert("hops.ops.com.ai/managed".into(), json!("true")); + labels.insert(format!("hops.ops.com.ai/{}", "secretstack"), json!(name)); + for (k, v) in &spec.labels { + labels.insert(k.clone(), v.clone()); + } + + let mut aws_tags = labels.clone(); + for (k, v) in &spec.aws_tags { + aws_tags.insert(k.clone(), v.clone()); + } + + let vault_enabled = matches!(backend, Backend::Vault); + let vault_install = vault_enabled && spec.vault_install; + let vault_namespace = spec + .vault_namespace + .clone() + .unwrap_or_else(|| "vault".into()); + let vault_release_name = spec + .vault_release_name + .clone() + .unwrap_or_else(|| "vault".into()); + let default_vault_server = format!( + "http://{}.{}.svc.cluster.local:8200", + vault_release_name, vault_namespace + ); + let vault_server = spec + .vault_server + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| vault_install.then_some(default_vault_server)) + .unwrap_or_default(); + + let vault_auth_method = spec.vault_auth_method.clone().unwrap_or_else(|| { + if vault_install { + "kubernetes".into() + } else { + "token".into() + } + }); + + Self { + name: name.clone(), + cluster_name: cluster_name.clone(), + namespace: namespace.clone(), + management_policies: spec + .management_policies + .clone() + .unwrap_or_else(|| vec!["*".into()]), + labels, + backend, + + helm_release_name: spec + .release_name + .clone() + .unwrap_or_else(|| "external-secrets".into()), + helm_namespace: namespace.clone(), + helm_provider: ProviderConfigRef::new( + spec.helm_provider_name + .clone() + .unwrap_or_else(|| cluster_name.clone()), + "ProviderConfig", + ), + helm_values: if spec.values.is_null() { + json!({}) + } else { + spec.values.clone() + }, + helm_override_all: spec.override_all_values.clone(), + + k8s_provider: ProviderConfigRef::new( + spec.k8s_provider_name + .clone() + .unwrap_or_else(|| cluster_name.clone()), + "ProviderConfig", + ), + + aws_enabled: matches!(backend, Backend::Aws), + aws_region: spec.aws_region.clone().unwrap_or_default(), + aws_provider: ProviderConfigRef::new( + spec.aws_provider_name + .clone() + .unwrap_or_else(|| "default".into()), + "ProviderConfig", + ), + aws_permissions_boundary_arn: spec + .aws_permissions_boundary_arn + .clone() + .unwrap_or_default(), + aws_role_prefix: spec.aws_role_prefix.clone().unwrap_or_default(), + aws_tags, + + vault_enabled, + vault_install, + vault_namespace, + vault_release_name, + vault_server, + vault_path: spec.vault_path.clone().unwrap_or_else(|| "secret".into()), + vault_version: spec.vault_version.clone().unwrap_or_else(|| "v2".into()), + vault_auth_method, + vault_auth_mount_path: spec + .vault_auth_mount_path + .clone() + .unwrap_or_else(|| "kubernetes".into()), + vault_auth_role: spec + .vault_auth_role + .clone() + .unwrap_or_else(|| "external-secrets".into()), + vault_token_secret_name: spec + .vault_token_secret_name + .clone() + .unwrap_or_else(|| "vault-token".into()), + vault_token_secret_key: spec + .vault_token_secret_key + .clone() + .unwrap_or_else(|| "token".into()), + vault_token_secret_namespace: spec + .vault_token_secret_namespace + .clone() + .unwrap_or_else(|| namespace.clone()), + vault_values: if spec.vault_values.is_null() { + json!({}) + } else { + spec.vault_values.clone() + }, + vault_override_all: spec.vault_override_all.clone(), + + secret_store_enabled: spec.secret_store_enabled.unwrap_or(true), + secret_store_scope: spec.secret_store_scope.unwrap_or_default(), + secret_store_name: spec + .secret_store_name + .clone() + .unwrap_or_else(|| "default".into()), + + pod_identity_name: format!("{name}-external-secrets"), + service_account_name: "external-secrets".into(), + service_account_namespace: namespace, + } + } +} + +/// Observed slices — mirrors `010-state-status.yaml.gotmpl`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Observed { + pub helm_external_secrets: ObservedSlice, + pub helm_vault: ObservedSlice, + pub pod_identity: ObservedSlice, + pub secret_store: ObservedSlice, +} + +impl Observed { + pub fn bootstrap() -> Self { + Self::default() + } +} + +/// Status fields written back to the XR (ready left for auto-ready in real pipeline). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusOut { + pub ready: bool, + pub backend: Backend, + pub secret_store_name: String, + pub secret_store_scope: SecretStoreScope, + pub secret_store_ready: bool, + pub vault: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VaultStatus { + pub release_name: String, + pub namespace: String, + pub ready: bool, +} + +pub fn compute_status(state: &EffectiveState, obs: &Observed) -> StatusOut { + StatusOut { + ready: false, // function-auto-ready owns overall ready in the real pipeline + backend: state.backend, + secret_store_name: state.secret_store_name.clone(), + secret_store_scope: state.secret_store_scope, + secret_store_ready: obs.secret_store.ready.is_set(), + vault: state.vault_install.then(|| VaultStatus { + release_name: state.vault_release_name.clone(), + namespace: state.vault_namespace.clone(), + ready: obs.helm_vault.ready.is_set(), + }), + } +} + +/// Convenience: Ready for multi-resource Usage gates. +pub fn all_ready(slices: &[Ready]) -> Ready { + Ready(slices.iter().all(|r| r.is_set())) +} From 91b60721cd292143b4f07e48b1dadcca3e07ec09 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 21 Aug 2026 20:02:59 -0500 Subject: [PATCH 3/9] fix: restore local SecretStack validation --- Makefile | 8 +++++--- .../standard/steps/1/helm-external-secrets.yaml | 1 + .../observed-resources/standard/steps/1/pod-identity.yaml | 3 ++- .../observed-resources/standard/steps/2/secret-store.yaml | 1 + .../vault/steps/1/helm-external-secrets.yaml | 1 + .../observed-resources/vault/steps/1/helm-vault.yaml | 1 + .../vault/steps/2/helm-external-secrets.yaml | 1 + .../observed-resources/vault/steps/2/helm-vault.yaml | 1 + .../observed-resources/vault/steps/2/secret-store.yaml | 1 + 9 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index c0a937a..0d544ca 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,7 @@ render\:all: observed=$${entry#*::}; \ outfile="$$tmpdir/$$(echo $$entry | tr '/:' '__')"; \ ( \ + set -euo pipefail; \ if [ -n "$$observed" ]; then \ echo "=== Rendering $$example with observed-resources $$observed ==="; \ up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example --observed-resources=$$observed; \ @@ -72,16 +73,17 @@ validate\:all: generate-configuration observed=$${entry#*::}; \ outfile="$$tmpdir/$$(echo $$entry | tr '/:' '__')"; \ ( \ + set -euo pipefail; \ if [ -n "$$observed" ]; then \ echo "=== Validating $$example with observed-resources $$observed ==="; \ up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example \ --observed-resources=$$observed --include-full-xr --quiet | \ - crossplane beta validate $(XRD_DIR) --error-on-missing-schemas -; \ + crossplane resource validate $(XRD_DIR) --error-on-missing-schemas -; \ else \ echo "=== Validating $$example ==="; \ up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example \ --include-full-xr --quiet | \ - crossplane beta validate $(XRD_DIR) --error-on-missing-schemas -; \ + crossplane resource validate $(XRD_DIR) --error-on-missing-schemas -; \ fi; \ echo "" \ ) > "$$outfile" 2>&1 & \ @@ -111,7 +113,7 @@ validate\:%: generate-configuration @example="examples/secretstacks/$*.yaml"; \ up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example \ --include-full-xr --quiet | \ - crossplane beta validate $(XRD_DIR) --error-on-missing-schemas - + crossplane resource validate $(XRD_DIR) --error-on-missing-schemas - test: up test run $(RENDER_TESTS) diff --git a/examples/test/mocks/observed-resources/standard/steps/1/helm-external-secrets.yaml b/examples/test/mocks/observed-resources/standard/steps/1/helm-external-secrets.yaml index a2239f0..21872dc 100644 --- a/examples/test/mocks/observed-resources/standard/steps/1/helm-external-secrets.yaml +++ b/examples/test/mocks/observed-resources/standard/steps/1/helm-external-secrets.yaml @@ -2,6 +2,7 @@ apiVersion: helm.m.crossplane.io/v1beta1 kind: Release metadata: name: external-secrets + namespace: default annotations: crossplane.io/composition-resource-name: helm-external-secrets status: diff --git a/examples/test/mocks/observed-resources/standard/steps/1/pod-identity.yaml b/examples/test/mocks/observed-resources/standard/steps/1/pod-identity.yaml index 1a8a687..4a01711 100644 --- a/examples/test/mocks/observed-resources/standard/steps/1/pod-identity.yaml +++ b/examples/test/mocks/observed-resources/standard/steps/1/pod-identity.yaml @@ -1,7 +1,8 @@ apiVersion: aws.hops.ops.com.ai/v1alpha1 kind: PodIdentity metadata: - name: external-secrets + name: external-secrets-external-secrets + namespace: default annotations: crossplane.io/composition-resource-name: pod-identity status: diff --git a/examples/test/mocks/observed-resources/standard/steps/2/secret-store.yaml b/examples/test/mocks/observed-resources/standard/steps/2/secret-store.yaml index 470f564..d6fe59e 100644 --- a/examples/test/mocks/observed-resources/standard/steps/2/secret-store.yaml +++ b/examples/test/mocks/observed-resources/standard/steps/2/secret-store.yaml @@ -2,6 +2,7 @@ apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object metadata: name: external-secrets-secret-store + namespace: default annotations: crossplane.io/composition-resource-name: secret-store status: diff --git a/examples/test/mocks/observed-resources/vault/steps/1/helm-external-secrets.yaml b/examples/test/mocks/observed-resources/vault/steps/1/helm-external-secrets.yaml index a2239f0..21872dc 100644 --- a/examples/test/mocks/observed-resources/vault/steps/1/helm-external-secrets.yaml +++ b/examples/test/mocks/observed-resources/vault/steps/1/helm-external-secrets.yaml @@ -2,6 +2,7 @@ apiVersion: helm.m.crossplane.io/v1beta1 kind: Release metadata: name: external-secrets + namespace: default annotations: crossplane.io/composition-resource-name: helm-external-secrets status: diff --git a/examples/test/mocks/observed-resources/vault/steps/1/helm-vault.yaml b/examples/test/mocks/observed-resources/vault/steps/1/helm-vault.yaml index 1baeedc..1139f78 100644 --- a/examples/test/mocks/observed-resources/vault/steps/1/helm-vault.yaml +++ b/examples/test/mocks/observed-resources/vault/steps/1/helm-vault.yaml @@ -2,6 +2,7 @@ apiVersion: helm.m.crossplane.io/v1beta1 kind: Release metadata: name: vault + namespace: default annotations: crossplane.io/composition-resource-name: helm-vault status: diff --git a/examples/test/mocks/observed-resources/vault/steps/2/helm-external-secrets.yaml b/examples/test/mocks/observed-resources/vault/steps/2/helm-external-secrets.yaml index a2239f0..21872dc 100644 --- a/examples/test/mocks/observed-resources/vault/steps/2/helm-external-secrets.yaml +++ b/examples/test/mocks/observed-resources/vault/steps/2/helm-external-secrets.yaml @@ -2,6 +2,7 @@ apiVersion: helm.m.crossplane.io/v1beta1 kind: Release metadata: name: external-secrets + namespace: default annotations: crossplane.io/composition-resource-name: helm-external-secrets status: diff --git a/examples/test/mocks/observed-resources/vault/steps/2/helm-vault.yaml b/examples/test/mocks/observed-resources/vault/steps/2/helm-vault.yaml index 1baeedc..1139f78 100644 --- a/examples/test/mocks/observed-resources/vault/steps/2/helm-vault.yaml +++ b/examples/test/mocks/observed-resources/vault/steps/2/helm-vault.yaml @@ -2,6 +2,7 @@ apiVersion: helm.m.crossplane.io/v1beta1 kind: Release metadata: name: vault + namespace: default annotations: crossplane.io/composition-resource-name: helm-vault status: diff --git a/examples/test/mocks/observed-resources/vault/steps/2/secret-store.yaml b/examples/test/mocks/observed-resources/vault/steps/2/secret-store.yaml index 401a642..68841f6 100644 --- a/examples/test/mocks/observed-resources/vault/steps/2/secret-store.yaml +++ b/examples/test/mocks/observed-resources/vault/steps/2/secret-store.yaml @@ -2,6 +2,7 @@ apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object metadata: name: external-secrets-cluster-secret-store + namespace: default annotations: crossplane.io/composition-resource-name: secret-store status: From b48e6463953dc19f25f48f920adb5491ea881714 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 00:06:51 -0500 Subject: [PATCH 4/9] chore: remove accidental Rust composition prototype Implements [[tasks/remove-secretstack-rust-prototype]] --- .gitignore | 3 - README.md | 11 - experimental/rust-compose/Cargo.lock | 155 -------- experimental/rust-compose/Cargo.toml | 21 - experimental/rust-compose/README.md | 74 ---- .../assets/eso-inline-policy.json | 38 -- experimental/rust-compose/src/compose.rs | 270 ------------- experimental/rust-compose/src/desired.rs | 112 ------ experimental/rust-compose/src/gate.rs | 108 ----- experimental/rust-compose/src/lib.rs | 25 -- experimental/rust-compose/src/resources.rs | 375 ------------------ experimental/rust-compose/src/state.rs | 340 ---------------- 12 files changed, 1532 deletions(-) delete mode 100644 experimental/rust-compose/Cargo.lock delete mode 100644 experimental/rust-compose/Cargo.toml delete mode 100644 experimental/rust-compose/README.md delete mode 100644 experimental/rust-compose/assets/eso-inline-policy.json delete mode 100644 experimental/rust-compose/src/compose.rs delete mode 100644 experimental/rust-compose/src/desired.rs delete mode 100644 experimental/rust-compose/src/gate.rs delete mode 100644 experimental/rust-compose/src/lib.rs delete mode 100644 experimental/rust-compose/src/resources.rs delete mode 100644 experimental/rust-compose/src/state.rs diff --git a/.gitignore b/.gitignore index c8692cc..de0ee3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ # Build output _output/ .up/ -**/target/ - -# Rust experimental compose crate lock is fine to commit; ignore build dir only # Virtual environments .venv/ diff --git a/README.md b/README.md index 80dcc89..727ed1e 100644 --- a/README.md +++ b/README.md @@ -104,17 +104,6 @@ make validate # validate rendered output make test # run KCL unit tests ``` -### Experimental: Rust layered-gate compose - -A prototype re-expresses this stack’s render graph (existence gates + Ready -Usages) in Rust. Production still uses `functions/render/*.gotmpl`. - -```bash -cd experimental/rust-compose && cargo test -``` - -See [experimental/rust-compose/README.md](experimental/rust-compose/README.md). - ### Local install (source) ```bash diff --git a/experimental/rust-compose/Cargo.lock b/experimental/rust-compose/Cargo.lock deleted file mode 100644 index 1a70603..0000000 --- a/experimental/rust-compose/Cargo.lock +++ /dev/null @@ -1,155 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", - "serde", - "serde_core", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "secret-stack-compose" -version = "0.1.0" -dependencies = [ - "indexmap", - "pretty_assertions", - "serde", - "serde_json", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/experimental/rust-compose/Cargo.toml b/experimental/rust-compose/Cargo.toml deleted file mode 100644 index ad21012..0000000 --- a/experimental/rust-compose/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "secret-stack-compose" -version = "0.1.0" -edition = "2021" -description = """ -Experimental layered-gate composition for SecretStack. - -Expresses the same render graph as functions/render/*.gotmpl using typed -Exists/Ready gates and under_exists layers (rusty alternative to go-templating). -Not yet wired into composition.yaml — prototype only. -""" -license = "Apache-2.0" -publish = false - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -indexmap = { version = "2", features = ["serde"] } - -[dev-dependencies] -pretty_assertions = "1" diff --git a/experimental/rust-compose/README.md b/experimental/rust-compose/README.md deleted file mode 100644 index a0221e7..0000000 --- a/experimental/rust-compose/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# experimental/rust-compose - -**Prototype only.** Re-expresses SecretStack’s go-templating composition as a -Rust layered-gate graph. Not wired into `composition.yaml`. - -## Why - -SecretStack already has clear layers: - -```text -always: helm-external-secrets - helm-vault (vault.install) - pod-identity (backend=aws) - -under exists(ESO revision>0) - [+ vault exists if install] - [+ region/server checks]: - secret-store - -Usages (Ready only): - usage-pod-identity - usage-secret-store-helm - usage-secret-store-vault -``` - -Gotmpl encodes that as `$state.observed.*.exists` + `if $shouldRender`. -This crate encodes the same graph as: - -```rust -d.emit("helm-external-secrets", eso); -d.under_exists(eso_exists & vault_exists_if_needed, |d| { - d.emit("secret-store", store); - d.usage_when_ready(store_ready, "usage-secret-store-helm", usage); -}); -``` - -`Exists` and `Ready` are distinct types so dependent MRs cannot accidentally -gate on Ready (the credential-rotation / un-render footgun). - -## Map to gotmpl - -| File | Rust | -|------|------| -| `functions/render/000-state-init.yaml.gotmpl` | `state::EffectiveState::from_spec` | -| `functions/render/010-state-status.yaml.gotmpl` | `state::Observed` + `compute_status` | -| `200-helm-release-external-secrets.yaml.gotmpl` | root `emit` | -| `201-helm-release-vault.yaml.gotmpl` | root `emit` if `vault.install` | -| `210-aws-pod-identity.yaml.gotmpl` | root `emit` if aws + `usage_when_ready` | -| `230-secret-store.yaml.gotmpl` | `under_exists` + usages | -| `999-status.yaml.gotmpl` | `StatusOut` | - -## Run tests - -```bash -cd experimental/rust-compose -cargo test -``` - -Tests cover: - -- AWS bootstrap (no SecretStore until ESO exists) -- ESO exists but Ready=False still unlocks SecretStore (upgrade blip) -- Full Ready emits Usages -- Vault install waits for **both** Helm exists signals -- Missing region / `secretStore.enabled=false` blocks store - -## Not included (next steps if we productize) - -1. Wire gRPC via `crossplane-fn-sdk-unofficial` (or official later) -2. Package as Function xpkg; point composition pipeline at it -3. Optional `compose!` / `under_exists!` macros for thinner call sites -4. Parity render tests against `make render` golden YAML - -Production path remains `functions/render/*.gotmpl` until those land. diff --git a/experimental/rust-compose/assets/eso-inline-policy.json b/experimental/rust-compose/assets/eso-inline-policy.json deleted file mode 100644 index c9642a2..0000000 --- a/experimental/rust-compose/assets/eso-inline-policy.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "ReadAllSecrets", - "Effect": "Allow", - "Action": [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret", - "secretsmanager:ListSecrets" - ], - "Resource": "*" - }, - { - "Sid": "WritePushedSecrets", - "Effect": "Allow", - "Action": "secretsmanager:*", - "Resource": "arn:aws:secretsmanager:*:*:secret:push/*" - }, - { - "Effect": "Allow", - "Action": [ - "ssm:GetParameter", - "ssm:GetParameters", - "ssm:GetParametersByPath", - "ssm:DescribeParameters" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "kms:Decrypt" - ], - "Resource": "*" - } - ] -} diff --git a/experimental/rust-compose/src/compose.rs b/experimental/rust-compose/src/compose.rs deleted file mode 100644 index ca289e3..0000000 --- a/experimental/rust-compose/src/compose.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! SecretStack layered composition. -//! -//! Gate graph (same as `functions/render/*.gotmpl`): -//! -//! ```text -//! [root — always] -//! helm-external-secrets -//! helm-vault if vault.install -//! pod-identity if backend=aws -//! usage-pod-identity if backend=aws AND both Ready -//! -//! [under ESO exists (revision > 0)] -//! + vault exists if vault.install -//! + aws.region non-empty if backend=aws -//! + vault.server non-empty if backend=vault -//! secret-store -//! usage-secret-store-helm if secret-store Ready -//! usage-secret-store-vault if vault.install AND both Ready -//! ``` -//! -//! Existence gates dependents; Ready gates Usages only. - -use crate::desired::Desired; -use crate::gate::Exists; -use crate::resources; -use crate::state::{all_ready, compute_status, EffectiveState, Observed, StatusOut}; - -/// Resource name constants — must stay stable across reconciles. -pub mod names { - pub const HELM_ESO: &str = "helm-external-secrets"; - pub const HELM_VAULT: &str = "helm-vault"; - pub const POD_IDENTITY: &str = "pod-identity"; - pub const USAGE_POD_IDENTITY: &str = "usage-pod-identity"; - pub const SECRET_STORE: &str = "secret-store"; - pub const USAGE_SECRET_STORE_HELM: &str = "usage-secret-store-helm"; - pub const USAGE_SECRET_STORE_VAULT: &str = "usage-secret-store-vault"; -} - -pub struct ComposeResult { - pub desired: Desired, - pub status: StatusOut, -} - -/// Compose desired resources for one SecretStack reconcile. -pub fn compose(state: &EffectiveState, obs: &Observed) -> ComposeResult { - let mut d = Desired::new(); - - // --- root layer (no parent existence gate) --- - d.emit(names::HELM_ESO, resources::helm_external_secrets(state)); - - if state.vault_install { - d.emit(names::HELM_VAULT, resources::helm_vault(state)); - } - - if state.aws_enabled { - d.emit(names::POD_IDENTITY, resources::pod_identity(state)); - // Usage: Ready-gated (delete ESO Helm before PodIdentity) - d.usage_when_ready( - obs.helm_external_secrets.ready & obs.pod_identity.ready, - names::USAGE_POD_IDENTITY, - resources::usage_pod_identity_protects_until_helm_gone(state), - ); - } - - // --- secret-store layer: sticky existence of ESO (+ vault if installed) --- - // Mirrors 230-secret-store.yaml.gotmpl $shouldRender - let eso_exists = obs.helm_external_secrets.exists; - let vault_exists_if_needed = if state.vault_install { - obs.helm_vault.exists - } else { - Exists::YES - }; - - let mut store_gate = - state.secret_store_enabled && eso_exists.is_set() && vault_exists_if_needed.is_set(); - - if store_gate && state.vault_enabled { - store_gate = store_gate && !state.vault_server.is_empty(); - } - if store_gate && state.aws_enabled { - store_gate = store_gate && !state.aws_region.is_empty(); - } - - d.under_exists(Exists(store_gate), |d| { - d.emit(names::SECRET_STORE, resources::secret_store_object(state)); - - // Usages: Ready only - d.usage_when_ready( - obs.secret_store.ready, - names::USAGE_SECRET_STORE_HELM, - resources::usage_secret_store_before_eso_helm(state), - ); - - if state.vault_install { - d.usage_when_ready( - all_ready(&[obs.secret_store.ready, obs.helm_vault.ready]), - names::USAGE_SECRET_STORE_VAULT, - resources::usage_secret_store_before_vault(state), - ); - } - }); - - let status = compute_status(state, obs); - ComposeResult { desired: d, status } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::gate::{Exists, ObservedSlice, Ready}; - use crate::state::{Backend, SecretStackSpec, SecretStoreScope}; - use pretty_assertions::assert_eq; - - fn aws_spec() -> SecretStackSpec { - SecretStackSpec { - metadata_name: "external-secrets".into(), - cluster_name: "production-cluster".into(), - backend: Backend::Aws, - aws_region: Some("us-west-2".into()), - aws_role_prefix: Some("prod-".into()), - ..Default::default() - } - } - - fn vault_install_spec() -> SecretStackSpec { - SecretStackSpec { - metadata_name: "external-secrets".into(), - cluster_name: "dory".into(), - backend: Backend::Vault, - vault_install: true, - secret_store_scope: Some(SecretStoreScope::Cluster), - secret_store_name: Some("vault".into()), - ..Default::default() - } - } - - #[test] - fn aws_bootstrap_no_secret_store_yet() { - let state = EffectiveState::from_spec(&aws_spec()); - let obs = Observed::bootstrap(); - let r = compose(&state, &obs); - - assert!(r.desired.contains(names::HELM_ESO)); - assert!(r.desired.contains(names::POD_IDENTITY)); - assert!(!r.desired.contains(names::HELM_VAULT)); - assert!( - !r.desired.contains(names::SECRET_STORE), - "SecretStore must wait for ESO Helm exists (revision>0)" - ); - assert!(!r.desired.contains(names::USAGE_POD_IDENTITY)); - assert!(!r.desired.contains(names::USAGE_SECRET_STORE_HELM)); - } - - #[test] - fn aws_eso_exists_unlocks_secret_store_even_if_not_ready() { - // Chart upgrade blip: revision sticky, Ready false — store must stay - let state = EffectiveState::from_spec(&aws_spec()); - let obs = Observed { - helm_external_secrets: ObservedSlice::helm(2, false), - ..Observed::bootstrap() - }; - let r = compose(&state, &obs); - - assert!(r.desired.contains(names::SECRET_STORE)); - assert!( - !r.desired.contains(names::USAGE_SECRET_STORE_HELM), - "Usage still waits for secret-store Ready" - ); - } - - #[test] - fn aws_full_ready_emits_usages() { - let state = EffectiveState::from_spec(&aws_spec()); - let obs = Observed { - helm_external_secrets: ObservedSlice::helm(1, true), - pod_identity: ObservedSlice::new(Exists::YES, Ready::YES), - secret_store: ObservedSlice::new(Exists::YES, Ready::YES), - ..Observed::bootstrap() - }; - let r = compose(&state, &obs); - - let mut names = r.desired.names(); - names.sort(); - assert_eq!( - names, - vec![ - names::HELM_ESO, - names::POD_IDENTITY, - names::SECRET_STORE, - names::USAGE_POD_IDENTITY, - names::USAGE_SECRET_STORE_HELM, - ] - ); - } - - #[test] - fn vault_install_bootstrap_no_store_until_both_helms_exist() { - let state = EffectiveState::from_spec(&vault_install_spec()); - let obs = Observed { - helm_external_secrets: ObservedSlice::helm(1, true), - // vault not installed yet - helm_vault: ObservedSlice::missing(), - ..Observed::bootstrap() - }; - let r = compose(&state, &obs); - - assert!(r.desired.contains(names::HELM_ESO)); - assert!(r.desired.contains(names::HELM_VAULT)); - assert!(!r.desired.contains(names::POD_IDENTITY)); - assert!( - !r.desired.contains(names::SECRET_STORE), - "vault.install requires helm-vault exists before SecretStore" - ); - } - - #[test] - fn vault_install_both_exist_unlocks_store() { - let state = EffectiveState::from_spec(&vault_install_spec()); - let obs = Observed { - helm_external_secrets: ObservedSlice::helm(1, true), - helm_vault: ObservedSlice::helm(1, false), // not ready — still ok for render - ..Observed::bootstrap() - }; - let r = compose(&state, &obs); - - assert!(r.desired.contains(names::SECRET_STORE)); - assert!(!r.desired.contains(names::USAGE_SECRET_STORE_VAULT)); - } - - #[test] - fn vault_install_usages_when_ready() { - let state = EffectiveState::from_spec(&vault_install_spec()); - let obs = Observed { - helm_external_secrets: ObservedSlice::helm(1, true), - helm_vault: ObservedSlice::helm(1, true), - secret_store: ObservedSlice::new(Exists::YES, Ready::YES), - ..Observed::bootstrap() - }; - let r = compose(&state, &obs); - - assert!(r.desired.contains(names::USAGE_SECRET_STORE_HELM)); - assert!(r.desired.contains(names::USAGE_SECRET_STORE_VAULT)); - } - - #[test] - fn aws_missing_region_blocks_secret_store() { - let mut spec = aws_spec(); - spec.aws_region = Some("".into()); - let state = EffectiveState::from_spec(&spec); - let obs = Observed { - helm_external_secrets: ObservedSlice::helm(1, true), - ..Observed::bootstrap() - }; - let r = compose(&state, &obs); - assert!(!r.desired.contains(names::SECRET_STORE)); - } - - #[test] - fn secret_store_disabled() { - let mut spec = aws_spec(); - spec.secret_store_enabled = Some(false); - let state = EffectiveState::from_spec(&spec); - let obs = Observed { - helm_external_secrets: ObservedSlice::helm(1, true), - ..Observed::bootstrap() - }; - let r = compose(&state, &obs); - assert!(!r.desired.contains(names::SECRET_STORE)); - } -} diff --git a/experimental/rust-compose/src/desired.rs b/experimental/rust-compose/src/desired.rs deleted file mode 100644 index afc1b34..0000000 --- a/experimental/rust-compose/src/desired.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Desired composed resources + layered existence gates. -//! -//! ```ignore -//! d.emit("helm-external-secrets", eso); -//! d.under_exists(obs.helm_external_secrets.exists, |d| { -//! d.emit("secret-store", store); -//! }); -//! d.usage_when_ready(obs.a.ready & obs.b.ready, "usage-name", usage); -//! ``` - -use indexmap::IndexMap; -use serde_json::Value; - -use crate::gate::{Exists, Ready}; - -/// Stable composition resource name (`crossplane.io/composition-resource-name`). -pub type ResourceName = String; - -/// One desired composed resource (unstructured JSON body for the prototype). -#[derive(Debug, Clone, PartialEq)] -pub struct DesiredResource { - pub body: Value, -} - -/// Accumulated desired set for one RunFunction response (prototype). -#[derive(Debug, Default, Clone)] -pub struct Desired { - resources: IndexMap, -} - -impl Desired { - pub fn new() -> Self { - Self::default() - } - - /// Always emit into desired (root layer — no parent gate). - pub fn emit(&mut self, name: impl Into, body: Value) { - self.resources.insert(name.into(), DesiredResource { body }); - } - - /// Layer: run `f` only when sticky existence is set. - /// - /// If `gate` is false, children are **not** added — matching un-render - /// semantics. Callers must use sticky Exists, never Ready. - pub fn under_exists(&mut self, gate: Exists, f: impl FnOnce(&mut Desired)) { - if gate.is_set() { - f(self); - } - } - - /// Usage / ordering lock: gate on Ready (deliberate exception). - pub fn usage_when_ready(&mut self, gate: Ready, name: impl Into, body: Value) { - if gate.is_set() { - self.emit(name, body); - } - } - - pub fn contains(&self, name: &str) -> bool { - self.resources.contains_key(name) - } - - pub fn names(&self) -> Vec<&str> { - self.resources.keys().map(|s| s.as_str()).collect() - } - - pub fn get(&self, name: &str) -> Option<&DesiredResource> { - self.resources.get(name) - } - - pub fn len(&self) -> usize { - self.resources.len() - } - - pub fn is_empty(&self) -> bool { - self.resources.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn under_exists_skips_children_when_missing() { - let mut d = Desired::new(); - d.emit("vpc", json!({"kind": "VPC"})); - d.under_exists(Exists::NO, |d| { - d.emit("subnet", json!({"kind": "Subnet"})); - }); - assert!(d.contains("vpc")); - assert!(!d.contains("subnet")); - } - - #[test] - fn under_exists_emits_children_when_present() { - let mut d = Desired::new(); - d.under_exists(Exists::YES, |d| { - d.emit("subnet", json!({"kind": "Subnet"})); - }); - assert!(d.contains("subnet")); - } - - #[test] - fn usage_when_ready_requires_ready() { - let mut d = Desired::new(); - d.usage_when_ready(Ready::NO, "usage-x", json!({"kind": "Usage"})); - assert!(!d.contains("usage-x")); - d.usage_when_ready(Ready::YES, "usage-x", json!({"kind": "Usage"})); - assert!(d.contains("usage-x")); - } -} diff --git a/experimental/rust-compose/src/gate.rs b/experimental/rust-compose/src/gate.rs deleted file mode 100644 index fb09d0c..0000000 --- a/experimental/rust-compose/src/gate.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Sticky existence vs readiness. -//! -//! Matches xrd-authoring observed-state pattern: -//! - **Exists** — sticky atProvider signal (Helm `revision > 0`, upjet id/arn, …). -//! Use only for **dependent resource render** gates. Un-render deletes MRs. -//! - **Ready** — condition Ready=True. Use for **status** and **Usage** only. - -/// Sticky existence. Safe default for dependent MR render gates. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct Exists(pub bool); - -impl Exists { - pub const YES: Self = Self(true); - pub const NO: Self = Self(false); - - #[inline] - pub fn is_set(self) -> bool { - self.0 - } - - /// Helm sticky signal: revision > 0 means installed at least once. - pub fn from_helm_revision(revision: i64) -> Self { - Self(revision > 0) - } - - /// Non-empty sticky id/arn (upjet). - pub fn from_nonempty(s: Option<&str>) -> Self { - Self(s.map(|v| !v.is_empty()).unwrap_or(false)) - } - - /// Resource key present in observed map (weaker; prefer sticky fields). - pub fn from_observed_entry(present: bool) -> Self { - Self(present) - } -} - -impl std::ops::BitAnd for Exists { - type Output = Self; - fn bitand(self, rhs: Self) -> Self { - Self(self.0 && rhs.0) - } -} - -/// Condition Ready=True. Not for dependent MR render gates. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct Ready(pub bool); - -impl Ready { - pub const YES: Self = Self(true); - pub const NO: Self = Self(false); - - #[inline] - pub fn is_set(self) -> bool { - self.0 - } -} - -impl std::ops::BitAnd for Ready { - type Output = Self; - fn bitand(self, rhs: Self) -> Self { - Self(self.0 && rhs.0) - } -} - -/// Observed slice for one composed resource: both signals, always. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct ObservedSlice { - pub exists: Exists, - pub ready: Ready, -} - -impl ObservedSlice { - pub fn new(exists: Exists, ready: Ready) -> Self { - Self { exists, ready } - } - - pub fn missing() -> Self { - Self { - exists: Exists::NO, - ready: Ready::NO, - } - } - - pub fn helm(revision: i64, ready: bool) -> Self { - Self { - exists: Exists::from_helm_revision(revision), - ready: Ready(ready), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn helm_revision_zero_is_not_exists() { - assert!(!Exists::from_helm_revision(0).is_set()); - assert!(Exists::from_helm_revision(1).is_set()); - } - - #[test] - fn exists_and_ready_are_distinct() { - let mid_upgrade = ObservedSlice::helm(3, false); - assert!(mid_upgrade.exists.is_set()); - assert!(!mid_upgrade.ready.is_set()); - } -} diff --git a/experimental/rust-compose/src/lib.rs b/experimental/rust-compose/src/lib.rs deleted file mode 100644 index c2896a4..0000000 --- a/experimental/rust-compose/src/lib.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Experimental SecretStack composition with layered existence gates. -//! -//! This crate re-expresses `functions/render/*.gotmpl` in Rust: -//! -//! | gotmpl | Rust | -//! |--------|------| -//! | `000-state-init` | [`state::EffectiveState::from_spec`] | -//! | `010-state-status` | [`state::Observed`] + [`state::compute_status`] | -//! | `200` / `201` / `210` / `230` | [`compose::compose`] + [`resources`] | -//! | `$shouldRender` / `if exists` | [`Desired::under_exists`](desired::Desired::under_exists) | -//! | Usage `if ready` | [`Desired::usage_when_ready`](desired::Desired::usage_when_ready) | -//! -//! **Not production.** Not wired to `composition.yaml`. No gRPC function package yet. -//! Goal: prove the gate DX on a real stack before investing in a function runtime. - -pub mod compose; -pub mod desired; -pub mod gate; -pub mod resources; -pub mod state; - -pub use compose::{compose, ComposeResult}; -pub use desired::Desired; -pub use gate::{Exists, ObservedSlice, Ready}; -pub use state::{Backend, EffectiveState, Observed, SecretStackSpec, SecretStoreScope}; diff --git a/experimental/rust-compose/src/resources.rs b/experimental/rust-compose/src/resources.rs deleted file mode 100644 index b24fe79..0000000 --- a/experimental/rust-compose/src/resources.rs +++ /dev/null @@ -1,375 +0,0 @@ -//! Unstructured resource bodies — mirrors the YAML emitted by gotmpl files. -//! Bodies are JSON Value for the prototype; a full function would emit k8s objects. - -use serde_json::{json, Value}; - -use crate::state::{EffectiveState, SecretStoreScope}; - -const ESO_CHART_VERSION: &str = "2.2.0"; -const VAULT_CHART_VERSION: &str = "0.30.0"; - -fn labels(state: &EffectiveState) -> Value { - Value::Object(state.labels.clone()) -} - -fn management_policies(state: &EffectiveState) -> Value { - json!(state.management_policies) -} - -/// `200-helm-release-external-secrets.yaml.gotmpl` -pub fn helm_external_secrets(state: &EffectiveState) -> Value { - let values = if let Some(over) = &state.helm_override_all { - over.clone() - } else { - let mut defaults = json!({ - "serviceAccount": { - "create": true, - "name": state.service_account_name, - }, - "resources": { - "requests": { "cpu": "15m", "memory": "100Mi" }, - "limits": { "cpu": "100m", "memory": "256Mi" } - }, - "certController": { - "resources": { - "requests": { "cpu": "15m", "memory": "100Mi" }, - "limits": { "cpu": "100m", "memory": "256Mi" } - } - }, - "webhook": { - "resources": { - "requests": { "cpu": "15m", "memory": "100Mi" }, - "limits": { "cpu": "100m", "memory": "256Mi" } - } - } - }); - merge_objects(&mut defaults, &state.helm_values); - defaults - }; - - json!({ - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "metadata": { - "name": state.helm_release_name, - "annotations": { - "crossplane.io/composition-resource-name": "helm-external-secrets" - }, - "labels": labels(state), - }, - "spec": { - "managementPolicies": management_policies(state), - "forProvider": { - "chart": { - "name": "external-secrets", - "repository": "https://charts.external-secrets.io", - "version": ESO_CHART_VERSION, - }, - "namespace": state.helm_namespace, - "values": values, - }, - "rollbackLimit": 3, - "providerConfigRef": { - "name": state.helm_provider.name, - "kind": state.helm_provider.kind, - } - } - }) -} - -/// `201-helm-release-vault.yaml.gotmpl` -pub fn helm_vault(state: &EffectiveState) -> Value { - let values = if let Some(over) = &state.vault_override_all { - over.clone() - } else { - let mut defaults = json!({ - "global": { "enabled": true, "tlsDisable": true }, - "injector": { "enabled": false }, - "server": { - "dev": { "enabled": true }, - "standalone": { "enabled": true }, - "dataStorage": { "enabled": false }, - "resources": { - "requests": { "cpu": "50m", "memory": "128Mi" }, - "limits": { "cpu": "500m", "memory": "512Mi" } - } - }, - "ui": { "enabled": true } - }); - merge_objects(&mut defaults, &state.vault_values); - defaults - }; - - json!({ - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "metadata": { - "name": state.vault_release_name, - "annotations": { - "crossplane.io/composition-resource-name": "helm-vault" - }, - "labels": labels(state), - }, - "spec": { - "managementPolicies": management_policies(state), - "forProvider": { - "chart": { - "name": "vault", - "repository": "https://helm.releases.hashicorp.com", - "version": VAULT_CHART_VERSION, - }, - "namespace": state.vault_namespace, - "values": values, - }, - "rollbackLimit": 3, - "providerConfigRef": { - "name": state.helm_provider.name, - "kind": state.helm_provider.kind, - } - } - }) -} - -/// `210-aws-pod-identity.yaml.gotmpl` (body only; Usage separate). -pub fn pod_identity(state: &EffectiveState) -> Value { - let mut spec = json!({ - "managementPolicies": management_policies(state), - "clusterName": state.cluster_name, - "region": state.aws_region, - "providerConfigRef": { - "name": state.aws_provider.name, - "kind": state.aws_provider.kind, - }, - "serviceAccount": { - "name": state.service_account_name, - "namespace": state.service_account_namespace, - }, - "inlinePolicy": [{ - "name": "external-secrets", - "policy": include_str!("../assets/eso-inline-policy.json") - }], - "tags": Value::Object(state.aws_tags.clone()), - }); - if !state.aws_role_prefix.is_empty() { - spec["rolePrefix"] = json!(state.aws_role_prefix); - } - if !state.aws_permissions_boundary_arn.is_empty() { - spec["permissionsBoundaryArn"] = json!(state.aws_permissions_boundary_arn); - } - - json!({ - "apiVersion": "aws.hops.ops.com.ai/v1alpha1", - "kind": "PodIdentity", - "metadata": { - "name": state.pod_identity_name, - "annotations": { - "crossplane.io/composition-resource-name": "pod-identity" - }, - "labels": labels(state), - }, - "spec": spec, - }) -} - -pub fn usage_pod_identity_protects_until_helm_gone(state: &EffectiveState) -> Value { - json!({ - "apiVersion": "protection.crossplane.io/v1beta1", - "kind": "Usage", - "metadata": { - "name": format!("{}-delete-helm-eso-before-pod-identity", state.name), - "annotations": { - "crossplane.io/composition-resource-name": "usage-pod-identity" - }, - "labels": labels(state), - }, - "spec": { - "of": { - "apiVersion": "aws.hops.ops.com.ai/v1alpha1", - "kind": "PodIdentity", - "resourceRef": { "name": state.pod_identity_name } - }, - "by": { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "resourceRef": { "name": state.helm_release_name } - }, - "replayDeletion": true - } - }) -} - -/// Provider fragment for SecretStore / ClusterSecretStore. -fn secret_store_provider(state: &EffectiveState) -> Value { - if state.aws_enabled { - return json!({ - "aws": { - "service": "SecretsManager", - "region": state.aws_region, - } - }); - } - - // vault - let mut vault = json!({ - "server": state.vault_server, - "path": state.vault_path, - "version": state.vault_version, - }); - - if state.vault_auth_method == "kubernetes" { - let mut sa_ref = json!({ "name": state.service_account_name }); - if state.secret_store_scope == SecretStoreScope::Cluster { - sa_ref["namespace"] = json!(state.service_account_namespace); - } - vault["auth"] = json!({ - "kubernetes": { - "mountPath": state.vault_auth_mount_path, - "role": state.vault_auth_role, - "serviceAccountRef": sa_ref, - } - }); - } else { - let mut token_ref = json!({ - "name": state.vault_token_secret_name, - "key": state.vault_token_secret_key, - }); - if state.secret_store_scope == SecretStoreScope::Cluster - || !state.vault_token_secret_namespace.is_empty() - { - token_ref["namespace"] = json!(state.vault_token_secret_namespace); - } - vault["auth"] = json!({ "tokenSecretRef": token_ref }); - } - - json!({ "vault": vault }) -} - -/// `230-secret-store.yaml.gotmpl` Object (SecretStore or ClusterSecretStore). -pub fn secret_store_object(state: &EffectiveState) -> Value { - let provider = secret_store_provider(state); - let (meta_name, composition_name, manifest) = match state.secret_store_scope { - SecretStoreScope::Cluster => ( - format!("{}-cluster-secret-store", state.name), - "secret-store", - json!({ - "apiVersion": "external-secrets.io/v1", - "kind": "ClusterSecretStore", - "metadata": { "name": state.secret_store_name }, - "spec": { "provider": provider }, - }), - ), - SecretStoreScope::Namespaced => ( - format!("{}-secret-store", state.name), - "secret-store", - json!({ - "apiVersion": "external-secrets.io/v1", - "kind": "SecretStore", - "metadata": { - "name": state.secret_store_name, - "namespace": state.namespace, - }, - "spec": { "provider": provider }, - }), - ), - }; - - json!({ - "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", - "kind": "Object", - "metadata": { - "name": meta_name, - "annotations": { - "crossplane.io/composition-resource-name": composition_name - }, - "labels": labels(state), - }, - "spec": { - "managementPolicies": management_policies(state), - "forProvider": { "manifest": manifest }, - "providerConfigRef": { - "name": state.k8s_provider.name, - "kind": state.k8s_provider.kind, - } - } - }) -} - -fn secret_store_object_name(state: &EffectiveState) -> String { - match state.secret_store_scope { - SecretStoreScope::Cluster => format!("{}-cluster-secret-store", state.name), - SecretStoreScope::Namespaced => format!("{}-secret-store", state.name), - } -} - -pub fn usage_secret_store_before_eso_helm(state: &EffectiveState) -> Value { - json!({ - "apiVersion": "protection.crossplane.io/v1beta1", - "kind": "Usage", - "metadata": { - "name": format!("{}-delete-secret-store-before-helm", state.name), - "annotations": { - "crossplane.io/composition-resource-name": "usage-secret-store-helm" - }, - "labels": labels(state), - }, - "spec": { - "replayDeletion": true, - "of": { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "resourceRef": { "name": state.helm_release_name } - }, - "by": { - "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", - "kind": "Object", - "resourceRef": { "name": secret_store_object_name(state) } - } - } - }) -} - -pub fn usage_secret_store_before_vault(state: &EffectiveState) -> Value { - json!({ - "apiVersion": "protection.crossplane.io/v1beta1", - "kind": "Usage", - "metadata": { - "name": format!("{}-delete-secret-store-before-vault", state.name), - "annotations": { - "crossplane.io/composition-resource-name": "usage-secret-store-vault" - }, - "labels": labels(state), - }, - "spec": { - "replayDeletion": true, - "of": { - "apiVersion": "helm.m.crossplane.io/v1beta1", - "kind": "Release", - "resourceRef": { "name": state.vault_release_name } - }, - "by": { - "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", - "kind": "Object", - "resourceRef": { "name": secret_store_object_name(state) } - } - } - }) -} - -/// Shallow-ish recursive merge of object maps (gotmpl mergeOverwrite-ish for objects). -fn merge_objects(base: &mut Value, overlay: &Value) { - match (base, overlay) { - (Value::Object(b), Value::Object(o)) => { - for (k, v) in o { - match b.get_mut(k) { - Some(existing) => merge_objects(existing, v), - None => { - b.insert(k.clone(), v.clone()); - } - } - } - } - (base, overlay) => { - *base = overlay.clone(); - } - } -} diff --git a/experimental/rust-compose/src/state.rs b/experimental/rust-compose/src/state.rs deleted file mode 100644 index 78432a0..0000000 --- a/experimental/rust-compose/src/state.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! Effective SecretStack state — mirrors `000-state-init.yaml.gotmpl`. - -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; - -use crate::gate::{ObservedSlice, Ready}; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Backend { - #[default] - Aws, - Vault, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum SecretStoreScope { - #[default] - Namespaced, - Cluster, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProviderConfigRef { - pub name: String, - pub kind: String, -} - -impl ProviderConfigRef { - pub fn new(name: impl Into, kind: impl Into) -> Self { - Self { - name: name.into(), - kind: kind.into(), - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct EffectiveState { - pub name: String, - pub cluster_name: String, - pub namespace: String, - pub management_policies: Vec, - pub labels: Map, - pub backend: Backend, - - pub helm_release_name: String, - pub helm_namespace: String, - pub helm_provider: ProviderConfigRef, - pub helm_values: Value, - pub helm_override_all: Option, - - pub k8s_provider: ProviderConfigRef, - - pub aws_enabled: bool, - pub aws_region: String, - pub aws_provider: ProviderConfigRef, - pub aws_permissions_boundary_arn: String, - pub aws_role_prefix: String, - pub aws_tags: Map, - - pub vault_enabled: bool, - pub vault_install: bool, - pub vault_namespace: String, - pub vault_release_name: String, - pub vault_server: String, - pub vault_path: String, - pub vault_version: String, - pub vault_auth_method: String, - pub vault_auth_mount_path: String, - pub vault_auth_role: String, - pub vault_token_secret_name: String, - pub vault_token_secret_key: String, - pub vault_token_secret_namespace: String, - pub vault_values: Value, - pub vault_override_all: Option, - - pub secret_store_enabled: bool, - pub secret_store_scope: SecretStoreScope, - pub secret_store_name: String, - - pub pod_identity_name: String, - pub service_account_name: String, - pub service_account_namespace: String, -} - -/// Minimal XR input for effective-state construction (subset of XRD). -#[derive(Debug, Clone, Default)] -pub struct SecretStackSpec { - pub metadata_name: String, - pub cluster_name: String, - pub backend: Backend, - pub namespace: Option, - pub release_name: Option, - pub labels: Map, - pub management_policies: Option>, - pub values: Value, - pub override_all_values: Option, - - pub helm_provider_name: Option, - pub k8s_provider_name: Option, - pub aws_provider_name: Option, - - pub aws_region: Option, - pub aws_permissions_boundary_arn: Option, - pub aws_role_prefix: Option, - pub aws_tags: Map, - - pub vault_install: bool, - pub vault_namespace: Option, - pub vault_release_name: Option, - pub vault_server: Option, - pub vault_path: Option, - pub vault_version: Option, - pub vault_auth_method: Option, - pub vault_auth_mount_path: Option, - pub vault_auth_role: Option, - pub vault_token_secret_name: Option, - pub vault_token_secret_key: Option, - pub vault_token_secret_namespace: Option, - pub vault_values: Value, - pub vault_override_all: Option, - - pub secret_store_enabled: Option, - pub secret_store_scope: Option, - pub secret_store_name: Option, -} - -impl EffectiveState { - pub fn from_spec(spec: &SecretStackSpec) -> Self { - let name = if spec.metadata_name.is_empty() { - "external-secrets".into() - } else { - spec.metadata_name.clone() - }; - let cluster_name = if spec.cluster_name.is_empty() { - name.clone() - } else { - spec.cluster_name.clone() - }; - let namespace = spec - .namespace - .clone() - .unwrap_or_else(|| "external-secrets".into()); - let backend = spec.backend; - - let mut labels = Map::new(); - labels.insert("hops.ops.com.ai/managed".into(), json!("true")); - labels.insert(format!("hops.ops.com.ai/{}", "secretstack"), json!(name)); - for (k, v) in &spec.labels { - labels.insert(k.clone(), v.clone()); - } - - let mut aws_tags = labels.clone(); - for (k, v) in &spec.aws_tags { - aws_tags.insert(k.clone(), v.clone()); - } - - let vault_enabled = matches!(backend, Backend::Vault); - let vault_install = vault_enabled && spec.vault_install; - let vault_namespace = spec - .vault_namespace - .clone() - .unwrap_or_else(|| "vault".into()); - let vault_release_name = spec - .vault_release_name - .clone() - .unwrap_or_else(|| "vault".into()); - let default_vault_server = format!( - "http://{}.{}.svc.cluster.local:8200", - vault_release_name, vault_namespace - ); - let vault_server = spec - .vault_server - .clone() - .filter(|s| !s.is_empty()) - .or_else(|| vault_install.then_some(default_vault_server)) - .unwrap_or_default(); - - let vault_auth_method = spec.vault_auth_method.clone().unwrap_or_else(|| { - if vault_install { - "kubernetes".into() - } else { - "token".into() - } - }); - - Self { - name: name.clone(), - cluster_name: cluster_name.clone(), - namespace: namespace.clone(), - management_policies: spec - .management_policies - .clone() - .unwrap_or_else(|| vec!["*".into()]), - labels, - backend, - - helm_release_name: spec - .release_name - .clone() - .unwrap_or_else(|| "external-secrets".into()), - helm_namespace: namespace.clone(), - helm_provider: ProviderConfigRef::new( - spec.helm_provider_name - .clone() - .unwrap_or_else(|| cluster_name.clone()), - "ProviderConfig", - ), - helm_values: if spec.values.is_null() { - json!({}) - } else { - spec.values.clone() - }, - helm_override_all: spec.override_all_values.clone(), - - k8s_provider: ProviderConfigRef::new( - spec.k8s_provider_name - .clone() - .unwrap_or_else(|| cluster_name.clone()), - "ProviderConfig", - ), - - aws_enabled: matches!(backend, Backend::Aws), - aws_region: spec.aws_region.clone().unwrap_or_default(), - aws_provider: ProviderConfigRef::new( - spec.aws_provider_name - .clone() - .unwrap_or_else(|| "default".into()), - "ProviderConfig", - ), - aws_permissions_boundary_arn: spec - .aws_permissions_boundary_arn - .clone() - .unwrap_or_default(), - aws_role_prefix: spec.aws_role_prefix.clone().unwrap_or_default(), - aws_tags, - - vault_enabled, - vault_install, - vault_namespace, - vault_release_name, - vault_server, - vault_path: spec.vault_path.clone().unwrap_or_else(|| "secret".into()), - vault_version: spec.vault_version.clone().unwrap_or_else(|| "v2".into()), - vault_auth_method, - vault_auth_mount_path: spec - .vault_auth_mount_path - .clone() - .unwrap_or_else(|| "kubernetes".into()), - vault_auth_role: spec - .vault_auth_role - .clone() - .unwrap_or_else(|| "external-secrets".into()), - vault_token_secret_name: spec - .vault_token_secret_name - .clone() - .unwrap_or_else(|| "vault-token".into()), - vault_token_secret_key: spec - .vault_token_secret_key - .clone() - .unwrap_or_else(|| "token".into()), - vault_token_secret_namespace: spec - .vault_token_secret_namespace - .clone() - .unwrap_or_else(|| namespace.clone()), - vault_values: if spec.vault_values.is_null() { - json!({}) - } else { - spec.vault_values.clone() - }, - vault_override_all: spec.vault_override_all.clone(), - - secret_store_enabled: spec.secret_store_enabled.unwrap_or(true), - secret_store_scope: spec.secret_store_scope.unwrap_or_default(), - secret_store_name: spec - .secret_store_name - .clone() - .unwrap_or_else(|| "default".into()), - - pod_identity_name: format!("{name}-external-secrets"), - service_account_name: "external-secrets".into(), - service_account_namespace: namespace, - } - } -} - -/// Observed slices — mirrors `010-state-status.yaml.gotmpl`. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Observed { - pub helm_external_secrets: ObservedSlice, - pub helm_vault: ObservedSlice, - pub pod_identity: ObservedSlice, - pub secret_store: ObservedSlice, -} - -impl Observed { - pub fn bootstrap() -> Self { - Self::default() - } -} - -/// Status fields written back to the XR (ready left for auto-ready in real pipeline). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StatusOut { - pub ready: bool, - pub backend: Backend, - pub secret_store_name: String, - pub secret_store_scope: SecretStoreScope, - pub secret_store_ready: bool, - pub vault: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VaultStatus { - pub release_name: String, - pub namespace: String, - pub ready: bool, -} - -pub fn compute_status(state: &EffectiveState, obs: &Observed) -> StatusOut { - StatusOut { - ready: false, // function-auto-ready owns overall ready in the real pipeline - backend: state.backend, - secret_store_name: state.secret_store_name.clone(), - secret_store_scope: state.secret_store_scope, - secret_store_ready: obs.secret_store.ready.is_set(), - vault: state.vault_install.then(|| VaultStatus { - release_name: state.vault_release_name.clone(), - namespace: state.vault_namespace.clone(), - ready: obs.helm_vault.ready.is_set(), - }), - } -} - -/// Convenience: Ready for multi-resource Usage gates. -pub fn all_ready(slices: &[Ready]) -> Ready { - Ready(slices.iter().all(|r| r.is_set())) -} From ee25fd05729e7673f9ae1a0cf9876990878d377c Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 01:50:07 -0500 Subject: [PATCH 5/9] fix: validate and bootstrap SecretStack backends Implements [[tasks/address-secretstack-review-findings]] --- .github/workflows/on-pr.yaml | 21 +++ .github/workflows/on-push-main.yaml | 21 +++ Makefile | 5 +- README.md | 6 + apis/secretstacks/definition.yaml | 5 + .../render/201-helm-release-vault.yaml.gotmpl | 59 ++++++++ functions/render/230-secret-store.yaml.gotmpl | 2 +- .../fixtures/invalid-aws-missing-region.yaml | 8 ++ .../invalid-vault-missing-server.yaml | 12 ++ tests/fixtures/vault-cluster-token.yaml | 20 +++ tests/fixtures/vault-install-production.yaml | 18 +++ tests/review-findings.sh | 136 ++++++++++++++++++ tests/test-render/main.k | 10 ++ 13 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/invalid-aws-missing-region.yaml create mode 100644 tests/fixtures/invalid-vault-missing-server.yaml create mode 100644 tests/fixtures/vault-cluster-token.yaml create mode 100644 tests/fixtures/vault-install-production.yaml create mode 100755 tests/review-findings.sh diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 9a8f607..a7f2c10 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -46,6 +46,26 @@ jobs: test: uses: unbounded-tech/workflows-crossplane/.github/workflows/test.yaml@v3.0.0 + review-findings: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install up + uses: upbound/action-up@v1.0.0 + with: + skip-login: true + - name: Build project + uses: upbound/action-up-project@v1.1.2 + with: + skip-login-check: true + - name: Install Crossplane CLI + run: | + curl -sL https://cli.crossplane.io/install.sh | XP_VERSION=v2.4.1 sh + sudo mv crossplane /usr/local/bin/crossplane + - name: Verify review findings + run: tests/review-findings.sh + e2e: uses: unbounded-tech/workflows-crossplane/.github/workflows/e2e.yaml@v3.0.0 with: @@ -77,6 +97,7 @@ jobs: needs: - validate - test + - review-findings uses: unbounded-tech/workflows-crossplane/.github/workflows/publish.yaml@v3.0.0 secrets: inherit with: diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index 8476f47..f89cd8c 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -42,6 +42,26 @@ jobs: test: uses: unbounded-tech/workflows-crossplane/.github/workflows/test.yaml@v3.0.0 + review-findings: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install up + uses: upbound/action-up@v1.0.0 + with: + skip-login: true + - name: Build project + uses: upbound/action-up-project@v1.1.2 + with: + skip-login-check: true + - name: Install Crossplane CLI + run: | + curl -sL https://cli.crossplane.io/install.sh | XP_VERSION=v2.4.1 sh + sudo mv crossplane /usr/local/bin/crossplane + - name: Verify review findings + run: tests/review-findings.sh + e2e: uses: unbounded-tech/workflows-crossplane/.github/workflows/e2e.yaml@v3.0.0 with: @@ -74,6 +94,7 @@ jobs: needs: - validate - test + - review-findings - e2e uses: unbounded-tech/workflow-vnext-tag/.github/workflows/workflow.yaml@v1.21.3 secrets: diff --git a/Makefile b/Makefile index 0d544ca..3e4ba70 100644 --- a/Makefile +++ b/Makefile @@ -100,7 +100,7 @@ validate\:all: generate-configuration exit $$failed # Shorthand aliases -.PHONY: render validate generate-configuration +.PHONY: render validate generate-configuration test test-review e2e render: ; @$(MAKE) 'render:all' validate: ; @$(MAKE) generate-configuration 'validate:all' @@ -118,6 +118,9 @@ validate\:%: generate-configuration test: up test run $(RENDER_TESTS) +test-review: + ./tests/review-findings.sh + e2e: up test run $(E2E_TESTS) --e2e diff --git a/README.md b/README.md index 727ed1e..00e724e 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,12 @@ spec: role: external-secrets ``` +The default dev-mode Vault install bootstraps the Kubernetes auth method, +cluster connection, policy, and ESO role before the Vault pod becomes Ready. +If `vault.values` or `vault.overrideAllValues` disables dev mode for an HA or +production deployment, configure Vault authentication externally; Hops does not +assume or synthesize production administrator credentials. + ### External Vault (token auth) ```yaml diff --git a/apis/secretstacks/definition.yaml b/apis/secretstacks/definition.yaml index bfc3e88..4ccbe25 100644 --- a/apis/secretstacks/definition.yaml +++ b/apis/secretstacks/definition.yaml @@ -205,6 +205,11 @@ spec: namespace: description: Namespace of the token secret. Defaults to the ESO namespace. type: string + x-kubernetes-validations: + - rule: "self.backend != 'aws' || (has(self.aws) && has(self.aws.region) && self.aws.region != '')" + message: "backend=aws requires spec.aws.region" + - rule: "self.backend != 'vault' || (has(self.vault) && ((has(self.vault.install) && self.vault.install) || (has(self.secretStore) && has(self.secretStore.enabled) && !self.secretStore.enabled) || (has(self.vault.server) && self.vault.server != '')))" + message: "backend=vault with vault.install=false requires spec.vault.server when the SecretStore is enabled" required: - clusterName status: diff --git a/functions/render/201-helm-release-vault.yaml.gotmpl b/functions/render/201-helm-release-vault.yaml.gotmpl index b2929b3..4d0663f 100644 --- a/functions/render/201-helm-release-vault.yaml.gotmpl +++ b/functions/render/201-helm-release-vault.yaml.gotmpl @@ -9,6 +9,47 @@ {{- if $state.vault.install }} +{{- /* +Default dev mode has a known root token, so the chart-supported postStart hook +can idempotently bootstrap Kubernetes auth before the Vault pod becomes Ready. +Non-dev/HA overrides intentionally receive no bootstrap: production operators +must configure Vault with externally managed administrator credentials. +*/}} +{{- $vaultBootstrapScript := ` +set -eu +export VAULT_ADDR="http://127.0.0.1:8200" +export VAULT_TOKEN="${VAULT_DEV_ROOT_TOKEN_ID:-root}" + +until vault status >/dev/null 2>&1; do + sleep 1 +done + +if ! vault auth list -format=json | grep -q "\"${HOPS_VAULT_AUTH_MOUNT_PATH}/\""; then + vault auth enable -path="${HOPS_VAULT_AUTH_MOUNT_PATH}" kubernetes +fi + +vault write "auth/${HOPS_VAULT_AUTH_MOUNT_PATH}/config" \ + kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS:-443}" + +vault policy write "${HOPS_VAULT_AUTH_ROLE}" - </dev/null 2>&1; then + validate_resource() { + "$crossplane_bin" resource validate "$xrd" "$1" + } +elif "$crossplane_bin" beta validate --help >/dev/null 2>&1; then + validate_resource() { + "$crossplane_bin" beta validate "$1" "$xrd" + } +else + echo "Unsupported Crossplane CLI: expected resource validate or beta validate" >&2 + exit 1 +fi + +expect_invalid() { + local fixture="$1" + local expected="$2" + local output + local status + + set +e + output="$(validate_resource "$fixture" 2>&1)" + status=$? + set -e + + if [[ $status -eq 0 ]]; then + echo "Expected schema validation to reject $fixture" >&2 + exit 1 + fi + if ! grep -Fq "$expected" <<<"$output"; then + echo "Validation failed for an unexpected reason: $fixture" >&2 + echo "$output" >&2 + exit 1 + fi +} + +expect_invalid \ + "tests/fixtures/invalid-aws-missing-region.yaml" \ + "backend=aws requires spec.aws.region" +expect_invalid \ + "tests/fixtures/invalid-vault-missing-server.yaml" \ + "backend=vault with vault.install=false requires spec.vault.server" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +render() { + local fixture="$1" + local output="$2" + local observed="${3:-}" + if [[ -n "$observed" ]]; then + up composition render \ + --xrd="$xrd" \ + apis/secretstacks/composition.yaml \ + "$fixture" \ + --observed-resources="$observed" \ + --quiet >"$output" + else + up composition render \ + --xrd="$xrd" \ + apis/secretstacks/composition.yaml \ + "$fixture" \ + --quiet >"$output" + fi +} + +render "examples/secretstacks/vault.yaml" "$tmpdir/vault-dev.yaml" +render \ + "examples/secretstacks/vault-external.yaml" \ + "$tmpdir/vault-namespaced.yaml" \ + "examples/test/mocks/observed-resources/standard/steps/1/" +render \ + "tests/fixtures/vault-cluster-token.yaml" \ + "$tmpdir/vault-cluster.yaml" \ + "examples/test/mocks/observed-resources/standard/steps/1/" +render \ + "tests/fixtures/vault-install-production.yaml" \ + "$tmpdir/vault-production.yaml" + +ruby -ryaml - "$tmpdir" <<'RUBY' +dir = ARGV.fetch(0) + +def documents(path) + YAML.load_stream(File.read(path)).compact +end + +def composed(docs, resource_name) + docs.find do |doc| + doc.dig("metadata", "annotations", "crossplane.io/composition-resource-name") == resource_name + end or raise "missing composed resource #{resource_name}" +end + +dev = composed(documents(File.join(dir, "vault-dev.yaml")), "helm-vault") +server = dev.dig("spec", "forProvider", "values", "server") +raise "dev Vault must enable authDelegator" unless server.dig("authDelegator", "enabled") == true +script = server.fetch("postStart").fetch(2) +[ + "vault auth enable", + "/config", + "vault policy write", + "/role/", +].each do |command| + raise "Vault bootstrap is missing #{command}" unless script.include?(command) +end + +namespaced = composed(documents(File.join(dir, "vault-namespaced.yaml")), "secret-store") +namespaced_ref = namespaced.dig( + "spec", "forProvider", "manifest", "spec", "provider", "vault", "auth", "tokenSecretRef" +) +raise "namespaced SecretStore must omit token namespace" if namespaced_ref.key?("namespace") + +cluster = composed(documents(File.join(dir, "vault-cluster.yaml")), "secret-store") +cluster_ref = cluster.dig( + "spec", "forProvider", "manifest", "spec", "provider", "vault", "auth", "tokenSecretRef" +) +unless cluster_ref["namespace"] == "external-secrets" + raise "ClusterSecretStore must retain token namespace" +end + +production = composed(documents(File.join(dir, "vault-production.yaml")), "helm-vault") +production_server = production.dig("spec", "forProvider", "values", "server") +if production_server.key?("postStart") + raise "non-dev Vault must not receive the dev root-token bootstrap" +end +RUBY + +echo "SecretStack review-finding tests passed" diff --git a/tests/test-render/main.k b/tests/test-render/main.k index 2e4c0b3..c6d6d9a 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -89,6 +89,16 @@ _items = [ spec.forProvider = { chart.name = "vault" namespace = "vault" + values.server = { + authDelegator.enabled = True + extraEnvironmentVars = { + HOPS_VAULT_AUTH_MOUNT_PATH = "kubernetes" + HOPS_VAULT_AUTH_ROLE = "external-secrets" + HOPS_VAULT_ESO_NAMESPACE = "external-secrets" + HOPS_VAULT_ESO_SERVICE_ACCOUNT = "external-secrets" + HOPS_VAULT_PATH = "secret" + } + } } } ] From b2bebc46da3d0b990edf6b066fc1942657a194ab Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 02:13:37 -0500 Subject: [PATCH 6/9] fix: harden review workflow checkout Implements [[tasks/address-secretstack-review-findings]] --- .github/workflows/on-pr.yaml | 2 ++ .github/workflows/on-push-main.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index a7f2c10..bcc672f 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -51,6 +51,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + persist-credentials: false - name: Install up uses: upbound/action-up@v1.0.0 with: diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index f89cd8c..9d7eb06 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -47,6 +47,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + persist-credentials: false - name: Install up uses: upbound/action-up@v1.0.0 with: From ed11b8f4e34d293eb6e8a25adeb1c05c01463adb Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 05:43:28 -0500 Subject: [PATCH 7/9] fix: compute SecretStack readiness Implements [[tasks/fix-secretstack-status-ready]] --- functions/render/010-state-status.yaml.gotmpl | 37 ++- tests/test-render/main.k | 282 ++++++++++++++++++ 2 files changed, 317 insertions(+), 2 deletions(-) diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index d8c7ced..232f970 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -70,12 +70,40 @@ {{- end }} {{- $secretStoreExists := not (empty $secretStoreResource) }} +# --- deletion-order Usages (optional) --- +{{- $usageReady := dict }} +{{- range $usageName := (list "usage-secret-store-helm" "usage-secret-store-vault" "usage-pod-identity") }} + {{- $usageObs := get $observed $usageName | default dict }} + {{- $usageResource := $usageObs.resource | default dict }} + {{- $usageStatus := $usageResource.status | default dict }} + {{- $isReady := false }} + {{- range $cond := ($usageStatus.conditions | default list) }} + {{- if and (eq $cond.type "Ready") (eq $cond.status "True") }} + {{- $isReady = true }} + {{- end }} + {{- end }} + {{- $usageReady = set $usageReady $usageName $isReady }} +{{- end }} + # ============================================================================== # Compute status output # ============================================================================== -# Ready state determined by function-auto-ready -{{- $ready := false }} +# Mirror function-auto-ready for resources applicable to this configuration. +# Optional resources that do not render must not hold readiness false. +{{- $ready := $helmExternalSecretsReady }} +{{- if $state.aws.enabled }} + {{- $ready = and $ready $podIdentityReady (get $usageReady "usage-pod-identity") }} +{{- end }} +{{- if $state.vault.install }} + {{- $ready = and $ready $helmVaultReady }} +{{- end }} +{{- if $state.secretStore.enabled }} + {{- $ready = and $ready $secretStoreReady (get $usageReady "usage-secret-store-helm") }} + {{- if $state.vault.install }} + {{- $ready = and $ready (get $usageReady "usage-secret-store-vault") }} + {{- end }} +{{- end }} {{- $state = set $state "observed" (dict "helmExternalSecrets" (dict @@ -96,6 +124,11 @@ "ready" $secretStoreReady "exists" $secretStoreExists ) + "usages" (dict + "secretStoreHelmReady" (get $usageReady "usage-secret-store-helm") + "secretStoreVaultReady" (get $usageReady "usage-secret-store-vault") + "podIdentityReady" (get $usageReady "usage-pod-identity") + ) ) }} {{- $status := dict diff --git a/tests/test-render/main.k b/tests/test-render/main.k index c6d6d9a..b23fdb5 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -5,6 +5,87 @@ import models.k8s.apimachinery.pkg.apis.meta.v1 as metav1 # Unit tests for platform SecretStack XRD # ============================================================================== +_readyConditions = [ + {type = "Ready", status = "True"} + {type = "Synced", status = "True"} +] + +_observedHelmExternalSecrets = { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata = { + name = "external-secrets" + annotations = {"crossplane.io/composition-resource-name" = "helm-external-secrets"} + } + status = { + atProvider = {revision = 1, state = "deployed"} + conditions = _readyConditions + } +} + +_observedHelmVault = { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata = { + name = "vault" + annotations = {"crossplane.io/composition-resource-name" = "helm-vault"} + } + status = { + atProvider = {revision = 1, state = "deployed"} + conditions = _readyConditions + } +} + +_observedPodIdentity = { + apiVersion = "aws.hops.ops.com.ai/v1alpha1" + kind = "PodIdentity" + metadata = { + name = "test-external-secrets" + annotations = {"crossplane.io/composition-resource-name" = "pod-identity"} + } + status.conditions = _readyConditions +} + +_observedSecretStore = { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "test-secret-store" + annotations = {"crossplane.io/composition-resource-name" = "secret-store"} + } + status.conditions = _readyConditions +} + +_observedUsageSecretStoreHelm = { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata = { + name = "test-delete-secret-store-before-helm" + annotations = {"crossplane.io/composition-resource-name" = "usage-secret-store-helm"} + } + status.conditions = _readyConditions +} + +_observedUsageSecretStoreVault = { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata = { + name = "test-delete-secret-store-before-vault" + annotations = {"crossplane.io/composition-resource-name" = "usage-secret-store-vault"} + } + status.conditions = _readyConditions +} + +_observedUsagePodIdentity = { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata = { + name = "test-delete-helm-eso-before-pod-identity" + annotations = {"crossplane.io/composition-resource-name" = "usage-pod-identity"} + } + status.conditions = _readyConditions +} + _items = [ # ========================================================================== # Test 1: AWS minimal renders Helm Release and Pod Identity @@ -356,6 +437,207 @@ _items = [ ] } } + + # ========================================================================== + # Test 7: Readiness starts false without observed resources + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "status-ready-false-before-observation" + spec = { + compositionPath = "apis/secretstacks/composition.yaml" + xrdPath = "apis/secretstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + spec = { + backend = "vault" + vault.install = True + } + } + assertResources = [{ + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + status.ready = False + }] + } + } + + # ========================================================================== + # Test 8: Installed Vault is ready only after all resources and Usages + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "status-ready-vault-install" + spec = { + compositionPath = "apis/secretstacks/composition.yaml" + xrdPath = "apis/secretstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + spec = { + backend = "vault" + vault.install = True + } + } + observedResources = [ + _observedHelmExternalSecrets + _observedHelmVault + _observedSecretStore + _observedUsageSecretStoreHelm + _observedUsageSecretStoreVault + ] + assertResources = [{ + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + status.ready = True + }] + } + } + + # ========================================================================== + # Test 9: Missing rendered Usage keeps installed Vault unready + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "status-ready-vault-waits-for-usage" + spec = { + compositionPath = "apis/secretstacks/composition.yaml" + xrdPath = "apis/secretstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + spec = { + backend = "vault" + vault.install = True + } + } + observedResources = [ + _observedHelmExternalSecrets + _observedHelmVault + _observedSecretStore + ] + assertResources = [{ + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + status.ready = False + }] + } + } + + # ========================================================================== + # Test 10: External Vault does not wait for a Vault Helm release + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "status-ready-vault-external" + spec = { + compositionPath = "apis/secretstacks/composition.yaml" + xrdPath = "apis/secretstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + spec = { + backend = "vault" + vault = { + install = False + server = "https://vault.example.com" + } + } + } + observedResources = [ + _observedHelmExternalSecrets + _observedSecretStore + _observedUsageSecretStoreHelm + ] + assertResources = [{ + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + status.ready = True + }] + } + } + + # ========================================================================== + # Test 11: AWS readiness includes PodIdentity and its Usage + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "status-ready-aws" + spec = { + compositionPath = "apis/secretstacks/composition.yaml" + xrdPath = "apis/secretstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + spec = { + backend = "aws" + aws.region = "us-east-1" + } + } + observedResources = [ + _observedHelmExternalSecrets + _observedPodIdentity + _observedSecretStore + _observedUsageSecretStoreHelm + _observedUsagePodIdentity + ] + assertResources = [{ + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + status.ready = True + }] + } + } + + # ========================================================================== + # Test 12: Disabled SecretStore is excluded from AWS readiness + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "status-ready-aws-without-secret-store" + spec = { + compositionPath = "apis/secretstacks/composition.yaml" + xrdPath = "apis/secretstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + spec = { + backend = "aws" + secretStore.enabled = False + aws.region = "us-east-1" + } + } + observedResources = [ + _observedHelmExternalSecrets + _observedPodIdentity + _observedUsagePodIdentity + ] + assertResources = [{ + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "SecretStack" + metadata.name = "test" + status.ready = True + }] + } + } ] items = _items From d5ea605a7f9ae89ca84b125e54781aae236639b6 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 05:49:42 -0500 Subject: [PATCH 8/9] fix: preserve disabled SecretStore setting Implements [[tasks/fix-secretstack-status-ready]] --- functions/render/000-state-init.yaml.gotmpl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl index ade3139..7afb800 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -88,6 +88,10 @@ # SecretStore # ============================================================================== {{- $secretStoreSpec := $spec.secretStore | default dict }} +{{- $secretStoreEnabled := true }} +{{- if hasKey $secretStoreSpec "enabled" }} + {{- $secretStoreEnabled = $secretStoreSpec.enabled }} +{{- end }} # ============================================================================== # Initialize $state @@ -137,7 +141,7 @@ "overrideAllValues" ($vault.overrideAllValues | default dict) ) "secretStore" (dict - "enabled" ($secretStoreSpec.enabled | default true) + "enabled" $secretStoreEnabled "scope" ($secretStoreSpec.scope | default "Namespaced") "name" ($secretStoreSpec.name | default "default") ) From 3d0206939f886bc8f1dd9e38aeda33e2a08cf6aa Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 22 Aug 2026 20:55:15 -0500 Subject: [PATCH 9/9] refactor: remove duplicate SecretStack readiness Implements [[tasks/remove-secretstack-aggregate-ready]] --- apis/secretstacks/definition.yaml | 3 - functions/render/010-state-status.yaml.gotmpl | 37 --- functions/render/999-status.yaml.gotmpl | 1 - tests/test-render/main.k | 281 ------------------ 4 files changed, 322 deletions(-) diff --git a/apis/secretstacks/definition.yaml b/apis/secretstacks/definition.yaml index 4ccbe25..d198392 100644 --- a/apis/secretstacks/definition.yaml +++ b/apis/secretstacks/definition.yaml @@ -216,9 +216,6 @@ spec: description: SecretStackStatus defines the observed state. type: object properties: - ready: - description: Overall readiness. - type: boolean backend: description: Active secrets backend. type: string diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index 232f970..fcd4c2f 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -70,41 +70,10 @@ {{- end }} {{- $secretStoreExists := not (empty $secretStoreResource) }} -# --- deletion-order Usages (optional) --- -{{- $usageReady := dict }} -{{- range $usageName := (list "usage-secret-store-helm" "usage-secret-store-vault" "usage-pod-identity") }} - {{- $usageObs := get $observed $usageName | default dict }} - {{- $usageResource := $usageObs.resource | default dict }} - {{- $usageStatus := $usageResource.status | default dict }} - {{- $isReady := false }} - {{- range $cond := ($usageStatus.conditions | default list) }} - {{- if and (eq $cond.type "Ready") (eq $cond.status "True") }} - {{- $isReady = true }} - {{- end }} - {{- end }} - {{- $usageReady = set $usageReady $usageName $isReady }} -{{- end }} - # ============================================================================== # Compute status output # ============================================================================== -# Mirror function-auto-ready for resources applicable to this configuration. -# Optional resources that do not render must not hold readiness false. -{{- $ready := $helmExternalSecretsReady }} -{{- if $state.aws.enabled }} - {{- $ready = and $ready $podIdentityReady (get $usageReady "usage-pod-identity") }} -{{- end }} -{{- if $state.vault.install }} - {{- $ready = and $ready $helmVaultReady }} -{{- end }} -{{- if $state.secretStore.enabled }} - {{- $ready = and $ready $secretStoreReady (get $usageReady "usage-secret-store-helm") }} - {{- if $state.vault.install }} - {{- $ready = and $ready (get $usageReady "usage-secret-store-vault") }} - {{- end }} -{{- end }} - {{- $state = set $state "observed" (dict "helmExternalSecrets" (dict "ready" $helmExternalSecretsReady @@ -124,15 +93,9 @@ "ready" $secretStoreReady "exists" $secretStoreExists ) - "usages" (dict - "secretStoreHelmReady" (get $usageReady "usage-secret-store-helm") - "secretStoreVaultReady" (get $usageReady "usage-secret-store-vault") - "podIdentityReady" (get $usageReady "usage-pod-identity") - ) ) }} {{- $status := dict - "ready" $ready "backend" $state.backend "secretStore" (dict "name" $state.secretStore.name diff --git a/functions/render/999-status.yaml.gotmpl b/functions/render/999-status.yaml.gotmpl index 1b2de26..11166b5 100644 --- a/functions/render/999-status.yaml.gotmpl +++ b/functions/render/999-status.yaml.gotmpl @@ -9,7 +9,6 @@ apiVersion: {{ $xr.apiVersion }} kind: {{ $xr.kind }} status: - ready: {{ $s.ready }} backend: {{ $s.backend }} secretStore: name: {{ $s.secretStore.name }} diff --git a/tests/test-render/main.k b/tests/test-render/main.k index b23fdb5..5e9bf9d 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -5,87 +5,6 @@ import models.k8s.apimachinery.pkg.apis.meta.v1 as metav1 # Unit tests for platform SecretStack XRD # ============================================================================== -_readyConditions = [ - {type = "Ready", status = "True"} - {type = "Synced", status = "True"} -] - -_observedHelmExternalSecrets = { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata = { - name = "external-secrets" - annotations = {"crossplane.io/composition-resource-name" = "helm-external-secrets"} - } - status = { - atProvider = {revision = 1, state = "deployed"} - conditions = _readyConditions - } -} - -_observedHelmVault = { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata = { - name = "vault" - annotations = {"crossplane.io/composition-resource-name" = "helm-vault"} - } - status = { - atProvider = {revision = 1, state = "deployed"} - conditions = _readyConditions - } -} - -_observedPodIdentity = { - apiVersion = "aws.hops.ops.com.ai/v1alpha1" - kind = "PodIdentity" - metadata = { - name = "test-external-secrets" - annotations = {"crossplane.io/composition-resource-name" = "pod-identity"} - } - status.conditions = _readyConditions -} - -_observedSecretStore = { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - metadata = { - name = "test-secret-store" - annotations = {"crossplane.io/composition-resource-name" = "secret-store"} - } - status.conditions = _readyConditions -} - -_observedUsageSecretStoreHelm = { - apiVersion = "protection.crossplane.io/v1beta1" - kind = "Usage" - metadata = { - name = "test-delete-secret-store-before-helm" - annotations = {"crossplane.io/composition-resource-name" = "usage-secret-store-helm"} - } - status.conditions = _readyConditions -} - -_observedUsageSecretStoreVault = { - apiVersion = "protection.crossplane.io/v1beta1" - kind = "Usage" - metadata = { - name = "test-delete-secret-store-before-vault" - annotations = {"crossplane.io/composition-resource-name" = "usage-secret-store-vault"} - } - status.conditions = _readyConditions -} - -_observedUsagePodIdentity = { - apiVersion = "protection.crossplane.io/v1beta1" - kind = "Usage" - metadata = { - name = "test-delete-helm-eso-before-pod-identity" - annotations = {"crossplane.io/composition-resource-name" = "usage-pod-identity"} - } - status.conditions = _readyConditions -} - _items = [ # ========================================================================== # Test 1: AWS minimal renders Helm Release and Pod Identity @@ -438,206 +357,6 @@ _items = [ } } - # ========================================================================== - # Test 7: Readiness starts false without observed resources - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "status-ready-false-before-observation" - spec = { - compositionPath = "apis/secretstacks/composition.yaml" - xrdPath = "apis/secretstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = { - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - spec = { - backend = "vault" - vault.install = True - } - } - assertResources = [{ - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - status.ready = False - }] - } - } - - # ========================================================================== - # Test 8: Installed Vault is ready only after all resources and Usages - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "status-ready-vault-install" - spec = { - compositionPath = "apis/secretstacks/composition.yaml" - xrdPath = "apis/secretstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = { - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - spec = { - backend = "vault" - vault.install = True - } - } - observedResources = [ - _observedHelmExternalSecrets - _observedHelmVault - _observedSecretStore - _observedUsageSecretStoreHelm - _observedUsageSecretStoreVault - ] - assertResources = [{ - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - status.ready = True - }] - } - } - - # ========================================================================== - # Test 9: Missing rendered Usage keeps installed Vault unready - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "status-ready-vault-waits-for-usage" - spec = { - compositionPath = "apis/secretstacks/composition.yaml" - xrdPath = "apis/secretstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = { - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - spec = { - backend = "vault" - vault.install = True - } - } - observedResources = [ - _observedHelmExternalSecrets - _observedHelmVault - _observedSecretStore - ] - assertResources = [{ - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - status.ready = False - }] - } - } - - # ========================================================================== - # Test 10: External Vault does not wait for a Vault Helm release - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "status-ready-vault-external" - spec = { - compositionPath = "apis/secretstacks/composition.yaml" - xrdPath = "apis/secretstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = { - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - spec = { - backend = "vault" - vault = { - install = False - server = "https://vault.example.com" - } - } - } - observedResources = [ - _observedHelmExternalSecrets - _observedSecretStore - _observedUsageSecretStoreHelm - ] - assertResources = [{ - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - status.ready = True - }] - } - } - - # ========================================================================== - # Test 11: AWS readiness includes PodIdentity and its Usage - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "status-ready-aws" - spec = { - compositionPath = "apis/secretstacks/composition.yaml" - xrdPath = "apis/secretstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = { - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - spec = { - backend = "aws" - aws.region = "us-east-1" - } - } - observedResources = [ - _observedHelmExternalSecrets - _observedPodIdentity - _observedSecretStore - _observedUsageSecretStoreHelm - _observedUsagePodIdentity - ] - assertResources = [{ - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - status.ready = True - }] - } - } - - # ========================================================================== - # Test 12: Disabled SecretStore is excluded from AWS readiness - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "status-ready-aws-without-secret-store" - spec = { - compositionPath = "apis/secretstacks/composition.yaml" - xrdPath = "apis/secretstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = { - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - spec = { - backend = "aws" - secretStore.enabled = False - aws.region = "us-east-1" - } - } - observedResources = [ - _observedHelmExternalSecrets - _observedPodIdentity - _observedUsagePodIdentity - ] - assertResources = [{ - apiVersion = "hops.ops.com.ai/v1alpha1" - kind = "SecretStack" - metadata.name = "test" - status.ready = True - }] - } - } ] items = _items