diff --git a/.github/actions/encrypt-env/README.md b/.github/actions/encrypt-env/README.md index c0766e6..4198642 100644 --- a/.github/actions/encrypt-env/README.md +++ b/.github/actions/encrypt-env/README.md @@ -33,8 +33,9 @@ asset: mainframe.sops.env keys: - mainframe env: - DOMAIN: DOMAIN # output name: GitHub Secret/Variable name - TIMEZONE: TIMEZONE + DOMAIN: ${DOMAIN} # output name: ${GitHub Secret/Variable name} + TIMEZONE: ${TIMEZONE} + DISABLE_SIGNUP: true # output name: literal value, no lookup at all ``` -For each name in `keys`, it loads `/.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section. +For each name in `keys`, it loads `/.pub`. An `env:` value wrapped as `${NAME}` is a reference - looked up in Secrets first, then Variables, and the action fails if it resolves to neither. Any other value (a bare string, number, or boolean) is a literal, used as-is with no lookup and no way to fail on "missing." The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section. diff --git a/.github/actions/encrypt-env/scripts/render-env.py b/.github/actions/encrypt-env/scripts/render-env.py index 9d3d8ef..d93e8d4 100644 --- a/.github/actions/encrypt-env/scripts/render-env.py +++ b/.github/actions/encrypt-env/scripts/render-env.py @@ -10,7 +10,7 @@ import yaml ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") -SOURCE_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*$") +SOURCE_REF_RE = re.compile(r"^\$\{([A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*)\}$") KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$") @@ -75,12 +75,17 @@ def load_manifest(path): for output_name, source_name in env.items(): if not isinstance(output_name, str) or not ENV_NAME_RE.fullmatch(output_name): raise ManifestError(f"invalid output env name: {output_name!r}") - if not isinstance(source_name, str) or not SOURCE_NAME_RE.fullmatch(source_name): - raise ManifestError(f"invalid source key for {output_name}: {source_name!r}") + if isinstance(source_name, str) and source_name.startswith("${"): + if not SOURCE_REF_RE.fullmatch(source_name): + raise ManifestError(f"invalid source reference for {output_name}: {source_name!r}") + elif isinstance(source_name, (dict, list)) or source_name is None: + raise ManifestError(f"invalid literal value for {output_name}: {source_name!r}") return manifest def dotenv_value(value): + if isinstance(value, bool): + value = "true" if value else "false" value = str(value) if "\x00" in value: raise ManifestError("env values cannot contain NUL bytes") @@ -103,9 +108,13 @@ def render_env(manifest, secrets, variables): lines = [] missing = [] for output_name, source_name in manifest["env"].items(): - value = resolve_value(source_name, secrets, variables) + ref = SOURCE_REF_RE.fullmatch(source_name) if isinstance(source_name, str) else None + if ref is None: + lines.append(f"{output_name}={dotenv_value(source_name)}") + continue + value = resolve_value(ref.group(1), secrets, variables) if value is None: - missing.append(source_name) + missing.append(ref.group(1)) continue lines.append(f"{output_name}={dotenv_value(value)}") if missing: diff --git a/.github/actions/encrypt-env/tests/test_render_env.py b/.github/actions/encrypt-env/tests/test_render_env.py index b79786c..c32c3d3 100644 --- a/.github/actions/encrypt-env/tests/test_render_env.py +++ b/.github/actions/encrypt-env/tests/test_render_env.py @@ -13,7 +13,7 @@ class RenderEnvTest(unittest.TestCase): def test_render_prefers_secrets_over_variables(self): - manifest = {"env": {"DOMAIN": "DOMAIN", "TIMEZONE": "TIMEZONE"}} + manifest = {"env": {"DOMAIN": "${DOMAIN}", "TIMEZONE": "${TIMEZONE}"}} output = render_env.render_env( manifest, {"DOMAIN": "secret.example"}, @@ -23,7 +23,7 @@ def test_render_prefers_secrets_over_variables(self): self.assertIn("TIMEZONE=Europe/Berlin\n", output) def test_quotes_shell_sensitive_values(self): - output = render_env.render_env({"env": {"TOKEN": "TOKEN"}}, {"TOKEN": "hello world"}, {}) + output = render_env.render_env({"env": {"TOKEN": "${TOKEN}"}}, {"TOKEN": "hello world"}, {}) self.assertIn("TOKEN='hello world'\n", output) def test_rejects_raw_env(self): @@ -32,7 +32,26 @@ def test_rejects_raw_env(self): def test_missing_source_fails(self): with self.assertRaises(render_env.ManifestError): - render_env.render_env({"env": {"TOKEN": "TOKEN"}}, {}, {}) + render_env.render_env({"env": {"TOKEN": "${TOKEN}"}}, {}, {}) + + def test_literal_values_pass_through_without_lookup(self): + manifest = {"env": {"DISABLE_SIGNUP": True, "MAX_RETRIES": 5, "LABEL": "internal-only"}} + output = render_env.render_env(manifest, {}, {}) + self.assertIn("DISABLE_SIGNUP=true\n", output) + self.assertIn("MAX_RETRIES=5\n", output) + self.assertIn("LABEL=internal-only\n", output) + + def test_literal_false_renders_lowercase(self): + output = render_env.render_env({"env": {"FLAG": False}}, {}, {}) + self.assertIn("FLAG=false\n", output) + + def test_rejects_malformed_reference(self): + with self.assertRaises(render_env.ManifestError): + render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv:\n TOKEN: ${lowercase}\n")) + + def test_rejects_dict_or_list_literal(self): + with self.assertRaises(render_env.ManifestError): + render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv:\n TOKEN: [a, b]\n")) def test_duplicate_yaml_keys_fail(self): with tempfile.TemporaryDirectory() as directory: @@ -64,7 +83,7 @@ def test_main_writes_env_and_outputs(self): "asset: mainframe.sops.env\n" "keys: [master, server]\n" "env:\n" - " TOKEN: TOKEN\n" + " TOKEN: ${TOKEN}\n" ) old_env = os.environ.copy() os.environ.update( diff --git a/README.md b/README.md index 3a70d6f..9e1996e 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ asset: mainframe-traefik.sops.env keys: - mainframe env: - HTTP_PORT: MAINFRAME_TRAEFIK_HTTP_PORT + HTTP_PORT: ${MAINFRAME_TRAEFIK_HTTP_PORT} ``` `vaults/mainframe-rybbit.yml`: @@ -208,7 +208,8 @@ asset: mainframe-rybbit.sops.env keys: - mainframe env: - DOMAIN: MAINFRAME_DOMAIN + DOMAIN: ${MAINFRAME_DOMAIN} + DISABLE_SIGNUP: true ``` `targets/mainframe.yml`: @@ -239,6 +240,8 @@ credentials: Credential fields contain GitHub Variable/Secret names, never credential values. `app_refs` and `hosts` are YAML arrays; `apps` is a mapping from app name to that app's own `env_refs` array. Each host uses the SSH `user@host` format. `app_refs` must list at least one app bundle — flightdeck's own `apps/` catalog is just another entry, not implicit. Each app in `apps` must list at least one `env_refs` entry; `deploy/deploy.py` decrypts and concatenates all of an app's sources into that app's own `.env` on the runner, failing loud on any key collision — but only within that one app's own sources. Two different apps' vaults sharing a key (e.g. both declaring `DOMAIN`) is expected, since each app gets a separate `.env`. `credentials.secrets.sops_age_key` names the GitHub Secret holding this target's *private* age key — the one used to decrypt its vaults, matching the public key in `keys/.pub` used to encrypt them. +A vault manifest's `env:` value is either `${NAME}` (a reference — look up the GitHub Secret/Variable named `NAME`) or a bare literal (any other value, used as-is with no lookup at all — see `DISABLE_SIGNUP: true` above). Use a literal for a value that's fixed for this target but isn't a secret and doesn't need a GitHub Secret/Variable to exist just to hold it. + `load-yaml-matrix` reads every file in `vaults/` or `targets/` into a matrix — it does not validate the manifest shape. Each manifest's fields are the responsibility of whatever consumes them: `encrypt-env` re-parses and validates its own manifest from `manifest`, and the workflows calling `deploy-shared.yml` apply `path`/`keep-releases` defaults and pull `credentials.secrets`/`credentials.variables` values directly from the matrix item. --- @@ -269,10 +272,11 @@ asset: mainframe-traefik.sops.env keys: - mainframe env: - HTTP_PORT: MAINFRAME_TRAEFIK_HTTP_PORT # output name: GitHub Secret/Variable name + HTTP_PORT: ${MAINFRAME_TRAEFIK_HTTP_PORT} # output name: ${GitHub Secret/Variable name} + DISABLE_SIGNUP: true # output name: literal value, no lookup ``` -Secrets take precedence over Variables when both contain the same source key. Every source key must exist or the action fails. +Secrets take precedence over Variables when both contain the same `${...}` reference. Every reference must resolve to an existing Secret or Variable, or the action fails; literals never fail this way since there's nothing to look up. --- diff --git a/apps/rybbit/docker-compose.yml b/apps/rybbit/docker-compose.yml index c18c596..f3416ac 100644 --- a/apps/rybbit/docker-compose.yml +++ b/apps/rybbit/docker-compose.yml @@ -6,7 +6,7 @@ include: x-vault-env: &vault-env BASE_URL: https://${APP_NAME}.${DOMAIN} BETTER_AUTH_SECRET: ${SESSION_KEY} - DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false} + DISABLE_SIGNUP: ${DISABLE_SIGNUP} DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-true} MAPBOX_TOKEN: ${MAPBOX_TOKEN:-} POSTGRES_PASSWORD: ${DATABASE_PASSWORD} @@ -35,7 +35,7 @@ services: environment: NODE_ENV: production NEXT_PUBLIC_BACKEND_URL: https://${APP_NAME}.${DOMAIN} - NEXT_PUBLIC_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false} + NEXT_PUBLIC_DISABLE_SIGNUP: ${DISABLE_SIGNUP} depends_on: - backend healthcheck: diff --git a/vaults/cloudflared.yml b/vaults/cloudflared.yml index 8d49978..334a249 100644 --- a/vaults/cloudflared.yml +++ b/vaults/cloudflared.yml @@ -2,4 +2,4 @@ asset: cloudflared.sops.env keys: - heimdall env: - TUNNEL_TOKEN: CLOUDFLARE_TUNNEL_TOKEN + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN} diff --git a/vaults/rybbit.yml b/vaults/rybbit.yml index 43ed78e..7271a51 100644 --- a/vaults/rybbit.yml +++ b/vaults/rybbit.yml @@ -2,6 +2,7 @@ asset: rybbit.sops.env keys: - heimdall env: - DOMAIN: DOMAIN - DATABASE_PASSWORD: DATABASE_PASSWORD - SESSION_KEY: SESSION_KEY + DOMAIN: ${DOMAIN} + DATABASE_PASSWORD: ${DATABASE_PASSWORD} + SESSION_KEY: ${SESSION_KEY} + DISABLE_SIGNUP: true diff --git a/vaults/traefik.yml b/vaults/traefik.yml index e0e39d8..1d7e147 100644 --- a/vaults/traefik.yml +++ b/vaults/traefik.yml @@ -2,8 +2,8 @@ asset: traefik.sops.env keys: - heimdall env: - ADMIN_MAIL: ADMIN_MAIL - DNS_CHALLENGE_PROVIDER: DNS_CHALLENGE_PROVIDER - DNS_CHALLENGE_TOKEN: CLOUDFLARE_DNS_API_TOKEN - HTTP_PORT: TRAEFIK_HTTP_PORT - HTTPS_PORT: TRAEFIK_HTTPS_PORT + ADMIN_MAIL: ${ADMIN_MAIL} + DNS_CHALLENGE_PROVIDER: ${DNS_CHALLENGE_PROVIDER} + DNS_CHALLENGE_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN} + HTTP_PORT: ${TRAEFIK_HTTP_PORT} + HTTPS_PORT: ${TRAEFIK_HTTPS_PORT}