diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 5cfb85f..bcc672f 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 @@ -42,6 +46,28 @@ 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 + with: + persist-credentials: false + - 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: @@ -59,7 +85,7 @@ jobs: } debug-resource-types: | [ - "secretstacks.aws.hops.ops.com.ai" + "secretstacks.hops.ops.com.ai" ] delete-extra-resources: | [ @@ -73,6 +99,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 1e9756d..9d7eb06 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 @@ -38,6 +42,28 @@ 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 + with: + persist-credentials: false + - 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: @@ -55,7 +81,7 @@ jobs: } debug-resource-types: | [ - "secretstacks.aws.hops.ops.com.ai" + "secretstacks.hops.ops.com.ai" ] delete-extra-resources: | [ @@ -70,6 +96,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 7f1bcbe..3e4ba70 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: @@ -38,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; \ @@ -68,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 & \ @@ -94,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' @@ -107,11 +113,14 @@ 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) +test-review: + ./tests/review-findings.sh + e2e: up test run $(E2E_TESTS) --e2e diff --git a/README.md b/README.md index cb33132..00e724e 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,106 @@ -# 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: +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 -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 +108,12 @@ 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) +``` + +### 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..d198392 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,17 +136,108 @@ 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 + 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 - - aws status: description: SecretStackStatus defines the observed state. type: object properties: - 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..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,9 +2,13 @@ apiVersion: helm.m.crossplane.io/v1beta1 kind: Release metadata: name: external-secrets + namespace: default 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/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 new file mode 100644 index 0000000..21872dc --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/1/helm-external-secrets.yaml @@ -0,0 +1,16 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: external-secrets + namespace: default + 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..1139f78 --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/1/helm-vault.yaml @@ -0,0 +1,16 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: vault + namespace: default + 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..21872dc --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/2/helm-external-secrets.yaml @@ -0,0 +1,16 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: external-secrets + namespace: default + 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..1139f78 --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/2/helm-vault.yaml @@ -0,0 +1,16 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: vault + namespace: default + 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..68841f6 --- /dev/null +++ b/examples/test/mocks/observed-resources/vault/steps/2/secret-store.yaml @@ -0,0 +1,13 @@ +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: + 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..7afb800 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,46 +45,103 @@ }} # ============================================================================== -# 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 }} +{{- $secretStoreEnabled := true }} +{{- if hasKey $secretStoreSpec "enabled" }} + {{- $secretStoreEnabled = $secretStoreSpec.enabled }} +{{- end }} +# ============================================================================== +# 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) + "enabled" $secretStoreEnabled "scope" ($secretStoreSpec.scope | default "Namespaced") "name" ($secretStoreSpec.name | default "default") ) diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index 0467d15..fcd4c2f 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,55 +15,100 @@ # ============================================================================== {{- $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 }} + +# --- 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 }} -# Check if pod-identity is ready +# --- 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 # ============================================================================== -# Ready state determined by function-auto-ready -{{- $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 - "ready" $ready -) }} +{{- $status := dict + "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..4d0663f --- /dev/null +++ b/functions/render/201-helm-release-vault.yaml.gotmpl @@ -0,0 +1,114 @@ +# 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 }} + +{{- /* +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}" - < 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 eq $ss.scope "Cluster" }} + {{- $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..11166b5 100644 --- a/functions/render/999-status.yaml.gotmpl +++ b/functions/render/999-status.yaml.gotmpl @@ -4,8 +4,19 @@ # {{- $xr := getCompositeResource . }} +{{- $s := $state.status }} --- apiVersion: {{ $xr.apiVersion }} kind: {{ $xr.kind }} status: - ready: {{ $state.status.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/fixtures/invalid-aws-missing-region.yaml b/tests/fixtures/invalid-aws-missing-region.yaml new file mode 100644 index 0000000..085a10a --- /dev/null +++ b/tests/fixtures/invalid-aws-missing-region.yaml @@ -0,0 +1,8 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: SecretStack +metadata: + name: invalid-aws + namespace: default +spec: + clusterName: test + backend: aws diff --git a/tests/fixtures/invalid-vault-missing-server.yaml b/tests/fixtures/invalid-vault-missing-server.yaml new file mode 100644 index 0000000..f0a0df7 --- /dev/null +++ b/tests/fixtures/invalid-vault-missing-server.yaml @@ -0,0 +1,12 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: SecretStack +metadata: + name: invalid-vault + namespace: default +spec: + clusterName: test + backend: vault + vault: + install: false + auth: + method: token diff --git a/tests/fixtures/vault-cluster-token.yaml b/tests/fixtures/vault-cluster-token.yaml new file mode 100644 index 0000000..6fd410f --- /dev/null +++ b/tests/fixtures/vault-cluster-token.yaml @@ -0,0 +1,20 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: SecretStack +metadata: + name: external-secrets + namespace: default +spec: + clusterName: my-cluster + backend: vault + namespace: external-secrets + secretStore: + scope: Cluster + name: vault + vault: + server: https://vault.example.com:8200 + auth: + method: token + tokenSecretRef: + name: vault-token + key: token + namespace: external-secrets diff --git a/tests/fixtures/vault-install-production.yaml b/tests/fixtures/vault-install-production.yaml new file mode 100644 index 0000000..a92755b --- /dev/null +++ b/tests/fixtures/vault-install-production.yaml @@ -0,0 +1,18 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: SecretStack +metadata: + name: external-secrets + namespace: default +spec: + clusterName: my-cluster + backend: vault + secretStore: + enabled: false + vault: + install: true + values: + server: + dev: + enabled: false + standalone: + enabled: true diff --git a/tests/review-findings.sh b/tests/review-findings.sh new file mode 100755 index 0000000..9c7b9b3 --- /dev/null +++ b/tests/review-findings.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +crossplane_bin="${CROSSPLANE_BIN:-crossplane}" +xrd="apis/secretstacks/definition.yaml" + +if "$crossplane_bin" resource validate --help >/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 28eb67e..5e9bf9d 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,16 +81,23 @@ _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" + 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" + } } } } @@ -113,22 +106,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 +134,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 +152,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 +179,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 +214,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 +251,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 +289,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 +302,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 +312,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 +330,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" @@ -309,50 +357,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