Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/actions/encrypt-env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<keys-directory>/<name>.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 `<keys-directory>/<name>.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.
19 changes: 14 additions & 5 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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$")

Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down
27 changes: 23 additions & 4 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand All @@ -208,7 +208,8 @@ asset: mainframe-rybbit.sops.env
keys:
- mainframe
env:
DOMAIN: MAINFRAME_DOMAIN
DOMAIN: ${MAINFRAME_DOMAIN}
DISABLE_SIGNUP: true
```

`targets/mainframe.yml`:
Expand Down Expand Up @@ -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/<target>.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.

---
Expand Down Expand Up @@ -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.

---

Expand Down
4 changes: 2 additions & 2 deletions apps/rybbit/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion vaults/cloudflared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ asset: cloudflared.sops.env
keys:
- heimdall
env:
TUNNEL_TOKEN: CLOUDFLARE_TUNNEL_TOKEN
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
7 changes: 4 additions & 3 deletions vaults/rybbit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions vaults/traefik.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}