From 2ae62443351f58651f151aab1ee9ae955dd11c0e Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 17 Jun 2026 16:48:04 -0300 Subject: [PATCH] feat(parameters): pluggable secret/param storage with 4 providers --- aws-secret-manager-strategies.docx | Bin 0 -> 15433 bytes parameters/PENDING.md | 197 ++++++++++++++++ parameters/build_context | 100 ++++++++ parameters/delete | 6 + parameters/docs/adding_a_provider.md | 223 ++++++++++++++++++ parameters/docs/architecture.md | 115 +++++++++ parameters/docs/configuration.md | 135 +++++++++++ parameters/entrypoint | 50 ++++ parameters/notify | 10 + parameters/providers/README.md | 184 +++++++++++++++ parameters/providers/azure_key_vault/delete | 70 ++++++ .../azure_key_vault/docs/architecture.md | 103 ++++++++ parameters/providers/azure_key_vault/retrieve | 43 ++++ parameters/providers/azure_key_vault/setup | 48 ++++ parameters/providers/azure_key_vault/store | 36 +++ parameters/providers/hashicorp_vault/delete | 53 +++++ .../hashicorp_vault/docs/architecture.md | 80 +++++++ parameters/providers/hashicorp_vault/retrieve | 55 +++++ parameters/providers/hashicorp_vault/setup | 45 ++++ parameters/providers/hashicorp_vault/store | 35 +++ parameters/providers/parameter_store/delete | 43 ++++ .../parameter_store/docs/architecture.md | 104 ++++++++ .../parameter_store/docs/iam-policy.md | 112 +++++++++ parameters/providers/parameter_store/retrieve | 45 ++++ parameters/providers/parameter_store/setup | 61 +++++ parameters/providers/parameter_store/store | 51 ++++ parameters/providers/secret_manager/delete | 48 ++++ .../secret_manager/docs/architecture.md | 150 ++++++++++++ .../secret_manager/docs/iam-policy.md | 198 ++++++++++++++++ parameters/providers/secret_manager/retrieve | 44 ++++ parameters/providers/secret_manager/setup | 41 ++++ parameters/providers/secret_manager/store | 50 ++++ parameters/retrieve | 6 + parameters/store | 6 + parameters/tests/build_context.bats | 199 ++++++++++++++++ parameters/tests/delete.bats | 43 ++++ parameters/tests/entrypoint.bats | 156 ++++++++++++ parameters/tests/notify.bats | 47 ++++ .../providers/azure_key_vault/delete.bats | 128 ++++++++++ .../providers/azure_key_vault/retrieve.bats | 86 +++++++ .../providers/azure_key_vault/setup.bats | 69 ++++++ .../providers/azure_key_vault/store.bats | 78 ++++++ .../providers/hashicorp_vault/delete.bats | 103 ++++++++ .../providers/hashicorp_vault/retrieve.bats | 95 ++++++++ .../providers/hashicorp_vault/setup.bats | 84 +++++++ .../providers/hashicorp_vault/store.bats | 110 +++++++++ .../providers/parameter_store/delete.bats | 83 +++++++ .../providers/parameter_store/retrieve.bats | 86 +++++++ .../providers/parameter_store/setup.bats | 99 ++++++++ .../providers/parameter_store/store.bats | 139 +++++++++++ .../providers/secret_manager/delete.bats | 86 +++++++ .../providers/secret_manager/retrieve.bats | 85 +++++++ .../tests/providers/secret_manager/setup.bats | 70 ++++++ .../tests/providers/secret_manager/store.bats | 98 ++++++++ parameters/tests/retrieve.bats | 43 ++++ parameters/tests/store.bats | 53 +++++ parameters/tests/utils/get_config_value.bats | 89 +++++++ parameters/tests/utils/log.bats | 64 +++++ parameters/utils/get_config_value | 56 +++++ parameters/utils/log | 24 ++ parameters/workflows/delete.yaml | 12 + parameters/workflows/notify.yaml | 13 + parameters/workflows/retrieve.yaml | 14 ++ parameters/workflows/store.yaml | 16 ++ 64 files changed, 4875 insertions(+) create mode 100644 aws-secret-manager-strategies.docx create mode 100644 parameters/PENDING.md create mode 100755 parameters/build_context create mode 100755 parameters/delete create mode 100644 parameters/docs/adding_a_provider.md create mode 100644 parameters/docs/architecture.md create mode 100644 parameters/docs/configuration.md create mode 100755 parameters/entrypoint create mode 100755 parameters/notify create mode 100644 parameters/providers/README.md create mode 100755 parameters/providers/azure_key_vault/delete create mode 100644 parameters/providers/azure_key_vault/docs/architecture.md create mode 100755 parameters/providers/azure_key_vault/retrieve create mode 100755 parameters/providers/azure_key_vault/setup create mode 100755 parameters/providers/azure_key_vault/store create mode 100755 parameters/providers/hashicorp_vault/delete create mode 100644 parameters/providers/hashicorp_vault/docs/architecture.md create mode 100755 parameters/providers/hashicorp_vault/retrieve create mode 100755 parameters/providers/hashicorp_vault/setup create mode 100755 parameters/providers/hashicorp_vault/store create mode 100755 parameters/providers/parameter_store/delete create mode 100644 parameters/providers/parameter_store/docs/architecture.md create mode 100644 parameters/providers/parameter_store/docs/iam-policy.md create mode 100755 parameters/providers/parameter_store/retrieve create mode 100755 parameters/providers/parameter_store/setup create mode 100755 parameters/providers/parameter_store/store create mode 100755 parameters/providers/secret_manager/delete create mode 100644 parameters/providers/secret_manager/docs/architecture.md create mode 100644 parameters/providers/secret_manager/docs/iam-policy.md create mode 100755 parameters/providers/secret_manager/retrieve create mode 100755 parameters/providers/secret_manager/setup create mode 100755 parameters/providers/secret_manager/store create mode 100755 parameters/retrieve create mode 100755 parameters/store create mode 100644 parameters/tests/build_context.bats create mode 100644 parameters/tests/delete.bats create mode 100644 parameters/tests/entrypoint.bats create mode 100644 parameters/tests/notify.bats create mode 100644 parameters/tests/providers/azure_key_vault/delete.bats create mode 100644 parameters/tests/providers/azure_key_vault/retrieve.bats create mode 100644 parameters/tests/providers/azure_key_vault/setup.bats create mode 100644 parameters/tests/providers/azure_key_vault/store.bats create mode 100644 parameters/tests/providers/hashicorp_vault/delete.bats create mode 100644 parameters/tests/providers/hashicorp_vault/retrieve.bats create mode 100644 parameters/tests/providers/hashicorp_vault/setup.bats create mode 100644 parameters/tests/providers/hashicorp_vault/store.bats create mode 100644 parameters/tests/providers/parameter_store/delete.bats create mode 100644 parameters/tests/providers/parameter_store/retrieve.bats create mode 100644 parameters/tests/providers/parameter_store/setup.bats create mode 100644 parameters/tests/providers/parameter_store/store.bats create mode 100644 parameters/tests/providers/secret_manager/delete.bats create mode 100644 parameters/tests/providers/secret_manager/retrieve.bats create mode 100644 parameters/tests/providers/secret_manager/setup.bats create mode 100644 parameters/tests/providers/secret_manager/store.bats create mode 100644 parameters/tests/retrieve.bats create mode 100644 parameters/tests/store.bats create mode 100644 parameters/tests/utils/get_config_value.bats create mode 100644 parameters/tests/utils/log.bats create mode 100755 parameters/utils/get_config_value create mode 100755 parameters/utils/log create mode 100644 parameters/workflows/delete.yaml create mode 100644 parameters/workflows/notify.yaml create mode 100644 parameters/workflows/retrieve.yaml create mode 100644 parameters/workflows/store.yaml diff --git a/aws-secret-manager-strategies.docx b/aws-secret-manager-strategies.docx new file mode 100644 index 0000000000000000000000000000000000000000..3502ddd5f09622bb172421ee26f1ba960ba41df8 GIT binary patch literal 15433 zcmc(Gb99|u)AxyOG`7*$w(X>iZQHipsIjfaww=Ze8a8HwFTK^?eV^xB-}l$M*SgMi z);jw)XZFnOy=P|6mX`tng$Dd+4Z*eV{`m6O2iWV|#m3%{PX51kf%&_Oj=hnk!#^D% z|DZLHE@$-c+JO)h0KoZoM?)I}Co3as$G5IlmT!MRD-zaZK?o6pbb@S8Yb!LPC{Lmo z4k|S8eQWFZ>@Xpvqr5yAoT*vh&izL@m@@7eIm(-|jrH+2drcKCL{uEWi>F%{I z?JtL#K3r2vhkp^q5PhTW=`V2MH^c*lbOjI%8sY3(Iw)rg*FW&sO7vFa2{n3O6)4AZ zS3thf5dZys>t;Cz=Uq(GNsB^n-8=+{xSDDx{~(m0qjCpC{9{pNF2b9|MLMa*Awn9FIkE>={GEDz#1 zE{)KpRA@dY+>?G^KN@($N8(@0oLuNtEskjF*y+8Y^VG`sE)WPCCQ}@ZXKnwRBWr3X z_wK0ao#Xb&hpMJ*%W9I{xJeMSup(@fILpVa+fJ_2`;k#Wqym~Nce>!XnKt~c)30k@ z&g))%6WiaO!2`PU@?G#ebux>w}^kIj^l5>5$s+XK2SUXo8>>7B=+c5qYW-^kUPl>bI1|czy{I&@4^MT}1GjEj08wbKml2$}C%EJ}2F+|2}29pcK zeLUntAb?oe^I;@=b0hWHqu^W)*#UtyIP><704<07nCF{i{do!#!rN%KGsj*KptsE0 zR&OTLUdF!iu5Iv1@7r7u`dFWy2e;d&&Q&!;m?*s?90y)_V-W|*gO1(FclVr_2`v}F ztT)L0Md+i4G)c)`35H}Ay$(HI;F)rt5Y(lTE9sv50|P20e~^dMj&T2N!Cr+i zT%CNV+^ht_1;?|3?HrNIn{wA-M+7wfeIk{`4paEmwKds*MZ=s4s$`L*qy_X5Mc*J@ zD1mQgrA*yq(x9+`GwKh5OmZS19AnHOdcr;EzMuL|vk#pfp?0>PU0U3Wq9Tk5oZEpAc;|8WMp_&= zbPhhB`T(VaYlB9q1I0E^rU*3mLq6X?wS5@$yVB*I)gc0<^M&wBF_eJA)!WjCl$U$9 zwE=R8w8TaVsRpWd55VC`7H8cfq(SaRvMtmHfonBh2d40t)e-Ek&)LdNv_^=H9`#!) z3>~2L)k80EpV23}c5YmX@$I!E^F#vDclYga1(N4Xgo0IHE>IR0f=8J~4i4U>cAdTM z>cAAhBc<%>`OMCbjfAiRG2y!OmFX%0Fa=;{SSqVIQ{>o#6qO?_=^+iVBbY!W267eb zO+XrPJ9MDKK?b|-cBK`r^}m4huiK72b(R<+XYP%hqpJlBRx!6p3i{yIY&BW{hjZj}u1O!4!te7xAz zt@VQUh}?srOv+ieOEf=2O(!p=2SN)t{_4B|ogr>JpJ{Gm~Ei`K ^vMG)`7esN%45+z*8 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

=2p3VorI_NcYQ2eZD(m^ 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 literal 0 HcmV?d00001 diff --git a/parameters/PENDING.md b/parameters/PENDING.md new file mode 100644 index 00000000..2f79a1ad --- /dev/null +++ b/parameters/PENDING.md @@ -0,0 +1,197 @@ +# Parameters Package — Pending Work + +Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. Para una vista de la arquitectura completa ver `parameters/docs/architecture.md`. + +--- + +## Estado actual + +| Componente | Estado | +|---|---| +| Skeleton (entrypoint, build_context, dispatch, utils, workflows) | ✅ Implementado | +| Provider `hashicorp_vault` | ✅ Implementado (migrado del `parameters/vault/` original) | +| Provider `secret_manager` | ✅ Implementado | +| Provider `parameter_store` | ✅ Implementado (nuevo) | +| Provider `azure_key_vault` | ✅ Implementado (nuevo) | +| Error handling (not_found → idempotent, otros → fail loud) | ✅ Aplicado a deletes y retrieves de los 4 providers | +| Tests BATS | ✅ 150 tests pasando | +| Docs globales | ✅ architecture.md, configuration.md, adding_a_provider.md | +| Docs por provider | ✅ architecture.md (4 providers), iam-policy.md (SM + PS) | +| Decision doc para equipo | ✅ `aws-secret-manager-strategies.docx` (en root del repo) | +| Naming NRN+slug-based | ⏳ Pendiente — ver "1. Refactor de naming" | +| `fetch_configuration` por provider | ⏳ Pendiente — ver "2. Placeholders" | + +--- + +## Decisiones tomadas + +| Decisión | Valor | Origen | +|---|---|---| +| Estrategia de granularidad | 1:1 mapping (un secret por parámetro) | Review del equipo sobre el decision doc | +| Naming convention | NRN entities con slugs+ids + dimensiones + parameter_id | Conversación de diseño | +| Provider AWS Secrets Manager | Nombre futuro: `aws_secret_manager` (rename pendiente de `secret_manager`) | Conversación de diseño | +| Selector resolution | Env-only (`SECRET_PROVIDER`, `PARAMETER_PROVIDER`) | Limitación del provider-categories de nullplatform | +| Workflow YAMLs | 4 workflows unificados (store, retrieve, delete, notify), sin discriminación por kind | Cleanup arquitectónico | +| Discriminación secret/param | En `build_context` desde `$CONTEXT.secret`, no en entrypoint | Mismo cleanup | +| Logging | Todos los niveles routean a stderr (stdout reservado para JSON) | Bug encontrado durante tests | +| Delete failure semantics | "not found" → success idempotente, todo lo demás → exit 1 con troubleshooting | Feedback de revisión | +| Retrieve failure semantics | Idem delete: "not found" → `{value: "value not found"}`, otros errores → exit 1 | Idem | + +--- + +## Pendiente + +### 1. Refactor de naming a NRN+slugs+ids + +**Bloqueado por:** falta confirmar la syntax exacta del `np` CLI para obtener slugs de entities por ID. + +**Hipótesis (a confirmar antes de implementar):** + +```bash +np organization get --id 1255165411 --query slug --output text +``` + +#### Diseño aprobado + +El `external_id` retornado a nullplatform (y por tanto el nombre del secret en cada provider) se compone así: + +``` +=-/=-/.../=/ +``` + +- Entities iteradas en orden NRN canónico: `organization → account → namespace → application → scope`. Solo se incluyen las presentes. +- Dimensiones ordenadas alfabéticamente por key para garantizar determinismo. +- `parameter_id` al final como identificador único. +- Slugs son inmutables en nullplatform (garantía del contrato), por lo que el external_id no sufre deriva en el tiempo. + +#### Ejemplo + +Con `entities = {organization: "1255165411", account: "95118862", namespace: "37094320", application: "321402625"}`, `dimensions = {env: "prod"}`, `parameter_id = 42`: + +``` +organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/env=prod/42 +``` + +#### Pasos + +1. Crear `parameters/utils/build_external_id` con fetch paralelo de slugs vía `np` CLI (usando `mktemp` + `&` + `wait`). +2. Refactorizar `store` de los 4 providers: + - `hashicorp_vault/store`: nombre `secret/data/parameters/` + - `secret_manager/store`: nombre `` + - `parameter_store/store`: nombre `` + - `azure_key_vault/store`: nombre ``. AKV solo permite alfanumérico + `-`, así que transformamos `/` → `-` y removemos `=`. +3. `retrieve`/`delete`/`notify` NO cambian: usan el `EXTERNAL_ID` que llega de nullplatform. +4. Tests: mock de `np` CLI en `$BATS_TEST_TMPDIR/bin/`, expected paths actualizados en cada provider. +5. Update de `parameters/providers//docs/architecture.md` con el nuevo naming. + +#### Edge cases (todos confirmados) + +- Entities siempre vienen (parte del contrato de nullplatform) — no hay caso de "entities vacío". +- `np` CLI siempre está disponible (instalado en la imagen Docker base del agente). +- Slugs inmutables — no hay riesgo de deriva o reconstrucción incorrecta. + +--- + +### 2. Placeholders `fetch_configuration` por provider + +Cada provider necesita un placeholder `fetch_configuration` (opcional según el contrato pero útil) que populate `PROVIDER_CONFIG` con su config específica desde donde corresponda (np CLI, REST, file, etc.). + +Hoy todos los providers funcionan vía env vars (`VAULT_ADDR`, `AWS_REGION`, etc.). El placeholder permite wirear el fetch real cuando el platform team defina el mecanismo. + +Estructura sugerida: + +```bash +#!/bin/bash +# parameters/providers//fetch_configuration +# +# TODO(platform-team): wire la lógica de fetch real (np CLI, REST, file montado, etc.) +# Mientras tanto, PROVIDER_CONFIG default a '{}' y todo cae a env vars. + +: "${PROVIDER_CONFIG:=}" +if [ -z "$PROVIDER_CONFIG" ]; then + PROVIDER_CONFIG='{}' +fi +export PROVIDER_CONFIG +``` + +A duplicar en los 4 providers. Build_context ya sourcea `$PROVIDER_DIR/fetch_configuration` si existe. + +--- + +### 3. Rename `secret_manager` → `aws_secret_manager` (opcional) + +Decisión tomada en conversación pero no aplicada todavía. No bloqueante para nada. Cuando se haga: + +- Mover `parameters/providers/secret_manager/` → `parameters/providers/aws_secret_manager/` +- Update referencias en docs (architecture.md, configuration.md, adding_a_provider.md, iam-policy.md) +- Update tests en `parameters/tests/providers/secret_manager/` (mover y renombrar) +- Actualizar valores aceptables de `SECRET_PROVIDER` / `PARAMETER_PROVIDER` en docs + +--- + +## Contrato del payload — para referencia rápida + +Notification de nullplatform tiene estos campos en `$CONTEXT` (después de que el entrypoint extrae `.notification`): + +| Campo | Tipo | Acciones | Notas | +|---|---|---|---| +| `parameter_id` | number | store, notify | nullplatform parameter ID | +| `value` | string | store | el valor a persistir | +| `external_id` | string | retrieve, delete, notify | handle generado en store (NRN+slugs+ids+dims+id) | +| `secret` | bool | todas | discriminador secret/parameter (sigue derivando PARAMETER_KIND pero no afecta routing en 1:1) | +| `parameter_name` | string | todas | display name del parámetro | +| `encoding` | string | todas | `plain`, `base64`, etc. | +| `entities` | object | todas | IDs only — slugs se fetchean por separado vía np CLI | +| `value_entities` | object | retrieve (opcional) | Mismo formato que entities, solo presente si el value tiene NRN distinto al parámetro | +| `dimensions` | object | opcional | key-value pairs (env, country, etc.) — ordenarse alfabéticamente | + +Las entities siempre vienen como IDs strings: + +```json +{ + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" +} +``` + +--- + +## Cómo correr los tests + +```bash +bats $(find parameters/tests -name "*.bats") +``` + +Distribución actual (150 tests): + +- Skeleton (entrypoint, build_context, dispatch, utils): 55 tests +- hashicorp_vault: 27 tests +- secret_manager: 17 tests +- parameter_store: 23 tests +- azure_key_vault: 15 tests +- utils/log + utils/get_config_value: 13 tests + +--- + +## Estructura del paquete + +``` +parameters/ +├── PENDING.md # este archivo +├── entrypoint, build_context # router + provider resolution +├── store, retrieve, delete, notify # dispatch one-liners +├── workflows/ # 4 YAMLs (acción-only, kind se deriva) +├── utils/ +│ ├── get_config_value # priority: provider config > env > default +│ └── log # todos los niveles a stderr +├── providers/ +│ ├── README.md # contrato del provider +│ ├── hashicorp_vault/ +│ ├── secret_manager/ +│ ├── parameter_store/ +│ └── azure_key_vault/ +├── tests/ # 150 BATS tests +└── docs/ # docs globales del paquete +``` diff --git a/parameters/build_context b/parameters/build_context new file mode 100755 index 00000000..72ce1a8e --- /dev/null +++ b/parameters/build_context @@ -0,0 +1,100 @@ +#!/bin/bash +set -euo pipefail + +# Resolves which provider implementation handles this workflow run. +# +# Inputs: +# CONTEXT — JSON of the notification body (set by entrypoint) +# PARAMETER_KIND — "secret" | "parameter" (set by workflow `configuration` block; +# falls back to deriving from $CONTEXT.secret if absent — e.g. notify) +# SECRET_PROVIDER — env var: name of provider to use when kind=secret +# PARAMETER_PROVIDER — env var: name of provider to use when kind=parameter +# +# Per-provider config fetching is delegated to providers//fetch_configuration +# (optional). Each provider owns its own fetching mechanism — no global config +# fetcher in this layer. +# +# Outputs (exported for subsequent workflow steps): +# PARAMETER_KIND, ACTIVE_PROVIDER, PROVIDER_DIR, PARAMETERS_ROOT +# EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE, PARAMETER_NAME, PARAMETER_ENCODING +# Plus any vars the provider's fetch_configuration and setup scripts export. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PARAMETERS_ROOT="$SCRIPT_DIR" + +source "$SCRIPT_DIR/utils/log" +source "$SCRIPT_DIR/utils/get_config_value" + +export EXTERNAL_ID=$(echo "$CONTEXT" | jq -r '.external_id // empty') +export PARAMETER_ID=$(echo "$CONTEXT" | jq -r '.parameter_id // empty') +export PARAMETER_VALUE=$(echo "$CONTEXT" | jq -r '.value // empty') +export PARAMETER_NAME=$(echo "$CONTEXT" | jq -r '.parameter_name // empty') +export PARAMETER_ENCODING=$(echo "$CONTEXT" | jq -r '.encoding // empty') + +if [ -z "${PARAMETER_KIND:-}" ]; then + # `// empty` would swallow `false` (jq's // treats false as missing), so use tostring. + case "$(echo "$CONTEXT" | jq -r '.secret | tostring')" in + true) PARAMETER_KIND="secret" ;; + false) PARAMETER_KIND="parameter" ;; + *) PARAMETER_KIND="" ;; + esac +fi +export PARAMETER_KIND + +# Selector resolution is env-only at this layer. +# If the platform wants to derive selectors from provider config, it must +# populate these env vars BEFORE invoking the entrypoint. +case "$PARAMETER_KIND" in + secret) + ACTIVE_PROVIDER="${SECRET_PROVIDER:-}" + selector_env="SECRET_PROVIDER" + ;; + parameter) + ACTIVE_PROVIDER="${PARAMETER_PROVIDER:-}" + selector_env="PARAMETER_PROVIDER" + ;; + *) + ACTIVE_PROVIDER="${SECRET_PROVIDER:-${PARAMETER_PROVIDER:-}}" + selector_env="SECRET_PROVIDER or PARAMETER_PROVIDER" + ;; +esac + +if [ -z "$ACTIVE_PROVIDER" ]; then + log error "❌ No provider configured for kind '$PARAMETER_KIND'" + log error "" + log error "💡 Possible causes:" + log error " • $selector_env env var is not set in the workflow runtime" + log error "" + log error "🔧 How to fix:" + log error " • Set $selector_env= in the agent/runner environment" + log error " • Available providers: $(ls "$SCRIPT_DIR/providers" 2>/dev/null | grep -v '^README' | tr '\n' ' ' || true)" + exit 1 +fi + +PROVIDER_DIR="$SCRIPT_DIR/providers/$ACTIVE_PROVIDER" +if [ ! -d "$PROVIDER_DIR" ]; then + available=$(ls "$SCRIPT_DIR/providers" 2>/dev/null | grep -v '^README' | tr '\n' ' ' || true) + log error "❌ Provider implementation not found: '$ACTIVE_PROVIDER'" + log error "" + log error "🔧 How to fix:" + log error " • Available providers: ${available:-(none installed)}" + log error " • Set $selector_env to one of the above, or add a provider at parameters/providers/$ACTIVE_PROVIDER/" + exit 1 +fi +export ACTIVE_PROVIDER +export PROVIDER_DIR + +log debug "📦 active_provider=$ACTIVE_PROVIDER kind=$PARAMETER_KIND" + +# Each provider owns its config fetching (np CLI, REST call, file, env vars, etc.) +# Optional: if absent, the provider relies on whatever's already in the environment. +if [ -f "$PROVIDER_DIR/fetch_configuration" ]; then + log debug "📡 Sourcing $ACTIVE_PROVIDER/fetch_configuration" + source "$PROVIDER_DIR/fetch_configuration" +fi + +# Validation + connection handles. Operations downstream assume invariants hold. +if [ -f "$PROVIDER_DIR/setup" ]; then + log debug "📡 Sourcing $ACTIVE_PROVIDER/setup" + source "$PROVIDER_DIR/setup" +fi diff --git a/parameters/delete b/parameters/delete new file mode 100755 index 00000000..ee412263 --- /dev/null +++ b/parameters/delete @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +# Dispatch: delegate to the active provider's delete implementation. +# PROVIDER_DIR is exported by build_context. +source "$PROVIDER_DIR/delete" diff --git a/parameters/docs/adding_a_provider.md b/parameters/docs/adding_a_provider.md new file mode 100644 index 00000000..108be4cd --- /dev/null +++ b/parameters/docs/adding_a_provider.md @@ -0,0 +1,223 @@ +# Adding a New Provider + +Step-by-step guide to add a new backend (e.g. Google Secret Manager, Doppler, 1Password Secrets Automation). + +The parameters package is designed so that adding a provider is **strictly additive**. You drop a directory under `providers/`; nothing outside it changes. + +--- + +## What you need to know about the backend + +Before you start, answer these questions: + +| Question | Why it matters | +|---------------------------------------------------------|-------------------------------------------------| +| What CLI / API do you call? | Determines your tooling (curl, aws, az, gcloud) | +| How does authentication work? | Defines what `setup` validates | +| What's the naming convention for stored items? | Defines your prefix + UUID scheme | +| Does it have soft-delete? | Determines if `delete` needs a purge step | +| Does it distinguish secret vs plain types at the API? | Determines if `store` branches on PARAMETER_KIND | + +--- + +## Step 1: Create the provider directory + +```bash +mkdir -p parameters/providers//docs +mkdir -p parameters/tests/providers/ +``` + +`` is `snake_case` and is what users will set in `SECRET_PROVIDER` / `PARAMETER_PROVIDER`. + +--- + +## Step 2: Write `setup` + +Validate config and export connection handles. Don't repeat this in operation scripts — `setup` is the DRY anchor. + +```bash +#!/bin/bash +set -euo pipefail + +# Read config (provider config wins, env fallback, defaults last) +MY_ENDPOINT=$(get_config_value --env MY_ENDPOINT --provider '.endpoint') +MY_TOKEN=$(get_config_value --env MY_TOKEN --provider '.token') +MY_PREFIX=$(get_config_value --env MY_PREFIX --provider '.prefix' --default 'parameters-') + +if [ -z "$MY_ENDPOINT" ]; then + log error "❌ endpoint not configured" + log error "" + log error "💡 Possible causes:" + log error " • MY_ENDPOINT env var is not set" + log error " • .endpoint is missing in PROVIDER_CONFIG" + log error "" + log error "🔧 How to fix:" + log error " • Set MY_ENDPOINT=" + exit 1 +fi + +# Validate format / shape if relevant +# ... + +export MY_ENDPOINT MY_TOKEN MY_PREFIX +``` + +--- + +## Step 3: Write the four operation scripts + +### `store` + +Generate a UUID, persist the value, return `{external_id, metadata}`. + +```bash +#!/bin/bash +set -euo pipefail + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +NAME="${MY_PREFIX}${EXTERNAL_ID}" + +if ! HANDLE=$(my_cli create --endpoint "$MY_ENDPOINT" --name "$NAME" --value "$PARAMETER_VALUE" 2>/dev/null); then + log error "❌ Failed to store in " + log error "" + log error "💡 Possible causes:" + log error " • " + log error "" + log error "🔧 How to fix:" + log error " • " + exit 1 +fi + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg handle "$HANDLE" \ + --arg name "$NAME" \ + '{external_id: $external_id, metadata: {handle: $handle, name: $name}}' +``` + +If your backend distinguishes types (like `parameter_store` does with String/SecureString), branch on `PARAMETER_KIND` here. + +### `retrieve` + +Read the value, return `{value}` or `{value: "value not found"}` on miss. + +```bash +#!/bin/bash +set -euo pipefail + +NAME="${MY_PREFIX}${EXTERNAL_ID}" + +if VALUE=$(my_cli get --endpoint "$MY_ENDPOINT" --name "$NAME" 2>/dev/null); then + jq -n --arg value "$VALUE" '{value: $value}' +else + echo '{ + "value": "value not found" + }' +fi +``` + +### `delete` + +Always returns `{success: true}`. Suppress errors with `|| true`. + +```bash +#!/bin/bash +set -euo pipefail + +NAME="${MY_PREFIX}${EXTERNAL_ID}" + +my_cli delete --endpoint "$MY_ENDPOINT" --name "$NAME" >/dev/null 2>&1 || true + +echo '{ + "success": true +}' +``` + +### `notify` (optional) + +Skip the file unless your backend needs a per-notify side effect. The dispatch returns the default `{success: true}` if `notify` doesn't exist. + +--- + +## Step 4: Write `fetch_configuration` (optional) + +If the platform stores your provider's config somewhere fetchable, add a `fetch_configuration` script that exports `PROVIDER_CONFIG` as a JSON string with the shape your `setup` expects. + +```bash +#!/bin/bash +# providers//fetch_configuration +PROVIDER_CONFIG=$(np provider get --type --output json) +export PROVIDER_CONFIG +``` + +If you skip this file, `PROVIDER_CONFIG` stays unset and `setup` reads everything from env vars. + +--- + +## Step 5: Write tests + +Mirror the source structure under `parameters/tests/providers//`: + +``` +tests/providers// +├── setup.bats # Config resolution, validation, error paths +├── store.bats # JSON output shape, CLI args, error paths +├── retrieve.bats # Hit case, miss case, CLI args +└── delete.bats # Always-success, CLI args, idempotency +``` + +Use the patterns from existing providers (`hashicorp_vault`, `secret_manager`, `parameter_store`, `azure_key_vault`): + +- Mock the backend CLI as a script in `$BATS_TEST_TMPDIR/bin/`, export PATH to find it. +- Capture CLI args to a log file, assert on them. +- Mock `uuidgen` for deterministic `external_id` in store tests. +- Use the `DEPS="source $PARAMETERS_DIR/utils/log"` pattern to make `log` available in `bash -c` subshells. + +Aim for at least these scenarios per provider: + +| Script | Required tests | +|-----------|-----------------------------------------------------------------------------| +| setup | Missing required config fails with troubleshooting; PROVIDER_CONFIG wins over env; defaults applied | +| store | Output JSON shape; CLI called with correct args; failure path returns non-zero with troubleshooting | +| retrieve | Hit returns value; miss returns "value not found" | +| delete | Returns `{success: true}`; idempotent on CLI failure | + +--- + +## Step 6: Write the docs + +Add at least `parameters/providers//docs/architecture.md` describing: + +- Storage layout (naming, prefix, encryption model) +- Cost model +- Authentication +- Any quirks (soft-delete, regions, multi-tenant constraints) + +If the backend needs IAM-style permissions (AWS, GCP), add `iam-policy.md` with a least-privilege example using placeholders for accounts/regions/keys. + +--- + +## Step 7: Wire it up + +1. Set the env var: `SECRET_PROVIDER=` and/or `PARAMETER_PROVIDER=`. +2. If using `fetch_configuration`, the platform team needs to ensure the fetch mechanism (np CLI, REST endpoint, etc.) returns the JSON shape your provider expects. + +Done. The new provider is reachable from every workflow without any other change. + +--- + +## Checklist + +Before considering a new provider complete: + +- [ ] `setup` validates config and exits with troubleshooting on missing fields +- [ ] `store` outputs `{external_id, metadata}` JSON +- [ ] `retrieve` outputs `{value}` (or `{value: "value not found"}`) +- [ ] `delete` outputs `{success: true}` (always — idempotent) +- [ ] Scripts use `set -euo pipefail` +- [ ] Errors go to stderr via `log error "..."` +- [ ] No stdout output other than the final JSON +- [ ] Every error has `💡 Possible causes:` and `🔧 How to fix:` blocks +- [ ] BATS tests cover setup error paths, store output shape, retrieve hit/miss, delete idempotency +- [ ] `architecture.md` documents storage layout and cost +- [ ] If the backend has IAM, `iam-policy.md` shows least-privilege scoping diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md new file mode 100644 index 00000000..3c18441e --- /dev/null +++ b/parameters/docs/architecture.md @@ -0,0 +1,115 @@ +# Parameters Package — Architecture + +A pluggable parameter and secret storage layer for nullplatform scopes. Choose any backend per-kind (one provider for plain parameters, another for secrets) without touching code outside provider directories. + +--- + +## What problem this solves + +nullplatform scopes need to persist parameter values somewhere. Different organizations want different backends: + +- AWS-native shops: AWS Secrets Manager and/or Parameter Store +- Azure-native shops: Azure Key Vault +- Existing HashiCorp infrastructure: Vault +- Hybrid: secrets in one backend, plain parameters in another + +A monolithic scope tied to one backend forces fork-and-modify for every variation. This package inverts the relationship: the **dispatch layer is the package**, the **backends are pluggable modules** dropped into `providers/`. + +--- + +## Layered design + +``` +┌────────────────────────────────────────────────────────────────┐ +│ nullplatform sends action notification │ +│ (NOTIFICATION_ACTION="parameter:", NP_ACTION_CONTEXT) │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ parameters/entrypoint │ +│ - Clean NP_ACTION_CONTEXT, export CONTEXT (= .notification) │ +│ - Pick workflow: workflows/.yaml │ +│ - Honor OVERRIDES_PATH for consumer-side workflow overrides │ +│ - No kind discrimination here — that's pushed to build_context │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ workflows/.yaml │ +│ - Step 1: build_context │ +│ - Step 2: (store / retrieve / delete / notify) │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ parameters/build_context │ +│ - Parse CONTEXT → EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE │ +│ - Derive PARAMETER_KIND from $CONTEXT.secret (true/false) │ +│ - Resolve ACTIVE_PROVIDER from SECRET_PROVIDER or PARAMETER_ │ +│ PROVIDER env var (per PARAMETER_KIND) │ +│ - Source providers/$ACTIVE_PROVIDER/fetch_configuration │ +│ - Source providers/$ACTIVE_PROVIDER/setup │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ parameters/ (dispatch) │ +│ - One-liner: source providers/$ACTIVE_PROVIDER/ │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ providers// │ +│ - Executes the actual backend call (curl, aws, az, ...) │ +│ - Writes JSON result to stdout │ +└────────────────────────────────────────────────────────────────┘ +``` + +The dispatch layer is **provider-agnostic**. It has zero knowledge of any specific provider's existence. Adding a new provider is strictly additive — no edits to `entrypoint`, `build_context`, `workflows/`, or other providers. + +--- + +## Why two env vars instead of one + +`SECRET_PROVIDER` and `PARAMETER_PROVIDER` are separate because the most common production setup uses different backends for each kind: + +- Plain parameters in Parameter Store (free Standard tier) +- Secrets in Secrets Manager (per-secret cost, but rotation + replication) + +Setting `PARAMETER_PROVIDER=parameter_store` and `SECRET_PROVIDER=secret_manager` is one configuration line that captures this. The dispatcher resolves the right provider per request based on `$CONTEXT.secret`. + +If you want a single provider for both kinds, set both env vars to the same value: + +```bash +SECRET_PROVIDER=hashicorp_vault +PARAMETER_PROVIDER=hashicorp_vault +``` + +--- + +## File tree + +``` +parameters/ +├── entrypoint # Action router (kind discrimination + workflow selection) +├── build_context # Provider resolution + sourcing of provider's setup +├── store, retrieve, # Dispatch one-liners +│ delete, notify +├── workflows/ # 4 unified (store/retrieve/delete/notify) +├── utils/ +│ ├── get_config_value # Priority: provider config > env > default +│ └── log # debug/info/warn/error with stderr routing +├── providers/ +│ ├── README.md # Contract every provider must satisfy +│ ├── hashicorp_vault/ # HTTP API +│ ├── secret_manager/ # aws CLI +│ ├── parameter_store/ # aws CLI (the only kind-branching provider) +│ └── azure_key_vault/ # az CLI +├── tests/ # BATS — mirrors source structure +└── docs/ # This file, configuration.md, adding_a_provider.md +``` + +See `parameters/providers/README.md` for the provider contract spec. +See `configuration.md` for how `PROVIDER_CONFIG` is structured and how selectors are resolved. +See `adding_a_provider.md` to drop in a new backend. diff --git a/parameters/docs/configuration.md b/parameters/docs/configuration.md new file mode 100644 index 00000000..ca0b157b --- /dev/null +++ b/parameters/docs/configuration.md @@ -0,0 +1,135 @@ +# Configuration + +How the parameters package resolves which provider to use and where each provider gets its config. + +--- + +## Two layers of configuration + +### 1. Provider selection (which backend handles this request) + +Two env variables: + +| Env var | Purpose | +|----------------------|--------------------------------------------------| +| `SECRET_PROVIDER` | Which provider handles `kind=secret` requests | +| `PARAMETER_PROVIDER` | Which provider handles `kind=parameter` requests | + +Values are the directory names under `providers/` (e.g. `secret_manager`, `parameter_store`, `hashicorp_vault`, `azure_key_vault`). + +**Resolution:** env-only. There is no provider-config fallback for selectors at this layer — that would create a chicken-and-egg problem (build_context needs to know which provider to fetch config from, but the config tells it which provider to use). If you want the platform to drive selectors, populate these env vars in the agent/runner environment before invoking the entrypoint. + +### 2. Provider-specific configuration (settings for the chosen backend) + +Each provider's `setup` script reads its own config from a combination of env vars and `PROVIDER_CONFIG` (a JSON string scoped to that one provider). + +**Resolution priority** (highest to lowest): + +1. `PROVIDER_CONFIG` (via `get_config_value --provider '.field'`) +2. Environment variable (via `get_config_value --env NAME`) +3. Default (via `get_config_value --default 'value'`) + +`PROVIDER_CONFIG` is populated by the active provider's `fetch_configuration` script (optional). If `fetch_configuration` doesn't exist or doesn't set `PROVIDER_CONFIG`, the provider falls back entirely to env vars. + +--- + +## The four strategies + +| Strategy | `PARAMETER_PROVIDER` | `SECRET_PROVIDER` | +|----------------------------------|----------------------|------------------------| +| Full Secrets Manager | `secret_manager` | `secret_manager` | +| Full Parameter Store (cheapest) | `parameter_store` | `parameter_store` | +| Mixed AWS (recommended for AWS) | `parameter_store` | `secret_manager` | +| Full HashiCorp Vault | `hashicorp_vault` | `hashicorp_vault` | +| Full Azure Key Vault | `azure_key_vault` | `azure_key_vault` | +| Hybrid Azure secrets, AWS params | `parameter_store` | `azure_key_vault` | + +Switching strategies = changing two env vars. Zero code changes. + +--- + +## Per-provider config shapes + +The shape of `PROVIDER_CONFIG` for each provider: + +### `hashicorp_vault` + +```json +{ + "address": "https://vault.example.com", + "token": "hvs.xxx", + "path_prefix": "secret/data/parameters" +} +``` + +Equivalent env vars: `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX`. + +### `secret_manager` + +```json +{ + "region": "us-east-1", + "name_prefix": "parameters/", + "kms_key_id": "alias/aws/secretsmanager" +} +``` + +Equivalent env vars: `AWS_REGION` (or `AWS_DEFAULT_REGION`), `SM_NAME_PREFIX`, `SM_KMS_KEY_ID`. `kms_key_id` is optional (defaults to AWS-managed key). + +### `parameter_store` + +```json +{ + "region": "us-east-1", + "name_prefix": "/nullplatform/parameters/", + "kms_key_id": "alias/parameters-secure", + "tier": "Standard" +} +``` + +Equivalent env vars: `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. `kms_key_id` only matters for `kind=secret` (SecureString). `tier` ∈ {`Standard`, `Advanced`, `Intelligent-Tiering`}. + +### `azure_key_vault` + +```json +{ + "vault_name": "my-keyvault", + "secret_prefix": "parameters-" +} +``` + +Equivalent env vars: `AZURE_KEY_VAULT_NAME`, `AZURE_KEY_VAULT_SECRET_PREFIX`. Auth comes from the Azure CLI's default credential chain. + +--- + +## How `PROVIDER_CONFIG` gets populated + +Each provider may have a `fetch_configuration` script. When `build_context` activates that provider, it sources `providers//fetch_configuration` before `setup`. The script's job: + +1. Fetch the provider's config from wherever it lives. +2. Export `PROVIDER_CONFIG` as a JSON string. + +Where the config "lives" is up to each provider: + +- **`np provider get`** — call the nullplatform CLI to read providers config. +- **REST call** — query an internal config service. +- **File** — read a mounted config file. +- **Env vars only** — skip `fetch_configuration` entirely; rely on env. + +The provider package doesn't care which mechanism you choose. If you want a uniform mechanism across providers, you can implement them all the same way; if you want each to source config differently (e.g. Vault config from Consul, AWS config from instance profile), nothing forces them to align. + +--- + +## Local development + +For local testing without wiring `fetch_configuration`, set everything via env vars: + +```bash +export SECRET_PROVIDER=hashicorp_vault +export PARAMETER_PROVIDER=hashicorp_vault +export VAULT_ADDR=http://localhost:8200 +export VAULT_TOKEN=root-token +# ...then invoke the entrypoint +``` + +All providers fall through to env vars when `PROVIDER_CONFIG` is unset or empty. diff --git a/parameters/entrypoint b/parameters/entrypoint new file mode 100755 index 00000000..3e6c78fa --- /dev/null +++ b/parameters/entrypoint @@ -0,0 +1,50 @@ +#!/bin/bash +set -euo pipefail + +# Entry point for the parameters package. +# - Cleans NP_ACTION_CONTEXT (strips surrounding single quotes some runners add) +# - Exports CONTEXT scoped to the .notification body (what every script reads) +# - Resolves the workflow file from the action name (parameter:) +# - Honors OVERRIDES_PATH for consumer-side workflow overrides +# +# Kind discrimination (secret vs parameter) is NOT done here. It's derived at +# the script layer: build_context reads $CONTEXT.secret and exports +# PARAMETER_KIND; providers that branch on it (e.g. parameter_store choosing +# String vs SecureString) read PARAMETER_KIND directly. + +if [ -z "${NP_ACTION_CONTEXT:-}" ]; then + echo "❌ NP_ACTION_CONTEXT is not set" >&2 + exit 1 +fi + +CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//") +export NP_ACTION_CONTEXT="$CLEAN_CONTEXT" +export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification') + +IFS=':' read -ra ACTION_PARTS <<< "${NOTIFICATION_ACTION:-}" +ACTION_TO_EXECUTE="${ACTION_PARTS[1]:-}" + +if [ -z "$ACTION_TO_EXECUTE" ]; then + echo "❌ NOTIFICATION_ACTION is missing the action part (expected 'parameter:')" >&2 + exit 1 +fi + +WORKFLOW_PATH="$SERVICE_PATH/parameters/workflows/$ACTION_TO_EXECUTE.yaml" +if [ ! -f "$WORKFLOW_PATH" ]; then + echo "❌ No workflow found at $WORKFLOW_PATH" >&2 + exit 1 +fi + +CMD="np service workflow exec --no-output --workflow $WORKFLOW_PATH" + +if [ -n "${OVERRIDES_PATH:-}" ]; then + IFS=',' read -ra OVERRIDE_PATHS <<< "$OVERRIDES_PATH" + for path in "${OVERRIDE_PATHS[@]}"; do + path=$(echo "$path" | xargs) + [ -z "$path" ] && continue + override_yaml="$path/parameters/workflows/$ACTION_TO_EXECUTE.yaml" + [ -f "$override_yaml" ] && CMD="$CMD --overrides $override_yaml" + done +fi + +eval $CMD diff --git a/parameters/notify b/parameters/notify new file mode 100755 index 00000000..7ddb095e --- /dev/null +++ b/parameters/notify @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +# Dispatch: delegate to the active provider's notify implementation if defined, +# otherwise return the default success ack. Notify is optional per provider. +if [ -f "$PROVIDER_DIR/notify" ]; then + source "$PROVIDER_DIR/notify" +else + echo '{"success":true}' +fi diff --git a/parameters/providers/README.md b/parameters/providers/README.md new file mode 100644 index 00000000..156c0c97 --- /dev/null +++ b/parameters/providers/README.md @@ -0,0 +1,184 @@ +# Provider Contract + +This directory contains all concrete provider implementations. Each subdirectory is one provider — fully self-contained. + +The dispatch layer (`parameters/build_context` + `parameters/{store,retrieve,delete,notify}`) is provider-agnostic. It selects a provider at runtime from env vars `SECRET_PROVIDER` / `PARAMETER_PROVIDER` and sources the matching scripts. + +**Adding a new provider is a strictly additive change**: drop a directory here that satisfies this contract. No edits to dispatch, build_context, workflows, or other providers are required. The parameters package has zero knowledge of any specific provider. + +--- + +## Required layout + +``` +providers// +├── fetch_configuration # (optional) Fetch this provider's config from wherever it lives +├── setup # (optional) Validate config, prepare connection handles +├── store # (required) Persist a parameter value +├── retrieve # (required) Read a value by external_id +├── delete # (required) Idempotent delete by external_id +├── notify # (optional) Per-provider notify hook (default is {"success":true}) +└── docs/ # (recommended) architecture.md, iam-policy.md, etc. +``` + +`` is the string users set in `SECRET_PROVIDER` / `PARAMETER_PROVIDER`. Use `snake_case` (e.g. `hashicorp_vault`, `azure_key_vault`, `parameter_store`). + +--- + +## Lifecycle of one workflow run + +1. `entrypoint` cleans `NP_ACTION_CONTEXT`, exports `CONTEXT` (= notification body), routes to the right workflow YAML. +2. Workflow's `build_context` step: + - Determines `PARAMETER_KIND` from workflow `configuration` or `$CONTEXT.secret`. + - Resolves `ACTIVE_PROVIDER` from `SECRET_PROVIDER` or `PARAMETER_PROVIDER` env var. + - Sources `providers/$ACTIVE_PROVIDER/fetch_configuration` if present. + - Sources `providers/$ACTIVE_PROVIDER/setup` if present. +3. Workflow's operation step (`store`/`retrieve`/`delete`/`notify`) sources `providers/$ACTIVE_PROVIDER/` and produces the JSON response. + +All steps share the same bash session — env vars set in any step are visible to the next. + +--- + +## Environment available to your scripts + +By the time any of your scripts runs, `build_context` has exported: + +| Variable | Description | +|----------------------|-----------------------------------------------------------------| +| `CONTEXT` | JSON of the notification body (`.notification` of the action) | +| `PARAMETER_KIND` | `"secret"` or `"parameter"` | +| `EXTERNAL_ID` | Existing handle for retrieve/delete/notify; empty for store | +| `PARAMETER_ID` | nullplatform parameter ID | +| `PARAMETER_VALUE` | The value to store (only set for store) | +| `PARAMETER_NAME` | Display name (e.g. `DB_PASSWORD`) | +| `PARAMETER_ENCODING` | Encoding of the value (e.g. `plain`, `base64`) | +| `PROVIDER_DIR` | Absolute path to your provider directory | +| `PARAMETERS_ROOT` | Absolute path to the parameters package root | +| `PROVIDER_CONFIG` | (optional) JSON your `fetch_configuration` set — its shape is up to you | + +The function `get_config_value` is already sourced — see usage below. + +--- + +## `fetch_configuration` (optional) + +Your provider's place to bring config in from the outside world. Sourced **once** at the start of every workflow run, before `setup`. + +Free-form by design — each provider knows best how to fetch its own config. Examples: + +- Call `np provider get --type ` and parse JSON +- `curl` a REST endpoint +- Read a file mounted by the runner +- Just rely on env vars (do nothing — omit the file) + +Convention: if you produce a JSON blob with your config, export it as `PROVIDER_CONFIG`. Then `get_config_value --provider '.field'` reads from it directly: + +```bash +#!/bin/bash +# providers/example/fetch_configuration +PROVIDER_CONFIG=$(np provider get --type my-thing --output json) +export PROVIDER_CONFIG +``` + +Then in `setup`: + +```bash +ADDR=$(get_config_value --env MY_ADDR --provider '.address') +``` + +If you don't need provider config, just skip `fetch_configuration` entirely. Operations can use env vars directly: + +```bash +ADDR="${MY_ADDR:-}" +[ -z "$ADDR" ] && { log error "❌ MY_ADDR not set"; exit 1; } +``` + +--- + +## `setup` (optional) + +Sourced after `fetch_configuration`. Use it to: + +1. Read provider-specific config (from env vars and/or `PROVIDER_CONFIG`). +2. Validate that all required fields are present. Fail fast with troubleshooting guidance if not. +3. Export connection handles (URLs, tokens, regions, prefixes) for the operation scripts. + +Do **not** repeat credential validation inside `store`/`retrieve`/`delete`. That's the whole point of `setup`. + +Example: + +```bash +#!/bin/bash +# providers/hashicorp_vault/setup +VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') +VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') + +[ -z "$VAULT_ADDR" ] && { log error "❌ vault address missing"; exit 1; } +[ -z "$VAULT_TOKEN" ] && { log error "❌ vault token missing"; exit 1; } + +export VAULT_ADDR VAULT_TOKEN +``` + +--- + +## Operation scripts + +Each produces **JSON on stdout** and routes **error messages to stderr**. The platform parses stdout as the action result. + +### `store` — required + +Input env: `PARAMETER_VALUE`, `PARAMETER_ID`, `PARAMETER_KIND`, plus your `setup` exports. + +Output: +```json +{ + "external_id": "", + "metadata": { "...": "provider-specific" } +} +``` + +`external_id` becomes the canonical handle. `metadata` is opaque to nullplatform but useful for auditing. + +### `retrieve` — required + +Input env: `EXTERNAL_ID`, plus `setup` exports. + +Output: +```json +{ "value": "" } +``` + +If not found, return `{"value": "value not found"}` rather than erroring (precedent: existing vault/secret_manager impls). + +### `delete` — required + +Input env: `EXTERNAL_ID`, plus `setup` exports. + +Output: +```json +{ "success": true } +``` + +Must be **idempotent**: re-deleting a missing handle is not an error. + +### `notify` — optional + +Input env: `EXTERNAL_ID`, `PARAMETER_ID`, plus `setup` exports. + +Output: +```json +{ "success": true } +``` + +Omit the file if your provider has nothing to do — the dispatch returns the default ack. + +--- + +## Conventions + +- Start every script with `set -euo pipefail`. +- Use `log error "..."` for error messages — it routes to stderr automatically. +- Every error message must include `💡 Possible causes:` and `🔧 How to fix:` blocks. +- Never print anything to stdout other than the final JSON result. The platform reads stdout literally. +- Don't validate `PROVIDER_DIR`, `EXTERNAL_ID`, or other dispatch-exported vars — assume `build_context` produced valid state. Validate only your provider-specific config in `setup`. +- Each operation should be **idempotent where it makes sense** (delete always, retrieve when missing, store typically not — the platform enforces store idempotency at its layer). diff --git a/parameters/providers/azure_key_vault/delete b/parameters/providers/azure_key_vault/delete new file mode 100755 index 00000000..79d8d8f1 --- /dev/null +++ b/parameters/providers/azure_key_vault/delete @@ -0,0 +1,70 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a secret from Azure Key Vault. +# +# AKV uses soft-delete by default (90-day retention). The flow is: +# 1. `delete` → moves to soft-deleted state (the user-facing "deleted" semantic) +# 2. `purge` → hard-deletes from soft-delete bin; releases the name immediately +# +# Idempotency semantics: +# - Successful delete → continue to purge +# - SecretNotFound on delete → success (already gone; idempotent) +# - Any other delete error → exit 1 with troubleshooting +# +# Purge is housekeeping (frees the name, stops retention billing). Failures +# during purge are downgraded to warnings: the user-facing delete contract is +# already satisfied by step 1. The secret stays in the soft-delete window +# (auto-cleaned at retention expiry). +# +# Required env: EXTERNAL_ID, AZ_VAULT_NAME, AZ_SECRET_PREFIX + +SECRET_NAME="${AZ_SECRET_PREFIX}${EXTERNAL_ID}" + +# --- Step 1: soft-delete --- +err_file=$(mktemp) +if az keyvault secret delete \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" >/dev/null 2>"$err_file"; then + rm -f "$err_file" +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -qE "(SecretNotFound|secret with .* was not found)"; then + log debug "Secret '$SECRET_NAME' does not exist, treating delete as success" + echo '{ + "success": true +}' + return 0 2>/dev/null || exit 0 + else + log error "❌ Failed to delete secret '$SECRET_NAME' from Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • Identity lacks 'Delete' permission on vault $AZ_VAULT_NAME" + log error " • Vault firewall blocks the caller" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: az account show" + log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" + log error "Underlying error: $err" + exit 1 + fi +fi + +# --- Step 2: purge (best-effort) --- +purge_err=$(mktemp) +if ! az keyvault secret purge \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" >/dev/null 2>"$purge_err"; then + pe=$(cat "$purge_err") + if echo "$pe" | grep -qiE "(Forbidden|not authorized|purge permission)"; then + log warn "⚠️ Purge permission missing on '$AZ_VAULT_NAME' — secret remains in soft-delete window" + else + log warn "⚠️ Purge failed (secret remains in soft-delete window): $pe" + fi +fi +rm -f "$purge_err" + +echo '{ + "success": true +}' diff --git a/parameters/providers/azure_key_vault/docs/architecture.md b/parameters/providers/azure_key_vault/docs/architecture.md new file mode 100644 index 00000000..0aea22b1 --- /dev/null +++ b/parameters/providers/azure_key_vault/docs/architecture.md @@ -0,0 +1,103 @@ +# Azure Key Vault — Provider Architecture + +This document describes the `parameters/providers/azure_key_vault/` implementation. It stores nullplatform parameters as Azure Key Vault (AKV) secrets. + +--- + +## Lifecycle + +| Step | What happens | +|------|-------------------------------------------------------------------------------| +| `setup` | Reads `AZ_VAULT_NAME`, `AZ_SECRET_PREFIX`. Fails if vault name missing or prefix has invalid chars. | +| `store` | Generates UUID. Calls `az keyvault secret set`. Returns `{external_id, metadata}`. | +| `retrieve` | Calls `az keyvault secret show`. Returns `{value}` or `{value: "value not found"}`. | +| `delete` | Calls `az keyvault secret delete` + `az keyvault secret purge` (both with `\|\| true`). | +| `notify` | Not implemented — dispatcher returns default `{success: true}`. | + +--- + +## Naming convention + +``` + +``` + +- `AZ_SECRET_PREFIX` defaults to `parameters-`. Must match `[A-Za-z0-9-]*` — AKV secret names allow only alphanumerics and dashes (no slashes, no dots, no underscores). +- `external_id` is a UUIDv4 generated at store time. UUIDs already satisfy AKV's character constraints. +- Full secret name example: `parameters-f47ac10b-58cc-4372-a567-0e02b2c3d479` +- Max 127 chars total. With a UUID (36 chars + dashes), you have ~90 chars left for the prefix. + +This naming differs from `secret_manager` and `parameter_store` (which support slashes for hierarchical organization) — AKV is flat-namespace. + +--- + +## PARAMETER_KIND is informational here + +AKV transparently encrypts all secrets using vault-managed keys (or a customer key if the vault is configured with one). The provider does **not** branch on `PARAMETER_KIND`: + +- `kind=secret` → AKV secret (encrypted at rest by AKV) +- `kind=parameter` → AKV secret (encrypted at rest by AKV) + +Both end up identical. If you need to distinguish parameter vs secret semantics at the storage layer, use the `parameter_store` provider instead (it uses SSM Type=String vs SecureString). + +--- + +## Soft-delete behavior + +Azure Key Vault has soft-delete enabled by default with 90-day retention: + +1. `az keyvault secret delete` moves the secret to a soft-deleted state. The name is reserved (cannot recreate with same name) and the secret is recoverable for 90 days. +2. `az keyvault secret purge` hard-deletes from the soft-delete bin, freeing the name immediately. + +The provider's `delete` script does **both** sequentially. Both calls suppress errors (`|| true`), so: + +- If you have the `Purge` permission: hard-deletes immediately, no retention cost. +- If you only have `Delete`: soft-deletes, retention applies. Since we use UUIDs, name reuse is not a concern in practice. +- If the secret already doesn't exist: both calls fail silently, the operation still returns `{success: true}`. + +--- + +## Configuration + +`PROVIDER_CONFIG` shape: + +```json +{ + "vault_name": "my-keyvault", + "secret_prefix": "parameters-" +} +``` + +Equivalent env vars: `AZURE_KEY_VAULT_NAME`, `AZURE_KEY_VAULT_SECRET_PREFIX`. `PROVIDER_CONFIG` wins per `get_config_value` priority. + +Authentication uses the Azure CLI's default credential chain: + +1. Managed Identity (Azure-hosted environments) +2. Service Principal env vars (`AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`) +3. `az login` cached credentials + +The provider does not validate auth in `setup` — the first `az keyvault` call surfaces auth errors. + +--- + +## Required permissions on the vault + +The identity running the provider scripts needs an access policy or RBAC role on the vault: + +| Operation | Access policy permission | RBAC role | +|-----------|--------------------------------|--------------------------------------| +| store | Set | Key Vault Secrets Officer / Contributor | +| retrieve | Get | Key Vault Secrets User | +| delete | Delete, Purge (optional) | Key Vault Secrets Officer + Purge action | + +The `Purge` permission is optional but recommended. Without it, soft-deletes accumulate and you may hit vault soft-delete quotas if you cycle many secrets. + +--- + +## Compatibility with the contract + +| Operation | Output shape | Notes | +|-----------|--------------|-------| +| store | `{external_id, metadata: {azure_secret_id, secret_name, vault_name}}` | `azure_secret_id` is the full AKV resource ID (URL form) | +| retrieve | `{value}` or `{value: "value not found"}` | | +| delete | `{success: true}` | Always; idempotent | diff --git a/parameters/providers/azure_key_vault/retrieve b/parameters/providers/azure_key_vault/retrieve new file mode 100755 index 00000000..faf09730 --- /dev/null +++ b/parameters/providers/azure_key_vault/retrieve @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a secret from Azure Key Vault by external_id. +# +# Semantics: +# - Success → return {value: ""} +# - SecretNotFound → return {value: "value not found"} +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AZ_VAULT_NAME, AZ_SECRET_PREFIX + +SECRET_NAME="${AZ_SECRET_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if VALUE=$(az keyvault secret show \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" \ + --query value \ + --output tsv 2>"$err_file"); then + rm -f "$err_file" + jq -n --arg value "$VALUE" '{value: $value}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -qE "(SecretNotFound|secret with .* was not found)"; then + echo '{ + "value": "value not found" + }' + else + log error "❌ Failed to retrieve secret '$SECRET_NAME' from Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • Identity lacks 'Get' permission on vault $AZ_VAULT_NAME" + log error " • Vault firewall blocks the caller" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: az account show" + log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/azure_key_vault/setup b/parameters/providers/azure_key_vault/setup new file mode 100755 index 00000000..4448ab3e --- /dev/null +++ b/parameters/providers/azure_key_vault/setup @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +# Validates Azure Key Vault connection config. +# +# Reads, in priority order: PROVIDER_CONFIG, environment variables, defaults. +# Exports: AZ_VAULT_NAME, AZ_SECRET_PREFIX +# +# Auth comes from the Azure CLI's default credential chain (managed identity, +# az login, service principal env vars). Not validated here — az will surface +# auth errors on the first call. + +AZ_VAULT_NAME=$(get_config_value \ + --env AZURE_KEY_VAULT_NAME \ + --provider '.vault_name') + +AZ_SECRET_PREFIX=$(get_config_value \ + --env AZURE_KEY_VAULT_SECRET_PREFIX \ + --provider '.secret_prefix' \ + --default 'parameters-') + +if [ -z "$AZ_VAULT_NAME" ]; then + log error "❌ Azure Key Vault name not configured" + log error "" + log error "💡 Possible causes:" + log error " • AZURE_KEY_VAULT_NAME env var is not set" + log error " • .vault_name is missing in the azure_key_vault provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set AZURE_KEY_VAULT_NAME=" + log error " • Or populate PROVIDER_CONFIG.vault_name via providers/azure_key_vault/fetch_configuration" + exit 1 +fi + +# Azure Key Vault secret names are constrained: alphanumeric + dashes, max 127 chars. +# UUIDs satisfy this. Validate the prefix uses only valid chars. +if [[ ! "$AZ_SECRET_PREFIX" =~ ^[A-Za-z0-9-]*$ ]]; then + log error "❌ Invalid AZ_SECRET_PREFIX '$AZ_SECRET_PREFIX'" + log error "" + log error "💡 Possible causes:" + log error " • Azure Key Vault secret names allow only alphanumerics and dashes" + log error "" + log error "🔧 How to fix:" + log error " • Set AZURE_KEY_VAULT_SECRET_PREFIX to a string matching [A-Za-z0-9-]*" + exit 1 +fi + +export AZ_VAULT_NAME AZ_SECRET_PREFIX diff --git a/parameters/providers/azure_key_vault/store b/parameters/providers/azure_key_vault/store new file mode 100755 index 00000000..7c1ef2d1 --- /dev/null +++ b/parameters/providers/azure_key_vault/store @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter value as an Azure Key Vault secret. +# AKV encrypts all secrets transparently — PARAMETER_KIND does not change behavior. +# +# Required env: PARAMETER_VALUE, AZ_VAULT_NAME, AZ_SECRET_PREFIX + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +SECRET_NAME="${AZ_SECRET_PREFIX}${EXTERNAL_ID}" + +if ! AKV_ID=$(az keyvault secret set \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" \ + --value "$PARAMETER_VALUE" \ + --query id \ + --output tsv 2>/dev/null); then + log error "❌ Failed to store secret in Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • Identity lacks Set permission on $AZ_VAULT_NAME" + log error " • Vault is in soft-deleted state or firewall blocks the caller" + log error " • Secret name '$SECRET_NAME' contains characters AKV rejects (only alphanumeric + dashes allowed)" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: az account show" + log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" + exit 1 +fi + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg secret_id "$AKV_ID" \ + --arg secret_name "$SECRET_NAME" \ + --arg vault_name "$AZ_VAULT_NAME" \ + '{external_id: $external_id, metadata: {azure_secret_id: $secret_id, secret_name: $secret_name, vault_name: $vault_name}}' diff --git a/parameters/providers/hashicorp_vault/delete b/parameters/providers/hashicorp_vault/delete new file mode 100755 index 00000000..0d711039 --- /dev/null +++ b/parameters/providers/hashicorp_vault/delete @@ -0,0 +1,53 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a secret from Vault by external_id. +# +# Idempotency semantics: +# - HTTP 2xx → success (deleted) +# - HTTP 404 → success (already gone; treated as idempotent) +# - Any other HTTP status or network error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" + +if ! RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$VAULT_ADDR/v1/$VAULT_PATH" 2>/dev/null); then + log error "❌ Network error calling Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Vault host unreachable (DNS / network / firewall)" + log error " • TLS handshake failure" + log error "" + log error "🔧 How to fix:" + log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" + exit 1 +fi + +HTTP_STATUS="${RESPONSE##*$'\n'}" + +case "$HTTP_STATUS" in + 2*) ;; + 404) + log debug "Secret at $VAULT_PATH does not exist, treating delete as success" + ;; + *) + HTTP_BODY="${RESPONSE%$'\n'*}" + log error "❌ Vault DELETE failed with HTTP $HTTP_STATUS at $VAULT_PATH" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_TOKEN lacks delete permission at this path (403)" + log error " • Server-side error (5xx) — check Vault logs" + log error "" + log error "🔧 How to fix:" + log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" + log error "Vault response: $HTTP_BODY" + exit 1 + ;; +esac + +echo '{ + "success": true +}' diff --git a/parameters/providers/hashicorp_vault/docs/architecture.md b/parameters/providers/hashicorp_vault/docs/architecture.md new file mode 100644 index 00000000..e3074fa2 --- /dev/null +++ b/parameters/providers/hashicorp_vault/docs/architecture.md @@ -0,0 +1,80 @@ +# HashiCorp Vault — Provider Architecture + +This document describes the `parameters/providers/hashicorp_vault/` implementation. It stores nullplatform parameters as Vault KV v2 secrets. + +--- + +## Lifecycle + +| Step | What happens | +|------|-----------------------------------------------------------------------| +| `setup` | Reads `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX` from env or `PROVIDER_CONFIG`. Fails fast if address or token is missing. Exports the three vars. | +| `store` | Generates a UUID `external_id`. POSTs to `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/$external_id` with a JSON payload. Returns `{external_id, metadata.vault_path}`. | +| `retrieve` | GETs from `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/$external_id`. Returns `{value}` or `{value: "value not found"}` on miss. | +| `delete` | DELETEs the secret. Idempotent — re-deleting is a no-op. Returns `{success: true}`. | +| `notify` | Not implemented — dispatcher returns the default `{success: true}` ack. | + +--- + +## Storage layout + +``` +/v1// +``` + +- **`VAULT_PATH_PREFIX`** defaults to `secret/data/parameters`. The `data/` segment is the KV v2 convention — change the default if your mount uses KV v1 (drop the `data/`) or a different mount point. +- **`external_id`** is a UUIDv4 generated at store time. It is the canonical handle nullplatform persists and re-injects for retrieve/delete. + +The stored payload at each path is a JSON envelope, not the raw value: + +```json +{ + "data": { + "parameter_id": 42, + "value": "the-actual-value", + "stored_at": "2026-05-15T12:34:56Z", + "external_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" + } +} +``` + +Keeping `parameter_id` and `external_id` inside the payload makes orphaned secrets self-describing — if someone discovers a stale entry under `parameters/`, the payload tells them which nullplatform parameter it belongs to. + +--- + +## Configuration + +`PROVIDER_CONFIG` shape (populated by `fetch_configuration` if you implement one): + +```json +{ + "address": "https://vault.example.com", + "token": "hvs.xxx", + "path_prefix": "secret/data/parameters" +} +``` + +Equivalent env vars: `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX`. Provider config wins over env per `get_config_value` priority. + +--- + +## Authentication notes + +This provider uses static token auth via `X-Vault-Token`. For production use, consider: + +- **Token rotation**: short-lived tokens (issued by AppRole, Kubernetes auth, etc.) should be refreshed by `fetch_configuration` rather than relying on a long-lived `VAULT_TOKEN` env var. +- **OIDC / Kubernetes auth**: a richer `fetch_configuration` could exchange a workload identity for a Vault token at runtime, removing the need for any pre-issued credential. + +The operation scripts (`store`/`retrieve`/`delete`) don't care how `VAULT_TOKEN` got into the environment — they just use it. Swap the auth mechanism by changing `setup` (and optionally adding `fetch_configuration`). + +--- + +## Compatibility + +The output JSON shape matches the previous `parameters/vault/` implementation byte-for-byte: + +- `store` → `{external_id, metadata: {vault_path}}` +- `retrieve` → `{value}` or `{value: "value not found"}` +- `delete` → `{success: true}` + +A scope that switches from the old layout to this provider sees no behavior change against Vault. diff --git a/parameters/providers/hashicorp_vault/retrieve b/parameters/providers/hashicorp_vault/retrieve new file mode 100755 index 00000000..a7a8f31d --- /dev/null +++ b/parameters/providers/hashicorp_vault/retrieve @@ -0,0 +1,55 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a value from Vault by external_id. +# +# Semantics: +# - HTTP 2xx → return {value: ""} +# - HTTP 404 → return {value: "value not found"} (legitimate miss) +# - Any other HTTP status or network error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" + +if ! RESPONSE=$(curl -s -w "\n%{http_code}" \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$VAULT_ADDR/v1/$VAULT_PATH" 2>/dev/null); then + log error "❌ Network error calling Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Vault host unreachable (DNS / network / firewall)" + log error "" + log error "🔧 How to fix:" + log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" + exit 1 +fi + +HTTP_STATUS="${RESPONSE##*$'\n'}" +HTTP_BODY="${RESPONSE%$'\n'*}" + +case "$HTTP_STATUS" in + 2*) + STORED_VALUE=$(echo "$HTTP_BODY" | jq -r '.data.data.value // empty') + echo '{ + "value": "'$STORED_VALUE'" + }' + ;; + 404) + echo '{ + "value": "value not found" + }' + ;; + *) + log error "❌ Vault GET failed with HTTP $HTTP_STATUS at $VAULT_PATH" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_TOKEN lacks read permission (403)" + log error " • Server-side error (5xx)" + log error "" + log error "🔧 How to fix:" + log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" + log error "Vault response: $HTTP_BODY" + exit 1 + ;; +esac diff --git a/parameters/providers/hashicorp_vault/setup b/parameters/providers/hashicorp_vault/setup new file mode 100755 index 00000000..2a84dcc0 --- /dev/null +++ b/parameters/providers/hashicorp_vault/setup @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +# Validates HashiCorp Vault connection config. +# Sourced once by parameters/build_context before any operation script runs. +# +# Reads, in priority order: PROVIDER_CONFIG (if populated by fetch_configuration), +# environment variables, defaults. +# +# Exports for operation scripts: VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') +VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') +VAULT_PATH_PREFIX=$(get_config_value \ + --env VAULT_PATH_PREFIX \ + --provider '.path_prefix' \ + --default 'secret/data/parameters') + +if [ -z "$VAULT_ADDR" ]; then + log error "❌ Vault address not configured" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_ADDR env var is not set in the workflow runtime" + log error " • .address is missing in the hashicorp_vault provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set VAULT_ADDR=https://your-vault-host" + log error " • Or populate PROVIDER_CONFIG.address via providers/hashicorp_vault/fetch_configuration" + exit 1 +fi + +if [ -z "$VAULT_TOKEN" ]; then + log error "❌ Vault token not configured" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_TOKEN env var is not set in the workflow runtime" + log error " • .token is missing in the hashicorp_vault provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set VAULT_TOKEN=" + log error " • Or populate PROVIDER_CONFIG.token via providers/hashicorp_vault/fetch_configuration" + exit 1 +fi + +export VAULT_ADDR VAULT_TOKEN VAULT_PATH_PREFIX diff --git a/parameters/providers/hashicorp_vault/store b/parameters/providers/hashicorp_vault/store new file mode 100755 index 00000000..20f1d176 --- /dev/null +++ b/parameters/providers/hashicorp_vault/store @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter value in HashiCorp Vault KV v2. +# Generates a fresh UUID as external_id (the canonical handle returned to nullplatform). +# +# Required env (exported by build_context + setup): +# PARAMETER_ID, PARAMETER_VALUE, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" + +if ! curl -s -X POST \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$VAULT_ADDR/v1/$VAULT_PATH" \ + -d "{\"data\":{\"parameter_id\":$PARAMETER_ID,\"value\":$(echo "$PARAMETER_VALUE" | jq -R .),\"stored_at\":\"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\",\"external_id\":\"$EXTERNAL_ID\"}}" >/dev/null; then + log error "❌ Failed to store parameter in Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Network unreachable to $VAULT_ADDR" + log error " • VAULT_TOKEN expired or lacks write permission on $VAULT_PATH" + log error " • KV mount at $VAULT_PATH_PREFIX does not exist" + log error "" + log error "🔧 How to fix:" + log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" + log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" + exit 1 +fi + +echo '{ + "external_id": "'$EXTERNAL_ID'", + "metadata": { + "vault_path": "'$VAULT_PATH'" + } +}' diff --git a/parameters/providers/parameter_store/delete b/parameters/providers/parameter_store/delete new file mode 100755 index 00000000..84c2be3e --- /dev/null +++ b/parameters/providers/parameter_store/delete @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a parameter from AWS Parameter Store. +# +# Idempotency semantics: +# - Successful delete → success +# - ParameterNotFound → success (already gone) +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AWS_REGION, PS_NAME_PREFIX + +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if aws ssm delete-parameter \ + --region "$AWS_REGION" \ + --name "$PARAM_NAME" >/dev/null 2>"$err_file"; then + rm -f "$err_file" + echo '{ + "success": true +}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ParameterNotFound"; then + log debug "Parameter '$PARAM_NAME' does not exist, treating delete as success" + echo '{ + "success": true +}' + else + log error "❌ Failed to delete parameter '$PARAM_NAME' in AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks ssm:DeleteParameter on this resource" + log error " • Region '$AWS_REGION' unreachable or wrong" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/parameter_store/docs/architecture.md b/parameters/providers/parameter_store/docs/architecture.md new file mode 100644 index 00000000..c616700e --- /dev/null +++ b/parameters/providers/parameter_store/docs/architecture.md @@ -0,0 +1,104 @@ +# AWS Systems Manager Parameter Store — Provider Architecture + +This document describes the `parameters/providers/parameter_store/` implementation. It stores nullplatform parameters as AWS SSM Parameter Store entries, using `String` for plain parameters and `SecureString` (KMS-encrypted) for secrets. + +This is the cheapest provider in the package — Standard tier is free up to 10,000 parameters. + +--- + +## Lifecycle + +| Step | What happens | +|------|-----------------------------------------------------------------------------| +| `setup` | Reads `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. Normalizes prefix to start/end with `/`. Fails if region is missing or tier is invalid. | +| `store` | Generates a UUID. Calls `aws ssm put-parameter` with `Type=String` (kind=parameter) or `Type=SecureString` (kind=secret). Returns `{external_id, metadata}`. | +| `retrieve` | Calls `aws ssm get-parameter --with-decryption`. Returns `{value}` or `{value: "value not found"}`. | +| `delete` | Calls `aws ssm delete-parameter`. Idempotent — never errors. | +| `notify` | Not implemented — dispatcher returns default `{success: true}`. | + +--- + +## Type selection via PARAMETER_KIND + +This is the first provider in the package that branches on `PARAMETER_KIND`: + +| Kind | SSM Type | KMS | +|-------------|-----------------|------------------------------------------------------| +| `parameter` | `String` | None (plain text) | +| `secret` | `SecureString` | `PS_KMS_KEY_ID` if set, otherwise `alias/aws/ssm` | + +For `secret_manager`, `hashicorp_vault`, and `azure_key_vault`, the kind is informational — those backends encrypt all values uniformly. Parameter Store is different because it distinguishes the storage type at the API level. + +--- + +## Naming convention + +``` + +``` + +- `PS_NAME_PREFIX` defaults to `/nullplatform/parameters/`. Always starts with `/` (SSM hierarchical naming) and ends with `/` (the script normalizes both). +- `external_id` is a UUIDv4 generated at store time. +- Full parameter name example: `/nullplatform/parameters/f47ac10b-58cc-4372-a567-0e02b2c3d479` + +The hierarchical prefix lets you scope IAM via path-based ARN patterns: +``` +arn:aws:ssm:::parameter/nullplatform/parameters/* +``` + +--- + +## Tiers + +Parameter Store has three tiers, selected via `PS_TIER`: + +| Tier | Free | Value size | Use case | +|-----------------------|-----------------------|-------------|-----------------------------------| +| `Standard` (default) | up to 10,000 params | 4 KB | Most cases | +| `Advanced` | $0.05/param/month | 8 KB | Large values or > 10k params | +| `Intelligent-Tiering` | Auto-promotes | varies | Mixed sizes, optimize for cost | + +Standard is the default and what most consumers should use. Switch to Advanced explicitly when you have a value > 4 KB or you'll cross 10,000 parameters. + +--- + +## Cost model + +``` +Standard: $0.00 / param / month (up to 10,000) + $0.05 / 10,000 API calls +Advanced: $0.05 / param / month + $0.05 / 10,000 API calls +Intelligent-Tiering: varies (sees Advanced rate once promoted) +``` + +For 100 secret parameters across all your apps on Standard tier: **$0/month** (vs ~$40/month with Secrets Manager). The trade-off: Parameter Store has no rotation, no replication, no resource-based policies — features Secrets Manager provides for the extra cost. + +--- + +## Configuration + +`PROVIDER_CONFIG` shape: + +```json +{ + "region": "us-east-1", + "name_prefix": "/nullplatform/parameters/", + "kms_key_id": "alias/parameters-secure", + "tier": "Standard" +} +``` + +Equivalent env vars: `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. `PROVIDER_CONFIG` wins per `get_config_value` priority. + +`kms_key_id` is only used when storing a `SecureString` (kind=secret). For plain parameters it's ignored. + +--- + +## Compatibility with the contract + +| Operation | Output shape | Notes | +|-----------|--------------|-------| +| store | `{external_id, metadata: {parameter_name, region, type, tier}}` | `type` reflects the SSM Type used (String or SecureString) | +| retrieve | `{value}` or `{value: "value not found"}` | --with-decryption is always passed; no-op for String | +| delete | `{success: true}` | Always; idempotent | diff --git a/parameters/providers/parameter_store/docs/iam-policy.md b/parameters/providers/parameter_store/docs/iam-policy.md new file mode 100644 index 00000000..f32978a7 --- /dev/null +++ b/parameters/providers/parameter_store/docs/iam-policy.md @@ -0,0 +1,112 @@ +# IAM Policy — Parameter Store Provider, Least Privilege + +Minimum IAM permissions required to operate the `parameters/providers/parameter_store/` provider, scoped to the configured `PS_NAME_PREFIX`. + +--- + +## Required actions + +| Action | Used by | Why | +|------------------------------|------------|--------------------------------------------------------| +| `ssm:PutParameter` | `store` | Creates the parameter (String or SecureString) | +| `ssm:GetParameter` | `retrieve` | Reads the value back | +| `ssm:DeleteParameter` | `delete` | Removes the parameter | +| `ssm:DescribeParameters` | optional | Useful for diagnostics | + +`PutParameterBatch`, `LabelParameterVersion`, `GetParameterHistory`, `AddTagsToResource` are **not** required and should not be granted unless code grows to use them. + +--- + +## Recommended policy + +Replace placeholders before applying: + +- `` — region where parameters are stored. +- `` — 12-digit AWS account id. +- `` — the configured prefix (e.g. `nullplatform/parameters`). Strip leading and trailing `/` when placing into the ARN. +- `` — required if you store any `SecureString` (kind=secret). For default `alias/aws/ssm` you can omit the KMS statement. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ManageNullplatformParameters", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:GetParameter", + "ssm:DeleteParameter", + "ssm:DescribeParameters" + ], + "Resource": [ + "arn:aws:ssm:::parameter//*" + ] + } + ] +} +``` + +Note the ARN format: `parameter//*` — no extra slash between `parameter` and the prefix because the prefix itself starts with `/`. So if `PS_NAME_PREFIX=/nullplatform/parameters/`, the ARN is `arn:aws:ssm:...:parameter/nullplatform/parameters/*`. + +--- + +## KMS (only when storing SecureString with a CMK) + +If `PS_KMS_KEY_ID` is set to a customer-managed key, both the agent (writer) and any consumer (reader) need KMS permissions. Add this to both policies: + +```json +{ + "Sid": "UseCustomerManagedKmsKeyForParameterStore", + "Effect": "Allow", + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey" + ], + "Resource": [ + "arn:aws:kms:::key/" + ], + "Condition": { + "StringEquals": { + "kms:ViaService": "ssm..amazonaws.com" + } + } +} +``` + +The `kms:ViaService` condition restricts the key to SSM use — without it, the role could decrypt arbitrary ciphertexts encrypted with the same key. The CMK's **key policy** must also allow the role principal; IAM permissions alone aren't enough for KMS. + +If you use the default `alias/aws/ssm` (AWS-managed), no extra KMS statement is needed — Parameter Store handles encryption transparently. + +--- + +## Splitting agent vs consumer + +The writer (this provider's scripts) needs put + get + delete. A runtime consumer typically only needs read: + +```json +{ + "Sid": "ReadNullplatformParameters", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": [ + "arn:aws:ssm:::parameter//*" + ] +} +``` + +`GetParametersByPath` is useful if a consumer wants to enumerate all parameters under a hierarchical prefix (e.g. fetching all secrets for an app in one call). + +--- + +## What not to grant + +- `ssm:*` (account-wide) — opens access to OS commands (`AWS-RunShellScript`), maintenance windows, session manager, etc. +- `ssm:PutParameter` with `Resource: "*"` — lets the role write to ANY parameter in the account (including other apps' secrets). +- `ssm:LabelParameterVersion`, `ssm:UnlabelParameterVersion` — versioning workflows; not used by this provider. +- `iam:*` — this provider doesn't manage IAM. diff --git a/parameters/providers/parameter_store/retrieve b/parameters/providers/parameter_store/retrieve new file mode 100755 index 00000000..e00dd44d --- /dev/null +++ b/parameters/providers/parameter_store/retrieve @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a parameter from AWS Parameter Store by external_id. +# Uses --with-decryption (no-op for String, decrypts SecureString). +# +# Semantics: +# - Success → return {value: ""} +# - ParameterNotFound → return {value: "value not found"} +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AWS_REGION, PS_NAME_PREFIX + +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if VALUE=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$PARAM_NAME" \ + --with-decryption \ + --query Parameter.Value \ + --output text 2>"$err_file"); then + rm -f "$err_file" + jq -n --arg value "$VALUE" '{value: $value}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ParameterNotFound"; then + echo '{ + "value": "value not found" + }' + else + log error "❌ Failed to retrieve parameter '$PARAM_NAME' from AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks ssm:GetParameter on this resource" + log error " • KMS key permission missing (kms:Decrypt) for SecureString" + log error " • Region '$AWS_REGION' unreachable" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/parameter_store/setup b/parameters/providers/parameter_store/setup new file mode 100755 index 00000000..0345b25b --- /dev/null +++ b/parameters/providers/parameter_store/setup @@ -0,0 +1,61 @@ +#!/bin/bash +set -euo pipefail + +# Validates AWS Systems Manager Parameter Store connection config. +# +# Required env (exported by parameters/build_context): +# PARAMETER_KIND — "secret" or "parameter"; determines String vs SecureString +# +# Reads, in priority order: PROVIDER_CONFIG, environment variables, defaults. +# Exports: AWS_REGION, PS_NAME_PREFIX, PS_KMS_KEY_ID, PS_TIER + +AWS_REGION=$(get_config_value \ + --env AWS_REGION \ + --env AWS_DEFAULT_REGION \ + --provider '.region') + +PS_NAME_PREFIX=$(get_config_value \ + --env PS_NAME_PREFIX \ + --provider '.name_prefix' \ + --default '/nullplatform/parameters/') + +PS_KMS_KEY_ID=$(get_config_value \ + --env PS_KMS_KEY_ID \ + --provider '.kms_key_id' \ + --default '') + +PS_TIER=$(get_config_value \ + --env PS_TIER \ + --provider '.tier' \ + --default 'Standard') + +if [ -z "$AWS_REGION" ]; then + log error "❌ AWS region not configured for parameter_store" + log error "" + log error "💡 Possible causes:" + log error " • AWS_REGION (or AWS_DEFAULT_REGION) env var is not set" + log error " • .region is missing in the parameter_store provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set AWS_REGION=" + log error " • Or populate PROVIDER_CONFIG.region via providers/parameter_store/fetch_configuration" + exit 1 +fi + +# Normalize the name prefix: must start with '/' (SSM hierarchical naming) and end with '/'. +[[ "$PS_NAME_PREFIX" != /* ]] && PS_NAME_PREFIX="/$PS_NAME_PREFIX" +[[ "$PS_NAME_PREFIX" != */ ]] && PS_NAME_PREFIX="$PS_NAME_PREFIX/" + +# Tier must be one of the valid SSM values. +case "$PS_TIER" in + Standard|Advanced|Intelligent-Tiering) ;; + *) + log error "❌ Invalid PS_TIER '$PS_TIER'" + log error "" + log error "🔧 How to fix:" + log error " • Set PS_TIER to one of: Standard, Advanced, Intelligent-Tiering" + exit 1 + ;; +esac + +export AWS_REGION PS_NAME_PREFIX PS_KMS_KEY_ID PS_TIER diff --git a/parameters/providers/parameter_store/store b/parameters/providers/parameter_store/store new file mode 100755 index 00000000..17dedd92 --- /dev/null +++ b/parameters/providers/parameter_store/store @@ -0,0 +1,51 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter in AWS Systems Manager Parameter Store. +# - kind=secret → Type=SecureString (encrypted via KMS_KEY_ID or default aws/ssm) +# - kind=parameter → Type=String (plain text) +# +# Required env: PARAMETER_KIND, PARAMETER_VALUE, AWS_REGION, PS_NAME_PREFIX, PS_TIER +# Optional env: PS_KMS_KEY_ID + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" + +SSM_TYPE="String" +[ "${PARAMETER_KIND:-}" = "secret" ] && SSM_TYPE="SecureString" + +put_args=( + --region "$AWS_REGION" + --name "$PARAM_NAME" + --value "$PARAMETER_VALUE" + --type "$SSM_TYPE" + --tier "$PS_TIER" +) +if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then + put_args+=(--key-id "$PS_KMS_KEY_ID") +fi + +if ! aws ssm put-parameter "${put_args[@]}" >/dev/null 2>&1; then + log error "❌ Failed to store parameter in AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks ssm:PutParameter for $PARAM_NAME" + log error " • A parameter with this name already exists (UUID collision — extremely unlikely)" + log error " • Tier '$PS_TIER' rejects this value size (Standard caps at 4KB)" + if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then + log error " • IAM principal lacks kms:Encrypt on $PS_KMS_KEY_ID" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error " • For large values, set PS_TIER=Advanced" + exit 1 +fi + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg parameter_name "$PARAM_NAME" \ + --arg region "$AWS_REGION" \ + --arg type "$SSM_TYPE" \ + --arg tier "$PS_TIER" \ + '{external_id: $external_id, metadata: {parameter_name: $parameter_name, region: $region, type: $type, tier: $tier}}' diff --git a/parameters/providers/secret_manager/delete b/parameters/providers/secret_manager/delete new file mode 100755 index 00000000..cc6c8835 --- /dev/null +++ b/parameters/providers/secret_manager/delete @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a secret from AWS Secrets Manager. +# +# Idempotency semantics: +# - Successful delete → success +# - ResourceNotFoundException → success (already gone, idempotent) +# - Any other error → exit 1 with troubleshooting +# +# Uses --force-delete-without-recovery to end billing immediately and free the name. +# +# Required env: EXTERNAL_ID, AWS_REGION, SM_NAME_PREFIX + +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if aws secretsmanager delete-secret \ + --region "$AWS_REGION" \ + --secret-id "$SECRET_NAME" \ + --force-delete-without-recovery >/dev/null 2>"$err_file"; then + rm -f "$err_file" + echo '{ + "success": true +}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ResourceNotFoundException"; then + log debug "Secret '$SECRET_NAME' does not exist, treating delete as success" + echo '{ + "success": true +}' + else + log error "❌ Failed to delete secret '$SECRET_NAME' in AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:DeleteSecret on this resource" + log error " • Region '$AWS_REGION' unreachable or wrong" + log error " • AWS API throttling" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error " • Check IAM policy: aws iam simulate-principal-policy --action-names secretsmanager:DeleteSecret" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/secret_manager/docs/architecture.md b/parameters/providers/secret_manager/docs/architecture.md new file mode 100644 index 00000000..78b5da7e --- /dev/null +++ b/parameters/providers/secret_manager/docs/architecture.md @@ -0,0 +1,150 @@ +# AWS Secrets Manager — Architecture + +This document describes how the `parameters/providers/secret_manager/` provider stores, retrieves, and deletes nullplatform parameters using AWS Secrets Manager (SM), and how it differs from clear-text parameters. + +--- + +## Role in the parameter lifecycle + +nullplatform parameters can be of two kinds: + +| Kind | Storage location | This package | +|-------------|---------------------------------------------------|--------------| +| Clear-text | nullplatform API (its own datastore) | Not involved | +| Secret | External provider (this package: AWS SM) | Used | + +Only **secret** parameters trigger the `store` / `retrieve` / `delete` workflows. Clear-text values never leave nullplatform and never touch AWS SM. From the operator's perspective: this provider is invisible until a parameter is marked as a secret. + +The interaction is event-driven via four nullplatform actions: + +| Action | Trigger | Effect on AWS SM | +|------------|--------------------------------------------------|---------------------------------------| +| `store` | A secret parameter is created | `CreateSecret` with payload | +| `retrieve` | A consumer needs the value (deploy, runtime API) | `GetSecretValue`, returns `{value}` | +| `delete` | A secret parameter is deleted | `DeleteSecret --force-delete-without-recovery` | +| `notify` | nullplatform-side ack hook | No-op (returns `{success: true}`) | + +The contract of these four scripts is identical to the `parameters/providers/hashicorp_vault/` provider — they are drop-in replaceable. Only the storage backend changes. + +--- + +## Naming strategy + +### Path layout + +Every secret is stored under a single namespace: + +``` +parameters/ +``` + +- **`parameters/`** — fixed prefix. This is the IAM anchor: it lets a single resource ARN pattern (`arn:...:secret:parameters/*`) cover everything this provider manages, and nothing else. Removing the prefix would force IAM to either allow account-wide access or maintain an enumerated list of secret names (impractical, since names are generated at runtime). +- **``** — UUIDv4 generated by the `store` script via `uuidgen` (with `openssl rand -hex 16` as a portable fallback). This becomes the canonical handle nullplatform persists; subsequent `retrieve` / `delete` actions get it back via `NP_ACTION_CONTEXT.notification.external_id`. + +### ARN shape + +When you `CreateSecret --name parameters/`, AWS appends a random 6-character suffix to the ARN: + +``` +arn:aws:secretsmanager:::secret:parameters/-XXXXXX +``` + +This matters for IAM: + +- ARN pattern `arn:...:secret:parameters/*` **does** match (the wildcard absorbs the suffix). +- ARN pattern `arn:...:secret:parameters/` **does not** match (no suffix-aware glob). + +Always use the wildcard form in policies, even when locking down to a single secret — anchor the wildcard at the deterministic part of the name. + +### Region partitioning + +Each secret lives in a single AWS region. The `store` / `retrieve` / `delete` scripts all read `AWS_REGION` (falling back to `AWS_DEFAULT_REGION`, then `us-east-1`). The region is also written into the `metadata` returned by `store`, so a future cross-region disaster-recovery flow can locate the secret without guessing. + +Cross-region replication is **not** enabled by default. AWS SM supports it (`replicate-secret-to-regions`), but it doubles the per-secret cost and is not required for the current contract. + +--- + +## Secret payload shape + +The value stored in AWS SM is **not** the raw parameter value — it is a JSON envelope: + +```json +{ + "parameter_id": 42, + "value": "the-actual-secret", + "stored_at": "2026-05-05T12:34:56Z", + "external_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" +} +``` + +| Field | Source | Purpose | +|----------------|----------------------------------------|--------------------------------------| +| `parameter_id` | `NP_ACTION_CONTEXT.notification.parameter_id` | Reverse lookup from SM to nullplatform | +| `value` | `NP_ACTION_CONTEXT.notification.value` | The secret itself | +| `stored_at` | `date -u` at store time | Audit trail | +| `external_id` | UUID generated at store time | Self-verification (must equal the path component) | + +Keeping `parameter_id` and `external_id` inside the payload — and not just in the path — means the secret is self-describing. If someone discovers an orphaned `parameters/` secret in AWS, the JSON tells them which nullplatform parameter it belongs to without needing nullplatform's metadata. + +The `retrieve` script extracts only `.value` from this envelope, preserving the `{value: "..."}` output contract shared with the Vault provider. + +--- + +## Cost model + +AWS Secrets Manager pricing (as of writing — verify against current AWS pricing for your region): + +| Component | Price | +|------------------|--------------------------------------| +| Per secret | **$0.40 / secret / month** (prorated) | +| API calls | **$0.05 / 10,000 calls** | +| Replicated secret | $0.40 / replica / month | + +### Cost intuition + +For a service with **N** secret parameters and roughly **M** API calls per parameter per month (deploys + retrievals): + +``` +monthly_cost ≈ 0.40 * N + 0.05 * (N * M / 10_000) +``` + +The fixed $0.40 dominates: at typical deploy frequencies, the per-secret monthly fee is ~99% of the total. **Adding a secret costs $0.40/month**; reading it 100 times costs $0.0005. + +### Cost levers + +1. **Don't store non-secrets here.** Clear-text parameters belong in nullplatform's API. Mis-classification is the most common cost regression. +2. **Delete promptly.** `--force-delete-without-recovery` (already used) ends billing immediately. Without it, AWS keeps charging for the 7–30 day soft-delete window. +3. **Avoid replication unless you need DR.** Each replica is a full $0.40/month. +4. **Cache at the consumer side.** Each `GetSecretValue` is a billable call. For high-fanout reads (e.g., autoscaling pods all pulling the same value), have a single retrieve at deploy-time and inject as env var, rather than per-pod API calls. + +### Comparison with self-hosted Vault + +This isn't apples-to-apples: + +- **Vault**: no per-secret fee. Cost is the underlying infra (EC2/EKS, storage, ops time). Below ~250 secrets, self-hosted Vault tends to be cheaper on paper but more expensive in operator hours. +- **AWS SM**: linear in secret count, zero ops. Above ~250 secrets the costs converge; the trade-off becomes operational rather than financial. + +The two providers were designed to be drop-in replaceable precisely so this decision can be revisited per environment. + +--- + +## Lifecycle notes + +### Hard delete by default + +`delete` uses `--force-delete-without-recovery`. This is intentional: + +- AWS SM defaults to a **soft-delete** with a 7–30 day recovery window. During that window, the secret name is reserved (you cannot create a new secret with the same name) and you continue paying the $0.40/month fee. +- Since `external_id` is a UUID, name collisions on re-creation are not a real risk. We trade recoverability for clean re-use. + +If you need recoverability for compliance reasons, change `delete` to omit `--force-delete-without-recovery` and add a `--recovery-window-in-days ` flag — but document it in the parameter-store-level retention policy. + +### Idempotency + +- `delete` is idempotent: it suppresses errors with `|| true` and always returns `{success: true}`. Re-deleting a missing secret is a no-op. +- `retrieve` is idempotent: it returns `{"value": "value not found"}` instead of failing if the secret doesn't exist. +- `store` is **not** idempotent: a second call generates a new UUID and stores a new secret. Idempotency is enforced at the nullplatform layer (the action is only fired once per parameter create event). + +### Encryption at rest + +All values are encrypted by AWS SM. By default, SM uses the AWS-managed KMS key `aws/secretsmanager`. To use a customer-managed KMS key (CMK), pass `--kms-key-id ` in `store` and grant `kms:Decrypt` / `kms:GenerateDataKey` on that key to consumers (see `iam-policy.md`). diff --git a/parameters/providers/secret_manager/docs/iam-policy.md b/parameters/providers/secret_manager/docs/iam-policy.md new file mode 100644 index 00000000..f10e3ed7 --- /dev/null +++ b/parameters/providers/secret_manager/docs/iam-policy.md @@ -0,0 +1,198 @@ +# IAM Policy — Least Privilege + +This document specifies the minimum IAM permissions required to operate the `parameters/providers/secret_manager/` provider. The policy is scoped to the `parameters/*` namespace and avoids account-wide wildcards. + +--- + +## Wildcards: which ones are OK + +There are two distinct uses of `*` in IAM, frequently conflated: + +| Pattern | Meaning | Allowed here? | +|------------------------------------------------------|--------------------------------------|---------------| +| `"Resource": "*"` | All resources of all types in the account | **No** | +| `"Resource": "arn:...:secret:parameters/*"` | Path glob on the `parameters/` prefix | **Yes** | + +The second is not a privilege escalation — it is the only way to express "all secrets owned by this provider" given that secret names are UUIDs generated at runtime and cannot be enumerated in advance. Avoiding it would force either explicit per-secret policies (impossible for unknown UUIDs) or `Resource: "*"` (much wider). + +--- + +## Required actions + +| Action | Used by | Why | +|-----------------------------------|------------|----------------------------------------------------------------------| +| `secretsmanager:CreateSecret` | `store` | Creates the secret with the JSON envelope | +| `secretsmanager:GetSecretValue` | `retrieve` | Reads the JSON envelope back | +| `secretsmanager:DeleteSecret` | `delete` | Removes the secret (with `--force-delete-without-recovery`) | +| `secretsmanager:DescribeSecret` | optional | Useful for diagnostics; not strictly required by the current scripts | + +`UpdateSecret`, `PutSecretValue`, `RestoreSecret`, `TagResource`, `RotateSecret` are **not** required and should not be granted unless the scripts grow to use them. + +--- + +## Recommended policy + +Replace placeholders before applying: + +- `` — region where the provider stores secrets (e.g. `us-east-1`). +- `` — 12-digit AWS account id of the agent. +- `` — only if using a customer-managed KMS key (see KMS section below). Otherwise omit the entire KMS statement. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ManageNullplatformSecretParameters", + "Effect": "Allow", + "Action": [ + "secretsmanager:CreateSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:::secret:parameters/*" + ] + } + ] +} +``` + +### Why this is sufficient + +- Confined to a single region and account. +- Confined to the `parameters/` name prefix — no other secrets in the account are reachable. +- No `Resource: "*"`. +- No write actions beyond create + delete (no overwrite, no rotation). +- No tagging or policy-management actions. + +--- + +## Splitting agent vs consumer + +The policy above grants both write and read in one role. In production it is often cleaner to split them: + +### Agent role (executes the workflow scripts) + +Needs `CreateSecret`, `GetSecretValue`, `DeleteSecret`, `DescribeSecret`. Same as the recommended policy above. + +### Consumer role (the application that needs the value at runtime) + +Needs only `GetSecretValue` (and `DescribeSecret` if the consumer enumerates metadata): + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ReadNullplatformSecretParameters", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:::secret:parameters/*" + ] + } + ] +} +``` + +If a consumer should only read **specific** secrets (rather than every parameter in the namespace), narrow the resource list: + +```json +"Resource": [ + "arn:aws:secretsmanager:::secret:parameters/-*" +] +``` + +The trailing `-*` is required because AWS SM appends a 6-character suffix to the ARN of every secret it creates (see `architecture.md`). Omitting it makes the ARN never match. + +--- + +## KMS (only if using a customer-managed key) + +If you pass `--kms-key-id` to `CreateSecret` (i.e. you do not want to use the default `aws/secretsmanager` AWS-managed key), both the agent and any consumer also need access to the CMK. Add this statement to **both** the agent and consumer policies: + +```json +{ + "Sid": "UseCustomerManagedKmsKey", + "Effect": "Allow", + "Action": [ + "kms:Decrypt", + "kms:GenerateDataKey" + ], + "Resource": [ + "arn:aws:kms:::key/" + ], + "Condition": { + "StringEquals": { + "kms:ViaService": "secretsmanager..amazonaws.com" + } + } +} +``` + +The `kms:ViaService` condition is the security-relevant part: it ensures the role can only use the key **through Secrets Manager**, not for arbitrary `kms:Decrypt` calls against other ciphertexts encrypted with the same key. + +The CMK's **key policy** must also allow the role principal — IAM permissions alone are not enough for KMS. Configure that on the key, not on the role. + +--- + +## Conditions worth adding + +Optional hardening, depending on threat model: + +### Restrict to specific VPC endpoints + +If the agent runs inside a VPC with a Secrets Manager interface endpoint: + +```json +"Condition": { + "StringEquals": { + "aws:SourceVpce": "" + } +} +``` + +### Restrict to a specific source IAM role + +For consumers running in a known service account (IRSA) or instance profile: + +```json +"Condition": { + "ArnEquals": { + "aws:PrincipalArn": "arn:aws:iam:::role/" + } +} +``` + +(This is more typically enforced via the trust policy of the role itself, but resource policies on the secret can pin it as defense-in-depth.) + +### Enforce TLS + +Mostly handled by AWS by default, but explicitly denying non-TLS traffic is cheap insurance: + +```json +"Condition": { + "Bool": { + "aws:SecureTransport": "true" + } +} +``` + +--- + +## What not to grant + +For reference — these are commonly requested but **not** needed by the current scripts and should be denied unless a specific use case is documented: + +- `secretsmanager:PutSecretValue` — would let the agent overwrite values. Not used; secrets are immutable in this design. +- `secretsmanager:UpdateSecret` — same reasoning. +- `secretsmanager:RotateSecret` — rotation is not implemented. +- `secretsmanager:TagResource`, `UntagResource` — no tagging in current scripts. +- `secretsmanager:PutResourcePolicy` — would let the agent change cross-account access. Should be reserved for a separate admin role. +- `secretsmanager:ReplicateSecretToRegions` — replication is opt-in and out of scope. +- `iam:*` of any kind — this provider does not manage IAM. diff --git a/parameters/providers/secret_manager/retrieve b/parameters/providers/secret_manager/retrieve new file mode 100755 index 00000000..350d0ef2 --- /dev/null +++ b/parameters/providers/secret_manager/retrieve @@ -0,0 +1,44 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a value from AWS Secrets Manager by external_id. +# +# Semantics: +# - Success → return {value: ""} (extracted from JSON envelope) +# - ResourceNotFoundException → return {value: "value not found"} +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AWS_REGION, SM_NAME_PREFIX + +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if SECRET_STRING=$(aws secretsmanager get-secret-value \ + --region "$AWS_REGION" \ + --secret-id "$SECRET_NAME" \ + --query SecretString \ + --output text 2>"$err_file"); then + rm -f "$err_file" + STORED_VALUE=$(echo "$SECRET_STRING" | jq -r '.value // empty') + jq -n --arg value "$STORED_VALUE" '{value: $value}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ResourceNotFoundException"; then + echo '{ + "value": "value not found" + }' + else + log error "❌ Failed to retrieve secret '$SECRET_NAME' from AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:GetSecretValue" + log error " • KMS key permission missing (kms:Decrypt) for CMK-encrypted secrets" + log error " • Region '$AWS_REGION' unreachable" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/secret_manager/setup b/parameters/providers/secret_manager/setup new file mode 100755 index 00000000..b1f898ca --- /dev/null +++ b/parameters/providers/secret_manager/setup @@ -0,0 +1,41 @@ +#!/bin/bash +set -euo pipefail + +# Validates AWS Secrets Manager connection config. +# Sourced once by parameters/build_context before any operation script runs. +# +# Reads, in priority order: PROVIDER_CONFIG, environment variables, defaults. +# Exports for operation scripts: AWS_REGION, SM_NAME_PREFIX, SM_KMS_KEY_ID + +AWS_REGION=$(get_config_value \ + --env AWS_REGION \ + --env AWS_DEFAULT_REGION \ + --provider '.region') + +SM_NAME_PREFIX=$(get_config_value \ + --env SM_NAME_PREFIX \ + --provider '.name_prefix' \ + --default 'parameters/') + +SM_KMS_KEY_ID=$(get_config_value \ + --env SM_KMS_KEY_ID \ + --provider '.kms_key_id' \ + --default '') + +if [ -z "$AWS_REGION" ]; then + log error "❌ AWS region not configured for secret_manager" + log error "" + log error "💡 Possible causes:" + log error " • AWS_REGION (or AWS_DEFAULT_REGION) env var is not set" + log error " • .region is missing in the secret_manager provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set AWS_REGION= (e.g. us-east-1)" + log error " • Or populate PROVIDER_CONFIG.region via providers/secret_manager/fetch_configuration" + exit 1 +fi + +# AWS credentials come from the SDK default chain (IRSA, instance profile, env vars). +# Not validated here — AWS CLI will surface auth errors on the first call. + +export AWS_REGION SM_NAME_PREFIX SM_KMS_KEY_ID diff --git a/parameters/providers/secret_manager/store b/parameters/providers/secret_manager/store new file mode 100755 index 00000000..d9295459 --- /dev/null +++ b/parameters/providers/secret_manager/store @@ -0,0 +1,50 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter value as an AWS Secrets Manager secret. +# One secret per parameter (each ~$0.40/month). External_id is a fresh UUIDv4. +# +# Required env: PARAMETER_ID, PARAMETER_VALUE, AWS_REGION, SM_NAME_PREFIX +# Optional env: SM_KMS_KEY_ID (uses default aws/secretsmanager key when empty) + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" + +SECRET_PAYLOAD=$(jq -n \ + --argjson parameter_id "${PARAMETER_ID:-null}" \ + --arg value "$PARAMETER_VALUE" \ + --arg stored_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + --arg external_id "$EXTERNAL_ID" \ + '{parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id}') + +create_args=( + --region "$AWS_REGION" + --name "$SECRET_NAME" + --secret-string "$SECRET_PAYLOAD" + --query ARN + --output text +) +if [ -n "${SM_KMS_KEY_ID:-}" ]; then + create_args+=(--kms-key-id "$SM_KMS_KEY_ID") +fi + +if ! SECRET_ARN=$(aws secretsmanager create-secret "${create_args[@]}" 2>/dev/null); then + log error "❌ Failed to store parameter in AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:CreateSecret for $SECRET_NAME" + log error " • Same-name secret is in soft-delete window (change prefix or wait)" + log error " • Region '$AWS_REGION' unreachable" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error " • Check policy: aws iam simulate-principal-policy --action-names secretsmanager:CreateSecret" + exit 1 +fi + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg secret_arn "$SECRET_ARN" \ + --arg secret_name "$SECRET_NAME" \ + --arg region "$AWS_REGION" \ + '{external_id: $external_id, metadata: {secret_arn: $secret_arn, secret_name: $secret_name, region: $region}}' diff --git a/parameters/retrieve b/parameters/retrieve new file mode 100755 index 00000000..3d400379 --- /dev/null +++ b/parameters/retrieve @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +# Dispatch: delegate to the active provider's retrieve implementation. +# PROVIDER_DIR is exported by build_context. +source "$PROVIDER_DIR/retrieve" diff --git a/parameters/store b/parameters/store new file mode 100755 index 00000000..338a2c8c --- /dev/null +++ b/parameters/store @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +# Dispatch: delegate to the active provider's store implementation. +# PROVIDER_DIR is exported by build_context. +source "$PROVIDER_DIR/store" diff --git a/parameters/tests/build_context.bats b/parameters/tests/build_context.bats new file mode 100644 index 00000000..1417e30a --- /dev/null +++ b/parameters/tests/build_context.bats @@ -0,0 +1,199 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/build_context — provider resolution + sourcing +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/build_context" + export TEST_PROVIDER_DIR="$PARAMETERS_DIR/providers/test_provider" + + # Sensible CONTEXT default (a secret payload). Individual tests can override. + export CONTEXT='{"external_id":"ext-123","parameter_id":42,"value":"my-val","parameter_name":"DB_PASS","encoding":"plain","secret":true}' +} + +teardown() { + rm -rf "$TEST_PROVIDER_DIR" + unset PARAMETER_KIND ACTIVE_PROVIDER PROVIDER_DIR PARAMETERS_ROOT + unset SECRET_PROVIDER PARAMETER_PROVIDER + unset EXTERNAL_ID PARAMETER_ID PARAMETER_VALUE PARAMETER_NAME PARAMETER_ENCODING + unset PROVIDER_CONFIG +} + +@test "build_context: extracts notification fields and exports them" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo EID=\$EXTERNAL_ID PID=\$PARAMETER_ID VAL=\$PARAMETER_VALUE NAME=\$PARAMETER_NAME ENC=\$PARAMETER_ENCODING" + + assert_equal "$status" "0" + assert_contains "$output" "EID=ext-123" + assert_contains "$output" "PID=42" + assert_contains "$output" "VAL=my-val" + assert_contains "$output" "NAME=DB_PASS" + assert_contains "$output" "ENC=plain" +} + +@test "build_context: secret kind selects SECRET_PROVIDER" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER KIND=\$PARAMETER_KIND" + + assert_equal "$status" "0" + assert_contains "$output" "PROV=test_provider" + assert_contains "$output" "KIND=secret" +} + +@test "build_context: parameter kind selects PARAMETER_PROVIDER" { + export CONTEXT='{"external_id":"e","secret":false}' + export PARAMETER_KIND="parameter" + export PARAMETER_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER KIND=\$PARAMETER_KIND" + + assert_equal "$status" "0" + assert_contains "$output" "PROV=test_provider" + assert_contains "$output" "KIND=parameter" +} + +@test "build_context: derives PARAMETER_KIND from CONTEXT.secret when unset" { + unset PARAMETER_KIND + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND" + + assert_equal "$status" "0" + assert_contains "$output" "KIND=secret" +} + +@test "build_context: derives parameter kind when CONTEXT.secret is false" { + export CONTEXT='{"external_id":"e","secret":false}' + unset PARAMETER_KIND + export PARAMETER_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND PROV=\$ACTIVE_PROVIDER" + + assert_equal "$status" "0" + assert_contains "$output" "KIND=parameter" + assert_contains "$output" "PROV=test_provider" +} + +@test "build_context: fails with troubleshooting when SECRET_PROVIDER is unset" { + export PARAMETER_KIND="secret" + unset SECRET_PROVIDER + + run bash -c "source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ No provider configured for kind 'secret'" + assert_contains "$output" "SECRET_PROVIDER env var is not set" + assert_contains "$output" "🔧 How to fix:" +} + +@test "build_context: fails with troubleshooting when PARAMETER_PROVIDER is unset" { + export PARAMETER_KIND="parameter" + unset PARAMETER_PROVIDER + + run bash -c "source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ No provider configured for kind 'parameter'" + assert_contains "$output" "PARAMETER_PROVIDER env var is not set" +} + +@test "build_context: fails when provider directory doesn't exist" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="nonexistent_provider" + + run bash -c "source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Provider implementation not found: 'nonexistent_provider'" + assert_contains "$output" "🔧 How to fix:" +} + +@test "build_context: sources provider fetch_configuration when present" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + echo 'export FETCH_RAN="yes"' > "$TEST_PROVIDER_DIR/fetch_configuration" + + run bash -c "source $SCRIPT && echo FETCH=\$FETCH_RAN" + + assert_equal "$status" "0" + assert_contains "$output" "FETCH=yes" +} + +@test "build_context: sources provider setup when present" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + echo 'export SETUP_RAN="yes"' > "$TEST_PROVIDER_DIR/setup" + + run bash -c "source $SCRIPT && echo SETUP=\$SETUP_RAN" + + assert_equal "$status" "0" + assert_contains "$output" "SETUP=yes" +} + +@test "build_context: sources fetch_configuration before setup" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + echo 'export ORDER="${ORDER:-}fetch,"' > "$TEST_PROVIDER_DIR/fetch_configuration" + echo 'export ORDER="${ORDER:-}setup"' > "$TEST_PROVIDER_DIR/setup" + + run bash -c "source $SCRIPT && echo ORDER=\$ORDER" + + assert_equal "$status" "0" + assert_contains "$output" "ORDER=fetch,setup" +} + +@test "build_context: succeeds when provider has no fetch_configuration or setup" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER" + + assert_equal "$status" "0" + assert_contains "$output" "PROV=test_provider" +} + +@test "build_context: exports PROVIDER_DIR and PARAMETERS_ROOT" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PD=\$PROVIDER_DIR ROOT=\$PARAMETERS_ROOT" + + assert_equal "$status" "0" + assert_contains "$output" "PD=$PARAMETERS_DIR/providers/test_provider" + assert_contains "$output" "ROOT=$PARAMETERS_DIR" +} + +@test "build_context: provider setup can read get_config_value with --provider" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + export PROVIDER_CONFIG='{"address":"https://example.com"}' + mkdir -p "$TEST_PROVIDER_DIR" + cat > "$TEST_PROVIDER_DIR/setup" << 'EOF' +ADDR=$(get_config_value --provider '.address') +export RESOLVED_ADDR="$ADDR" +EOF + + run bash -c "source $SCRIPT && echo ADDR=\$RESOLVED_ADDR" + + assert_equal "$status" "0" + assert_contains "$output" "ADDR=https://example.com" +} diff --git a/parameters/tests/delete.bats b/parameters/tests/delete.bats new file mode 100644 index 00000000..993a4fc5 --- /dev/null +++ b/parameters/tests/delete.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/delete (dispatch) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/delete" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" +} + +@test "delete: sources provider's delete and propagates stdout" { + cat > "$PROVIDER_DIR/delete" << 'EOF' +echo '{"success":true}' +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"success":true}' +} + +@test "delete: provider script sees EXTERNAL_ID env var" { + export EXTERNAL_ID="ext-to-delete" + cat > "$PROVIDER_DIR/delete" << 'EOF' +echo "{\"deleted\":\"$EXTERNAL_ID\"}" +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "ext-to-delete" +} + +@test "delete: fails when provider's delete doesn't exist" { + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} diff --git a/parameters/tests/entrypoint.bats b/parameters/tests/entrypoint.bats new file mode 100644 index 00000000..7304b009 --- /dev/null +++ b/parameters/tests/entrypoint.bats @@ -0,0 +1,156 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/entrypoint — workflow routing by action +# Kind discrimination is NOT done at this layer (see build_context.bats). +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/entrypoint" + + # Build a fake SERVICE_PATH with a mirror of parameters/workflows/ + export SERVICE_PATH="$BATS_TEST_TMPDIR/service" + mkdir -p "$SERVICE_PATH/parameters/workflows" + for wf in store retrieve delete notify; do + : > "$SERVICE_PATH/parameters/workflows/$wf.yaml" + done + + # Mock `np` so eval $CMD echoes the command to stdout instead of calling the real CLI + cat > "$BATS_TEST_TMPDIR/np" << 'EOF' +#!/bin/bash +echo "$@" +EOF + chmod +x "$BATS_TEST_TMPDIR/np" + export PATH="$BATS_TEST_TMPDIR:$PATH" +} + +teardown() { + unset NP_ACTION_CONTEXT NOTIFICATION_ACTION OVERRIDES_PATH SERVICE_PATH +} + +@test "entrypoint: fails when NP_ACTION_CONTEXT is empty" { + unset NP_ACTION_CONTEXT + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ NP_ACTION_CONTEXT is not set" +} + +@test "entrypoint: fails when NOTIFICATION_ACTION has no action part" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter" + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ NOTIFICATION_ACTION is missing the action part" +} + +@test "entrypoint: store action routes to store.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter:store" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "store.yaml" +} + +@test "entrypoint: retrieve action routes to retrieve.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true,"external_id":"abc"}}' + export NOTIFICATION_ACTION="parameter:retrieve" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "retrieve.yaml" +} + +@test "entrypoint: delete action routes to delete.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"secret":false,"external_id":"abc"}}' + export NOTIFICATION_ACTION="parameter:delete" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "delete.yaml" +} + +@test "entrypoint: notify action routes to notify.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true,"external_id":"abc"}}' + export NOTIFICATION_ACTION="parameter:notify" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "notify.yaml" +} + +@test "entrypoint: payload's .secret value does not affect routing" { + # Run with secret=true + export NOTIFICATION_ACTION="parameter:store" + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + run bash "$SCRIPT" + assert_equal "$status" "0" + output_true="$output" + + # Run with secret=false + export NP_ACTION_CONTEXT='{"notification":{"secret":false}}' + run bash "$SCRIPT" + assert_equal "$status" "0" + + # Both route to the same workflow path + assert_equal "$output" "$output_true" +} + +@test "entrypoint: fails when no matching workflow exists" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter:nonexistent" + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ No workflow found at" +} + +@test "entrypoint: strips surrounding single quotes from NP_ACTION_CONTEXT" { + export NP_ACTION_CONTEXT="'{\"notification\":{\"secret\":true}}'" + export NOTIFICATION_ACTION="parameter:store" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "store.yaml" +} + +@test "entrypoint: OVERRIDES_PATH appends --overrides for matching path" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter:store" + + mkdir -p "$BATS_TEST_TMPDIR/override1/parameters/workflows" + : > "$BATS_TEST_TMPDIR/override1/parameters/workflows/store.yaml" + export OVERRIDES_PATH="$BATS_TEST_TMPDIR/override1" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "--overrides $BATS_TEST_TMPDIR/override1/parameters/workflows/store.yaml" +} + +@test "entrypoint: OVERRIDES_PATH skips paths without the workflow file" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter:store" + + mkdir -p "$BATS_TEST_TMPDIR/empty_override" + export OVERRIDES_PATH="$BATS_TEST_TMPDIR/empty_override" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + [[ "$output" != *"--overrides $BATS_TEST_TMPDIR/empty_override"* ]] +} diff --git a/parameters/tests/notify.bats b/parameters/tests/notify.bats new file mode 100644 index 00000000..5896a964 --- /dev/null +++ b/parameters/tests/notify.bats @@ -0,0 +1,47 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/notify (dispatch with default fallback) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/notify" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" +} + +@test "notify: uses provider's notify when present" { + cat > "$PROVIDER_DIR/notify" << 'EOF' +echo '{"success":true,"provider":"fake"}' +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" '"provider":"fake"' +} + +@test "notify: falls back to default success when provider has no notify" { + # Intentionally do NOT create $PROVIDER_DIR/notify + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"success":true}' +} + +@test "notify: provider's notify failure propagates" { + cat > "$PROVIDER_DIR/notify" << 'EOF' +echo "ack failed" >&2 +exit 7 +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "7" + assert_contains "$output" "ack failed" +} diff --git a/parameters/tests/providers/azure_key_vault/delete.bats b/parameters/tests/providers/azure_key_vault/delete.bats new file mode 100644 index 00000000..81a0fe75 --- /dev/null +++ b/parameters/tests/providers/azure_key_vault/delete.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure_key_vault/delete +# Two-step: soft-delete + purge. Purge failures are warnings, not errors. +# ============================================================================= + +bats_require_minimum_version 1.5.0 + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AZ_LOG="$BATS_TEST_TMPDIR/az.log" + # The mock checks args to determine if this is `delete` or `purge`, and + # picks MOCK_DELETE_MODE / MOCK_PURGE_MODE accordingly. + cat > "$BATS_TEST_TMPDIR/bin/az" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AZ_LOG" + +# Identify which sub-command was called +sub_action="" +for arg in "$@"; do + case "$arg" in + delete) sub_action="delete" ;; + purge) sub_action="purge" ;; + esac +done + +if [ "$sub_action" = "delete" ]; then mode="${MOCK_DELETE_MODE:-success}" +elif [ "$sub_action" = "purge" ]; then mode="${MOCK_PURGE_MODE:-success}" +else mode="success"; fi + +case "$mode" in + success) ;; + not_found) + echo "(SecretNotFound) A secret with (name/id) X was not found in this key vault." >&2 + exit 3 + ;; + auth_error) + echo "(Forbidden) The user is not authorized to perform this action." >&2 + exit 1 + ;; + purge_forbidden) + echo "(Forbidden) Purge permission missing." >&2 + exit 1 + ;; + *) + echo "(InternalServerError) something went wrong." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/az" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AZ_VAULT_NAME="my-vault" + export AZ_SECRET_PREFIX="parameters-" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "azure_key_vault delete: both delete + purge succeed → {success: true}" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "azure_key_vault delete: SecretNotFound on delete is idempotent → success" { + run bash -c "$DEPS; MOCK_DELETE_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "azure_key_vault delete: delete auth_error fails with troubleshooting" { + run bash -c "$DEPS; MOCK_DELETE_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete secret" + assert_contains "$output" "lacks 'Delete' permission" +} + +@test "azure_key_vault delete: purge forbidden is downgraded to warning, still returns success" { + run --separate-stderr bash -c "$DEPS; MOCK_PURGE_MODE=purge_forbidden source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" + assert_contains "$stderr" "⚠️" + assert_contains "$stderr" "Purge permission missing" +} + +@test "azure_key_vault delete: purge other failure is warning, still success" { + run --separate-stderr bash -c "$DEPS; MOCK_PURGE_MODE=other source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" + assert_contains "$stderr" "⚠️ Purge failed" +} + +@test "azure_key_vault delete: calls both delete and purge sub-commands" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret delete" + assert_contains "$captured" "keyvault secret purge" + assert_contains "$captured" "--name parameters-abc-123" +} + +@test "azure_key_vault delete: skips purge if delete returned not_found" { + run bash -c "$DEPS; MOCK_DELETE_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret delete" + # Purge should NOT have been called since delete already said "not found" + [[ "$captured" != *"keyvault secret purge"* ]] +} diff --git a/parameters/tests/providers/azure_key_vault/retrieve.bats b/parameters/tests/providers/azure_key_vault/retrieve.bats new file mode 100644 index 00000000..89f3f0aa --- /dev/null +++ b/parameters/tests/providers/azure_key_vault/retrieve.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure_key_vault/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AZ_LOG="$BATS_TEST_TMPDIR/az.log" + cat > "$BATS_TEST_TMPDIR/bin/az" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AZ_LOG" +case "${MOCK_AZ_MODE:-success}" in + success) + echo "the-stored-value" + ;; + not_found) + echo "(SecretNotFound) A secret with (name/id) X was not found in this key vault." >&2 + exit 3 + ;; + auth_error) + echo "(Forbidden) The user is not authorized to perform this action." >&2 + exit 1 + ;; + *) + echo "(InternalServerError) something went wrong." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/az" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AZ_VAULT_NAME="my-vault" + export AZ_SECRET_PREFIX="parameters-" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "azure_key_vault retrieve: success → returns value" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-stored-value" +} + +@test "azure_key_vault retrieve: SecretNotFound → 'value not found'" { + run bash -c "$DEPS; MOCK_AZ_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "value not found" +} + +@test "azure_key_vault retrieve: auth_error fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AZ_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" + assert_contains "$output" "lacks 'Get' permission" +} + +@test "azure_key_vault retrieve: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AZ_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" +} + +@test "azure_key_vault retrieve: calls az keyvault secret show" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret show" + assert_contains "$captured" "--vault-name my-vault" + assert_contains "$captured" "--name parameters-abc-123" + assert_contains "$captured" "--query value" +} diff --git a/parameters/tests/providers/azure_key_vault/setup.bats b/parameters/tests/providers/azure_key_vault/setup.bats new file mode 100644 index 00000000..711db077 --- /dev/null +++ b/parameters/tests/providers/azure_key_vault/setup.bats @@ -0,0 +1,69 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure_key_vault/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset AZURE_KEY_VAULT_NAME AZURE_KEY_VAULT_SECRET_PREFIX AZ_VAULT_NAME AZ_SECRET_PREFIX PROVIDER_CONFIG +} + +@test "azure_key_vault setup: fails when vault name is missing" { + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Azure Key Vault name not configured" + assert_contains "$output" "🔧 How to fix:" +} + +@test "azure_key_vault setup: succeeds with vault name from env" { + export AZURE_KEY_VAULT_NAME="my-vault" + + run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME PREFIX=\$AZ_SECRET_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "VAULT=my-vault" + assert_contains "$output" "PREFIX=parameters-" +} + +@test "azure_key_vault setup: PROVIDER_CONFIG wins over env" { + export AZURE_KEY_VAULT_NAME="env-vault" + export PROVIDER_CONFIG='{"vault_name":"cfg-vault","secret_prefix":"app-secret-"}' + + run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME PREFIX=\$AZ_SECRET_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "VAULT=cfg-vault" + assert_contains "$output" "PREFIX=app-secret-" +} + +@test "azure_key_vault setup: rejects prefix with invalid characters" { + export AZURE_KEY_VAULT_NAME="my-vault" + export AZURE_KEY_VAULT_SECRET_PREFIX="invalid_prefix/" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Invalid AZ_SECRET_PREFIX 'invalid_prefix/'" + assert_contains "$output" "alphanumerics and dashes" +} + +@test "azure_key_vault setup: accepts empty prefix" { + export AZURE_KEY_VAULT_NAME="my-vault" + export AZURE_KEY_VAULT_SECRET_PREFIX="" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=[\$AZ_SECRET_PREFIX]" + + assert_equal "$status" "0" + # Empty env should fall through to default 'parameters-' + assert_contains "$output" "PREFIX=[parameters-]" +} diff --git a/parameters/tests/providers/azure_key_vault/store.bats b/parameters/tests/providers/azure_key_vault/store.bats new file mode 100644 index 00000000..24ef09e3 --- /dev/null +++ b/parameters/tests/providers/azure_key_vault/store.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure_key_vault/store +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' +#!/bin/bash +echo "fixed-akv-uuid" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + + export AZ_LOG="$BATS_TEST_TMPDIR/az.log" + cat > "$BATS_TEST_TMPDIR/bin/az" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$AZ_LOG" +if [ "\${MOCK_AZ_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AZ_EXIT; fi +echo "https://my-vault.vault.azure.net/secrets/parameters-fixed-akv-uuid/abc123def456" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/az" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AZ_VAULT_NAME="my-vault" + export AZ_SECRET_PREFIX="parameters-" + export PARAMETER_VALUE="my-secret-value" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "azure_key_vault store: outputs external_id and metadata" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + secret_name=$(echo "$output" | jq -r '.metadata.secret_name') + azure_secret_id=$(echo "$output" | jq -r '.metadata.azure_secret_id') + vault_name=$(echo "$output" | jq -r '.metadata.vault_name') + assert_equal "$external_id" "fixed-akv-uuid" + assert_equal "$secret_name" "parameters-fixed-akv-uuid" + assert_contains "$azure_secret_id" "vault.azure.net/secrets/parameters-fixed-akv-uuid" + assert_equal "$vault_name" "my-vault" +} + +@test "azure_key_vault store: calls az keyvault secret set" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret set" + assert_contains "$captured" "--vault-name my-vault" + assert_contains "$captured" "--name parameters-fixed-akv-uuid" + assert_contains "$captured" "--value my-secret-value" +} + +@test "azure_key_vault store: honors custom AZ_SECRET_PREFIX" { + export AZ_SECRET_PREFIX="app-prod-" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "--name app-prod-fixed-akv-uuid" +} + +@test "azure_key_vault store: fails with troubleshooting on az error" { + run bash -c "$DEPS; MOCK_AZ_EXIT=1 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store secret in Azure Key Vault 'my-vault'" + assert_contains "$output" "💡 Possible causes:" +} diff --git a/parameters/tests/providers/hashicorp_vault/delete.bats b/parameters/tests/providers/hashicorp_vault/delete.bats new file mode 100644 index 00000000..effc3207 --- /dev/null +++ b/parameters/tests/providers/hashicorp_vault/delete.bats @@ -0,0 +1,103 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp_vault/delete +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" + cat > "$BATS_TEST_TMPDIR/bin/curl" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$CURL_LOG" +if [ "${MOCK_CURL_MODE:-success}" = "network_error" ]; then exit 6; fi +want_status=0 +for arg in "$@"; do + if [ "$arg" = "-w" ]; then want_status=1; break; fi +done +if [ -n "${MOCK_HTTP_BODY:-}" ]; then printf "%s" "$MOCK_HTTP_BODY"; fi +if [ "$want_status" = "1" ]; then printf "\n%s" "${MOCK_HTTP_STATUS:-204}"; fi +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.test-token" + export VAULT_PATH_PREFIX="secret/data/parameters" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "vault delete: 204 returns {success: true}" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "vault delete: 200 returns {success: true}" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "vault delete: 404 is idempotent — returns success" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=404 source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "vault delete: 403 fails with auth troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=403 MOCK_HTTP_BODY='{\"errors\":[\"permission denied\"]}' source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault DELETE failed with HTTP 403" + assert_contains "$output" "lacks delete permission" + assert_contains "$output" "🔧 How to fix:" +} + +@test "vault delete: 500 fails with server troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=500 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault DELETE failed with HTTP 500" + assert_contains "$output" "Server-side error" +} + +@test "vault delete: network error fails with connectivity troubleshooting" { + run bash -c "$DEPS; MOCK_CURL_MODE=network_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Network error calling Vault" + assert_contains "$output" "unreachable" +} + +@test "vault delete: DELETEs the correct Vault URL with token header" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-X DELETE" + assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/abc-123" +} + +@test "vault delete: honors custom VAULT_PATH_PREFIX" { + export VAULT_PATH_PREFIX="kv/data/custom-mount" + + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/kv/data/custom-mount/abc-123" +} diff --git a/parameters/tests/providers/hashicorp_vault/retrieve.bats b/parameters/tests/providers/hashicorp_vault/retrieve.bats new file mode 100644 index 00000000..3f4797a5 --- /dev/null +++ b/parameters/tests/providers/hashicorp_vault/retrieve.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp_vault/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" + cat > "$BATS_TEST_TMPDIR/bin/curl" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$CURL_LOG" +if [ "${MOCK_CURL_MODE:-success}" = "network_error" ]; then exit 6; fi +want_status=0 +for arg in "$@"; do + if [ "$arg" = "-w" ]; then want_status=1; break; fi +done +if [ -n "${MOCK_HTTP_BODY:-}" ]; then printf "%s" "$MOCK_HTTP_BODY"; fi +if [ "$want_status" = "1" ]; then printf "\n%s" "${MOCK_HTTP_STATUS:-200}"; fi +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.test-token" + export VAULT_PATH_PREFIX="secret/data/parameters" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "vault retrieve: 200 returns stored value" { + body='{"data":{"data":{"value":"the-real-secret","parameter_id":42}}}' + + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-real-secret" +} + +@test "vault retrieve: 404 returns 'value not found'" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=404 source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "value not found" +} + +@test "vault retrieve: 403 fails with auth troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=403 MOCK_HTTP_BODY='{\"errors\":[\"permission denied\"]}' source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault GET failed with HTTP 403" + assert_contains "$output" "lacks read permission" +} + +@test "vault retrieve: 500 fails with server troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=500 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault GET failed with HTTP 500" +} + +@test "vault retrieve: network error fails with connectivity troubleshooting" { + run bash -c "$DEPS; MOCK_CURL_MODE=network_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Network error calling Vault" +} + +@test "vault retrieve: GETs the correct Vault URL with token header" { + body='{"data":{"data":{"value":"x"}}}' + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/abc-123" +} + +@test "vault retrieve: honors custom VAULT_PATH_PREFIX" { + export VAULT_PATH_PREFIX="kv/data/custom-mount" + body='{"data":{"data":{"value":"x"}}}' + + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/kv/data/custom-mount/abc-123" +} diff --git a/parameters/tests/providers/hashicorp_vault/setup.bats b/parameters/tests/providers/hashicorp_vault/setup.bats new file mode 100644 index 00000000..13fe0707 --- /dev/null +++ b/parameters/tests/providers/hashicorp_vault/setup.bats @@ -0,0 +1,84 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp_vault/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset VAULT_ADDR VAULT_TOKEN VAULT_PATH_PREFIX PROVIDER_CONFIG +} + +@test "vault setup: fails with troubleshooting when VAULT_ADDR is missing" { + unset VAULT_ADDR + export VAULT_TOKEN="hvs.xxx" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault address not configured" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "vault setup: fails with troubleshooting when VAULT_TOKEN is missing" { + export VAULT_ADDR="https://vault.example.com" + unset VAULT_TOKEN + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault token not configured" +} + +@test "vault setup: succeeds with both env vars set, exports them" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.xxx" + + run bash -c "$DEPS; source $SCRIPT && echo ADDR=\$VAULT_ADDR TOKEN=\$VAULT_TOKEN PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "ADDR=https://vault.example.com" + assert_contains "$output" "TOKEN=hvs.xxx" + assert_contains "$output" "PREFIX=secret/data/parameters" +} + +@test "vault setup: PROVIDER_CONFIG wins over env var" { + export VAULT_ADDR="https://env-vault.com" + export VAULT_TOKEN="env-token" + export PROVIDER_CONFIG='{"address":"https://provider-vault.com","token":"provider-token"}' + + run bash -c "$DEPS; source $SCRIPT && echo ADDR=\$VAULT_ADDR TOKEN=\$VAULT_TOKEN" + + assert_equal "$status" "0" + assert_contains "$output" "ADDR=https://provider-vault.com" + assert_contains "$output" "TOKEN=provider-token" +} + +@test "vault setup: custom path_prefix from PROVIDER_CONFIG" { + export PROVIDER_CONFIG='{"address":"https://v.com","token":"t","path_prefix":"kv/data/custom"}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=kv/data/custom" +} + +@test "vault setup: reads VAULT_PATH_PREFIX from env if PROVIDER_CONFIG has no path_prefix" { + export VAULT_ADDR="https://v.com" + export VAULT_TOKEN="t" + export VAULT_PATH_PREFIX="kv/data/from-env" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=kv/data/from-env" +} diff --git a/parameters/tests/providers/hashicorp_vault/store.bats b/parameters/tests/providers/hashicorp_vault/store.bats new file mode 100644 index 00000000..d7a50025 --- /dev/null +++ b/parameters/tests/providers/hashicorp_vault/store.bats @@ -0,0 +1,110 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp_vault/store +# Verifies HTTP request shape AND JSON output remain byte-compatible with +# the previous parameters/vault/store implementation. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/store" + + # Mock uuidgen for deterministic external_id + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' +#!/bin/bash +echo "fixed-test-uuid" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + + # Mock curl: capture args to file, return success by default + export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" + cat > "$BATS_TEST_TMPDIR/bin/curl" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$CURL_LOG" +exit \${MOCK_CURL_EXIT:-0} +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + # Defaults from setup() — operation script assumes these are present + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.test-token" + export VAULT_PATH_PREFIX="secret/data/parameters" + export PARAMETER_ID=42 + export PARAMETER_VALUE="my-super-secret" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "vault store: outputs JSON with external_id and vault_path metadata" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + # Parse with jq to be robust against whitespace + external_id=$(echo "$output" | jq -r '.external_id') + vault_path=$(echo "$output" | jq -r '.metadata.vault_path') + assert_equal "$external_id" "fixed-test-uuid" + assert_equal "$vault_path" "secret/data/parameters/fixed-test-uuid" +} + +@test "vault store: POSTs to correct Vault URL with token header" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-X POST" + assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/fixed-test-uuid" +} + +@test "vault store: POST body contains parameter_id, value, external_id, stored_at" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" '"parameter_id":42' + assert_contains "$captured" '"value":"my-super-secret"' + assert_contains "$captured" '"external_id":"fixed-test-uuid"' + assert_contains "$captured" '"stored_at":"' +} + +@test "vault store: fails with troubleshooting when curl returns non-zero" { + export MOCK_CURL_EXIT=22 + + run bash -c "$DEPS; MOCK_CURL_EXIT=22 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in Vault at https://vault.example.com" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "vault store: honors custom VAULT_PATH_PREFIX" { + export VAULT_PATH_PREFIX="kv/data/custom-mount" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + vault_path=$(echo "$output" | jq -r '.metadata.vault_path') + assert_equal "$vault_path" "kv/data/custom-mount/fixed-test-uuid" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/kv/data/custom-mount/fixed-test-uuid" +} + +@test "vault store: jq-escapes the value so quotes inside don't break the body" { + export PARAMETER_VALUE='val"with"quotes' + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + # jq -R turns the literal value into "val\"with\"quotes" — escaped + assert_contains "$captured" 'val\"with\"quotes' +} diff --git a/parameters/tests/providers/parameter_store/delete.bats b/parameters/tests/providers/parameter_store/delete.bats new file mode 100644 index 00000000..c4c7cec5 --- /dev/null +++ b/parameters/tests/providers/parameter_store/delete.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/parameter_store/delete +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) ;; + not_found) + echo "An error occurred (ParameterNotFound) when calling the DeleteParameter operation: Parameter not found." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the DeleteParameter operation." >&2 + exit 254 + ;; + *) + echo "An error occurred (InternalServerError)." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/nullplatform/parameters/" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "parameter_store delete: success → {success: true}" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "parameter_store delete: ParameterNotFound is idempotent → success" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "parameter_store delete: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete parameter" + assert_contains "$output" "lacks ssm:DeleteParameter" +} + +@test "parameter_store delete: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete parameter" +} + +@test "parameter_store delete: calls aws ssm delete-parameter with name" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "ssm delete-parameter" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name /nullplatform/parameters/abc-123" +} diff --git a/parameters/tests/providers/parameter_store/retrieve.bats b/parameters/tests/providers/parameter_store/retrieve.bats new file mode 100644 index 00000000..dce76d7f --- /dev/null +++ b/parameters/tests/providers/parameter_store/retrieve.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/parameter_store/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) + echo "the-real-value" + ;; + not_found) + echo "An error occurred (ParameterNotFound) when calling the GetParameter operation." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the GetParameter operation." >&2 + exit 254 + ;; + *) + echo "An error occurred (InternalServerError)." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/nullplatform/parameters/" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "parameter_store retrieve: success → returns value" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-real-value" +} + +@test "parameter_store retrieve: ParameterNotFound → 'value not found'" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "value not found" +} + +@test "parameter_store retrieve: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve parameter" + assert_contains "$output" "lacks ssm:GetParameter" +} + +@test "parameter_store retrieve: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve parameter" +} + +@test "parameter_store retrieve: calls aws with --with-decryption" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "ssm get-parameter" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name /nullplatform/parameters/abc-123" + assert_contains "$captured" "--with-decryption" +} diff --git a/parameters/tests/providers/parameter_store/setup.bats b/parameters/tests/providers/parameter_store/setup.bats new file mode 100644 index 00000000..f04b9691 --- /dev/null +++ b/parameters/tests/providers/parameter_store/setup.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/parameter_store/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset AWS_REGION AWS_DEFAULT_REGION PS_NAME_PREFIX PS_KMS_KEY_ID PS_TIER PROVIDER_CONFIG +} + +@test "parameter_store setup: fails when AWS_REGION is missing" { + unset AWS_REGION AWS_DEFAULT_REGION + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ AWS region not configured for parameter_store" +} + +@test "parameter_store setup: default name_prefix has leading and trailing slash" { + export AWS_REGION="us-east-1" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=/nullplatform/parameters/" +} + +@test "parameter_store setup: normalizes prefix without leading slash" { + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="custom/path/" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=/custom/path/" +} + +@test "parameter_store setup: normalizes prefix without trailing slash" { + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/custom/path" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=/custom/path/" +} + +@test "parameter_store setup: default tier is Standard" { + export AWS_REGION="us-east-1" + + run bash -c "$DEPS; source $SCRIPT && echo TIER=\$PS_TIER" + + assert_equal "$status" "0" + assert_contains "$output" "TIER=Standard" +} + +@test "parameter_store setup: accepts Advanced tier" { + export AWS_REGION="us-east-1" + export PS_TIER="Advanced" + + run bash -c "$DEPS; source $SCRIPT && echo TIER=\$PS_TIER" + + assert_equal "$status" "0" + assert_contains "$output" "TIER=Advanced" +} + +@test "parameter_store setup: rejects invalid tier with troubleshooting" { + export AWS_REGION="us-east-1" + export PS_TIER="Bogus" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Invalid PS_TIER 'Bogus'" + assert_contains "$output" "Standard, Advanced, Intelligent-Tiering" +} + +@test "parameter_store setup: PROVIDER_CONFIG wins over env" { + export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"region":"eu-west-1","name_prefix":"/cfg/path/","kms_key_id":"alias/cfg","tier":"Advanced"}' + + run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION PREFIX=\$PS_NAME_PREFIX KMS=\$PS_KMS_KEY_ID TIER=\$PS_TIER" + + assert_equal "$status" "0" + assert_contains "$output" "REGION=eu-west-1" + assert_contains "$output" "PREFIX=/cfg/path/" + assert_contains "$output" "KMS=alias/cfg" + assert_contains "$output" "TIER=Advanced" +} diff --git a/parameters/tests/providers/parameter_store/store.bats b/parameters/tests/providers/parameter_store/store.bats new file mode 100644 index 00000000..eda9e63e --- /dev/null +++ b/parameters/tests/providers/parameter_store/store.bats @@ -0,0 +1,139 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/parameter_store/store +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' +#!/bin/bash +echo "fixed-ps-uuid" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$AWS_LOG" +exit \${MOCK_AWS_EXIT:-0} +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/nullplatform/parameters/" + export PS_KMS_KEY_ID="" + export PS_TIER="Standard" + export PARAMETER_ID=42 + export PARAMETER_VALUE="my-value" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "parameter_store store: outputs external_id and metadata" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + parameter_name=$(echo "$output" | jq -r '.metadata.parameter_name') + type=$(echo "$output" | jq -r '.metadata.type') + assert_equal "$external_id" "fixed-ps-uuid" + assert_equal "$parameter_name" "/nullplatform/parameters/fixed-ps-uuid" + assert_equal "$type" "String" +} + +@test "parameter_store store: kind=secret uses SecureString" { + export PARAMETER_KIND="secret" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + type=$(echo "$output" | jq -r '.metadata.type') + assert_equal "$type" "SecureString" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--type SecureString" +} + +@test "parameter_store store: kind=parameter uses String" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--type String" + [[ "$captured" != *"SecureString"* ]] +} + +@test "parameter_store store: includes --key-id for SecureString when PS_KMS_KEY_ID set" { + export PARAMETER_KIND="secret" + export PS_KMS_KEY_ID="alias/parameters-secure" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--key-id alias/parameters-secure" +} + +@test "parameter_store store: omits --key-id when PS_KMS_KEY_ID is empty (uses default aws/ssm)" { + export PARAMETER_KIND="secret" + export PS_KMS_KEY_ID="" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + [[ "$captured" != *"--key-id"* ]] +} + +@test "parameter_store store: never includes --key-id for String (kind=parameter)" { + export PARAMETER_KIND="parameter" + export PS_KMS_KEY_ID="alias/should-not-be-used" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + [[ "$captured" != *"--key-id"* ]] +} + +@test "parameter_store store: passes tier flag" { + export PARAMETER_KIND="parameter" + export PS_TIER="Advanced" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--tier Advanced" +} + +@test "parameter_store store: calls put-parameter with name and value" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "ssm put-parameter" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name /nullplatform/parameters/fixed-ps-uuid" + assert_contains "$captured" "--value my-value" +} + +@test "parameter_store store: fails with troubleshooting on aws error" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in AWS Parameter Store" + assert_contains "$output" "💡 Possible causes:" +} diff --git a/parameters/tests/providers/secret_manager/delete.bats b/parameters/tests/providers/secret_manager/delete.bats new file mode 100644 index 00000000..1e103489 --- /dev/null +++ b/parameters/tests/providers/secret_manager/delete.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/secret_manager/delete +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) ;; + not_found) + echo "An error occurred (ResourceNotFoundException) when calling the DeleteSecret operation: Secret not found." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the DeleteSecret operation: User not authorized." >&2 + exit 254 + ;; + *) + echo "An error occurred (UnknownError) when calling." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export SM_NAME_PREFIX="parameters/" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "secret_manager delete: success → {success: true}" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "secret_manager delete: ResourceNotFoundException is idempotent → success" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "secret_manager delete: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete secret" + assert_contains "$output" "lacks secretsmanager:DeleteSecret" + assert_contains "$output" "AccessDeniedException" +} + +@test "secret_manager delete: unknown errors fail with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete secret" + assert_contains "$output" "🔧 How to fix:" +} + +@test "secret_manager delete: calls aws with force-delete flag" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager delete-secret" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--secret-id parameters/abc-123" + assert_contains "$captured" "--force-delete-without-recovery" +} diff --git a/parameters/tests/providers/secret_manager/retrieve.bats b/parameters/tests/providers/secret_manager/retrieve.bats new file mode 100644 index 00000000..67543946 --- /dev/null +++ b/parameters/tests/providers/secret_manager/retrieve.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/secret_manager/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) + echo '{"parameter_id":42,"value":"the-real-value","stored_at":"2026-01-01T00:00:00Z","external_id":"abc-123"}' + ;; + not_found) + echo "An error occurred (ResourceNotFoundException) when calling the GetSecretValue operation: Secret not found." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the GetSecretValue operation: User not authorized." >&2 + exit 254 + ;; + *) + echo "An error occurred (UnknownError) when calling." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export SM_NAME_PREFIX="parameters/" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "secret_manager retrieve: success → extracts .value from envelope" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-real-value" +} + +@test "secret_manager retrieve: ResourceNotFoundException → 'value not found'" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "value not found" +} + +@test "secret_manager retrieve: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" + assert_contains "$output" "lacks secretsmanager:GetSecretValue" +} + +@test "secret_manager retrieve: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" +} + +@test "secret_manager retrieve: calls aws with correct args" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager get-secret-value" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--secret-id parameters/abc-123" +} diff --git a/parameters/tests/providers/secret_manager/setup.bats b/parameters/tests/providers/secret_manager/setup.bats new file mode 100644 index 00000000..d9d7f5a4 --- /dev/null +++ b/parameters/tests/providers/secret_manager/setup.bats @@ -0,0 +1,70 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/secret_manager/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset AWS_REGION AWS_DEFAULT_REGION SM_NAME_PREFIX SM_KMS_KEY_ID PROVIDER_CONFIG +} + +@test "secret_manager setup: fails when AWS_REGION is missing" { + unset AWS_REGION AWS_DEFAULT_REGION + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ AWS region not configured for secret_manager" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "secret_manager setup: AWS_DEFAULT_REGION is honored when AWS_REGION is unset" { + unset AWS_REGION + export AWS_DEFAULT_REGION="eu-west-1" + + run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION" + + assert_equal "$status" "0" + assert_contains "$output" "REGION=eu-west-1" +} + +@test "secret_manager setup: default name_prefix is 'parameters/'" { + export AWS_REGION="us-east-1" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$SM_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=parameters/" +} + +@test "secret_manager setup: PROVIDER_CONFIG wins over env" { + export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"region":"eu-central-1","name_prefix":"custom/","kms_key_id":"alias/mykey"}' + + run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION PREFIX=\$SM_NAME_PREFIX KMS=\$SM_KMS_KEY_ID" + + assert_equal "$status" "0" + assert_contains "$output" "REGION=eu-central-1" + assert_contains "$output" "PREFIX=custom/" + assert_contains "$output" "KMS=alias/mykey" +} + +@test "secret_manager setup: kms_key_id is optional (empty when unset)" { + export AWS_REGION="us-east-1" + unset SM_KMS_KEY_ID + + run bash -c "$DEPS; source $SCRIPT && echo KMS=[\$SM_KMS_KEY_ID]" + + assert_equal "$status" "0" + assert_contains "$output" "KMS=[]" +} diff --git a/parameters/tests/providers/secret_manager/store.bats b/parameters/tests/providers/secret_manager/store.bats new file mode 100644 index 00000000..8f8612bf --- /dev/null +++ b/parameters/tests/providers/secret_manager/store.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/secret_manager/store +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' +#!/bin/bash +echo "fixed-sm-uuid" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$AWS_LOG" +if [ "\${MOCK_AWS_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AWS_EXIT; fi +# create-secret returns ARN +echo "arn:aws:secretsmanager:us-east-1:111122223333:secret:parameters/fixed-sm-uuid-AbCdEf" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export SM_NAME_PREFIX="parameters/" + export SM_KMS_KEY_ID="" + export PARAMETER_ID=42 + export PARAMETER_VALUE="my-secret" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "secret_manager store: outputs external_id and metadata" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + secret_name=$(echo "$output" | jq -r '.metadata.secret_name') + secret_arn=$(echo "$output" | jq -r '.metadata.secret_arn') + region=$(echo "$output" | jq -r '.metadata.region') + assert_equal "$external_id" "fixed-sm-uuid" + assert_equal "$secret_name" "parameters/fixed-sm-uuid" + assert_contains "$secret_arn" "arn:aws:secretsmanager" + assert_equal "$region" "us-east-1" +} + +@test "secret_manager store: calls aws secretsmanager create-secret with correct args" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager create-secret" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name parameters/fixed-sm-uuid" +} + +@test "secret_manager store: includes --kms-key-id when SM_KMS_KEY_ID is set" { + export SM_KMS_KEY_ID="alias/my-key" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--kms-key-id alias/my-key" +} + +@test "secret_manager store: omits --kms-key-id when SM_KMS_KEY_ID is empty" { + export SM_KMS_KEY_ID="" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + [[ "$captured" != *"--kms-key-id"* ]] +} + +@test "secret_manager store: fails with troubleshooting when aws CLI fails" { + run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in AWS Secrets Manager" + assert_contains "$output" "💡 Possible causes:" +} + +@test "secret_manager store: honors custom SM_NAME_PREFIX" { + export SM_NAME_PREFIX="custom-prefix/sub/" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--name custom-prefix/sub/fixed-sm-uuid" +} diff --git a/parameters/tests/retrieve.bats b/parameters/tests/retrieve.bats new file mode 100644 index 00000000..47d63dab --- /dev/null +++ b/parameters/tests/retrieve.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/retrieve (dispatch) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/retrieve" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" +} + +@test "retrieve: sources provider's retrieve and propagates stdout" { + cat > "$PROVIDER_DIR/retrieve" << 'EOF' +echo '{"value":"the-actual-value"}' +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"value":"the-actual-value"}' +} + +@test "retrieve: provider script sees EXTERNAL_ID env var" { + export EXTERNAL_ID="ext-abc-123" + cat > "$PROVIDER_DIR/retrieve" << 'EOF' +echo "{\"echoed_external_id\":\"$EXTERNAL_ID\"}" +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "ext-abc-123" +} + +@test "retrieve: fails when provider's retrieve doesn't exist" { + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} diff --git a/parameters/tests/store.bats b/parameters/tests/store.bats new file mode 100644 index 00000000..af300f77 --- /dev/null +++ b/parameters/tests/store.bats @@ -0,0 +1,53 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/store (dispatch) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/store" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" +} + +@test "store: sources provider's store and propagates stdout" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo '{"external_id":"test-id","metadata":{"k":"v"}}' +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"external_id":"test-id","metadata":{"k":"v"}}' +} + +@test "store: provider script sees PROVIDER_DIR env var" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo "{\"provider_dir\":\"$PROVIDER_DIR\"}" +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "\"provider_dir\":\"$PROVIDER_DIR\"" +} + +@test "store: fails when provider's store doesn't exist" { + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} + +@test "store: provider script error propagates exit code" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo "fatal" >&2 +exit 1 +EOF + + run bash "$SCRIPT" + [ "$status" -ne 0 ] + assert_contains "$output" "fatal" +} diff --git a/parameters/tests/utils/get_config_value.bats b/parameters/tests/utils/get_config_value.bats new file mode 100644 index 00000000..f6fcbf10 --- /dev/null +++ b/parameters/tests/utils/get_config_value.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/get_config_value +# Priority: provider config > env var > default +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + source "$PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset PROVIDER_CONFIG + unset TEST_ENV_VAR OTHER_ENV +} + +@test "get_config_value: provider wins over env var" { + export PROVIDER_CONFIG='{"address":"from-provider"}' + export TEST_ENV_VAR="from-env" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address' --default "default") + assert_equal "$result" "from-provider" +} + +@test "get_config_value: env wins when provider has no match" { + export PROVIDER_CONFIG='{"other":"value"}' + export TEST_ENV_VAR="from-env" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address' --default "default") + assert_equal "$result" "from-env" +} + +@test "get_config_value: default is last resort" { + result=$(get_config_value --env UNSET_VAR --provider '.address' --default "fallback") + assert_equal "$result" "fallback" +} + +@test "get_config_value: returns empty when no match and no default" { + result=$(get_config_value --env UNSET_VAR --provider '.address') + assert_equal "$result" "" +} + +@test "get_config_value: works without PROVIDER_CONFIG set" { + unset PROVIDER_CONFIG + export TEST_ENV_VAR="env-only" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address') + assert_equal "$result" "env-only" +} + +@test "get_config_value: multiple provider paths, first match wins" { + export PROVIDER_CONFIG='{"a":null,"b":"second"}' + + result=$(get_config_value --provider '.a' --provider '.b' --default "fallback") + assert_equal "$result" "second" +} + +@test "get_config_value: multiple env vars, first set wins" { + export TEST_ENV_VAR="" + export OTHER_ENV="other-set" + + result=$(get_config_value --env TEST_ENV_VAR --env OTHER_ENV --default "fallback") + assert_equal "$result" "other-set" +} + +@test "get_config_value: null value in PROVIDER_CONFIG is treated as missing" { + export PROVIDER_CONFIG='{"address":null}' + + result=$(get_config_value --provider '.address' --default "fallback") + assert_equal "$result" "fallback" +} + +@test "get_config_value: invalid JSON in PROVIDER_CONFIG falls through to env" { + export PROVIDER_CONFIG='not-valid-json' + export TEST_ENV_VAR="env-val" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address') + assert_equal "$result" "env-val" +} + +@test "get_config_value: nested provider path resolves correctly" { + export PROVIDER_CONFIG='{"hashicorp_vault":{"address":"https://vault.example.com"}}' + + result=$(get_config_value --provider '.hashicorp_vault.address') + assert_equal "$result" "https://vault.example.com" +} diff --git a/parameters/tests/utils/log.bats b/parameters/tests/utils/log.bats new file mode 100644 index 00000000..b4866b23 --- /dev/null +++ b/parameters/tests/utils/log.bats @@ -0,0 +1,64 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/log +# All log levels route to stderr (stdout is reserved for JSON contract). +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" +} + +teardown() { + unset -f log 2>/dev/null || true + unset LOG_LEVEL +} + +@test "log: info routes to stderr" { + source "$PARAMETERS_DIR/utils/log" + err=$(log info "hello" 2>&1 >/dev/null) + assert_equal "$err" "hello" + + # Verify stdout is empty + out=$(log info "hello" 2>/dev/null) + assert_equal "$out" "" +} + +@test "log: warn routes to stderr" { + source "$PARAMETERS_DIR/utils/log" + err=$(log warn "uh oh" 2>&1 >/dev/null) + assert_equal "$err" "uh oh" +} + +@test "log: error routes to stderr" { + source "$PARAMETERS_DIR/utils/log" + err=$(log error "boom" 2>&1 >/dev/null) + assert_equal "$err" "boom" +} + +@test "log: debug is silent by default" { + source "$PARAMETERS_DIR/utils/log" + err=$(log debug "shhh" 2>&1 >/dev/null) + assert_equal "$err" "" +} + +@test "log: debug emits to stderr when LOG_LEVEL=debug" { + export LOG_LEVEL=debug + source "$PARAMETERS_DIR/utils/log" + err=$(log debug "spoke up" 2>&1 >/dev/null) + assert_equal "$err" "spoke up" +} + +@test "log: stdout is always empty (JSON contract)" { + source "$PARAMETERS_DIR/utils/log" + out=$( + log info "info msg" + log warn "warn msg" + log error "error msg" + log debug "debug msg" + LOG_LEVEL=debug log debug "debug enabled msg" + ) + assert_equal "$out" "" +} diff --git a/parameters/utils/get_config_value b/parameters/utils/get_config_value new file mode 100755 index 00000000..7d173131 --- /dev/null +++ b/parameters/utils/get_config_value @@ -0,0 +1,56 @@ +#!/bin/bash + +# Get configuration value with priority: provider config > environment variable > default. +# Usage: get_config_value [--provider "jq.path"] ... [--env ENV_VAR] ... [--default "value"] +# +# Reads provider config from $PROVIDER_CONFIG — a JSON string scoped to the +# currently active provider. It is populated by providers//fetch_configuration +# (each provider owns its own config-fetching mechanism). The shape of +# PROVIDER_CONFIG is defined by each provider. +# +# Example (inside providers/hashicorp_vault/setup): +# VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') +# VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') + +get_config_value() { + local default_value="" + local -a providers=() + local -a env_vars=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --env) env_vars+=("${2:-}"); shift 2 ;; + --provider) providers+=("${2:-}"); shift 2 ;; + --default) default_value="${2:-}"; shift 2 ;; + *) shift ;; + esac + done + + # Priority 1: provider config + for jq_path in "${providers[@]}"; do + if [[ -n "$jq_path" && -n "${PROVIDER_CONFIG:-}" ]]; then + local v + v=$(echo "$PROVIDER_CONFIG" | jq -r "$jq_path // empty" 2>/dev/null || true) + if [[ -n "$v" && "$v" != "null" ]]; then + echo "$v" + return 0 + fi + fi + done + + # Priority 2: env vars + for env_var in "${env_vars[@]}"; do + if [[ -n "$env_var" && -n "${!env_var:-}" ]]; then + echo "${!env_var}" + return 0 + fi + done + + # Priority 3: default + if [[ -n "$default_value" ]]; then + echo "$default_value" + return 0 + fi + + echo "" +} diff --git a/parameters/utils/log b/parameters/utils/log new file mode 100755 index 00000000..09fd60c2 --- /dev/null +++ b/parameters/utils/log @@ -0,0 +1,24 @@ +#!/bin/bash + +# Minimal structured logging. +# Usage: log +# level ∈ debug | info | warn | error +# +# All levels route to STDERR. stdout is reserved for the JSON contract output +# of operation scripts (store/retrieve/delete/notify). Logging on stdout would +# corrupt the JSON parsed by the platform. +# +# Debug is silent unless LOG_LEVEL=debug. + +log() { + local level="${1:-info}" + shift || true + local msg="$*" + case "$level" in + debug) [ "${LOG_LEVEL:-info}" = "debug" ] && echo "$msg" >&2 || true ;; + info) echo "$msg" >&2 ;; + warn) echo "$msg" >&2 ;; + error) echo "$msg" >&2 ;; + *) echo "$level $msg" >&2 ;; + esac +} diff --git a/parameters/workflows/delete.yaml b/parameters/workflows/delete.yaml new file mode 100644 index 00000000..34e9e4f7 --- /dev/null +++ b/parameters/workflows/delete.yaml @@ -0,0 +1,12 @@ +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/parameters/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - name: delete + type: script + file: "$SERVICE_PATH/parameters/delete" diff --git a/parameters/workflows/notify.yaml b/parameters/workflows/notify.yaml new file mode 100644 index 00000000..b9e846cd --- /dev/null +++ b/parameters/workflows/notify.yaml @@ -0,0 +1,13 @@ +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/parameters/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: PARAMETER_ID, type: environment } + - name: notify + type: script + file: "$SERVICE_PATH/parameters/notify" diff --git a/parameters/workflows/retrieve.yaml b/parameters/workflows/retrieve.yaml new file mode 100644 index 00000000..a669885f --- /dev/null +++ b/parameters/workflows/retrieve.yaml @@ -0,0 +1,14 @@ +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/parameters/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: PARAMETER_NAME, type: environment } + - { name: PARAMETER_ENCODING, type: environment } + - name: retrieve + type: script + file: "$SERVICE_PATH/parameters/retrieve" diff --git a/parameters/workflows/store.yaml b/parameters/workflows/store.yaml new file mode 100644 index 00000000..a63146ec --- /dev/null +++ b/parameters/workflows/store.yaml @@ -0,0 +1,16 @@ +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/parameters/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: PARAMETER_ID, type: environment } + - { name: PARAMETER_VALUE, type: environment } + - { name: PARAMETER_NAME, type: environment } + - { name: PARAMETER_ENCODING, type: environment } + - name: store + type: script + file: "$SERVICE_PATH/parameters/store"