From 673c6e0866c38432579f11ed3ca07bca9ac4b910 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Tue, 25 Aug 2026 22:31:02 +0200 Subject: [PATCH 1/2] Revert "feat: make a vault manifest's env: optional (#158)" This reverts commit 04b33ff12d1cfb228bf5481360b97cbc246d1815. --- .github/actions/encrypt-env/README.md | 2 +- .../actions/encrypt-env/scripts/render-env.py | 9 ++--- .../encrypt-env/tests/test_render_env.py | 39 ------------------- README.md | 2 - 4 files changed, 4 insertions(+), 48 deletions(-) diff --git a/.github/actions/encrypt-env/README.md b/.github/actions/encrypt-env/README.md index 8c168d4..4198642 100644 --- a/.github/actions/encrypt-env/README.md +++ b/.github/actions/encrypt-env/README.md @@ -38,4 +38,4 @@ env: DISABLE_SIGNUP: true # output name: literal value, no lookup at all ``` -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." `env:` can be empty (`env: {}`) or omitted entirely for an app that needs zero vault-sourced values - it just renders an empty `.env`. 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 e6ae03a..d93e8d4 100644 --- a/.github/actions/encrypt-env/scripts/render-env.py +++ b/.github/actions/encrypt-env/scripts/render-env.py @@ -62,14 +62,13 @@ def load_manifest(path): raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown)) asset = manifest.get("asset") keys = manifest.get("keys") - env = manifest.get("env") or {} - manifest["env"] = env + env = manifest.get("env") if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset): raise ManifestError("asset must be named like server.sops.env") if not isinstance(keys, list) or not keys: raise ManifestError("keys must be a non-empty list") - if not isinstance(env, dict): - raise ManifestError("env must be a mapping") + if not isinstance(env, dict) or not env: + raise ManifestError("env must be a non-empty mapping") for key in keys: if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key): raise ManifestError(f"invalid key name: {key!r}") @@ -120,8 +119,6 @@ def render_env(manifest, secrets, variables): lines.append(f"{output_name}={dotenv_value(value)}") if missing: raise ManifestError("missing GitHub Secrets/Variables: " + ", ".join(sorted(missing))) - if not lines: - return "" return "\n".join(lines) + "\n" diff --git a/.github/actions/encrypt-env/tests/test_render_env.py b/.github/actions/encrypt-env/tests/test_render_env.py index 7121490..c32c3d3 100644 --- a/.github/actions/encrypt-env/tests/test_render_env.py +++ b/.github/actions/encrypt-env/tests/test_render_env.py @@ -53,20 +53,6 @@ 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_allows_omitted_env(self): - manifest = render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\n")) - self.assertEqual(manifest["env"], {}) - self.assertEqual(render_env.render_env(manifest, {}, {}), "") - - def test_allows_empty_env_mapping(self): - manifest = render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv: {}\n")) - self.assertEqual(manifest["env"], {}) - self.assertEqual(render_env.render_env(manifest, {}, {}), "") - - def test_rejects_non_mapping_env(self): - with self.assertRaises(render_env.ManifestError): - render_env.load_manifest(self.write_manifest("asset: test.sops.env\nkeys: [k]\nenv: [a, b]\n")) - def test_duplicate_yaml_keys_fail(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "manifest.yml" @@ -118,30 +104,5 @@ def test_main_writes_env_and_outputs(self): self.assertIn("keys=master,server\n", outputs_path.read_text()) - def test_main_writes_empty_env_for_vault_less_app(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - manifest_path = root / "manifest.yml" - env_path = root / ".env" - outputs_path = root / "outputs" - manifest_path.write_text("asset: beszel.sops.env\nkeys: [heimdall]\n") - old_env = os.environ.copy() - os.environ.update( - { - "GITHUB_SECRETS_JSON": "{}", - "GITHUB_VARS_JSON": "{}", - "GITHUB_OUTPUT": str(outputs_path), - } - ) - try: - result = render_env.main(["--manifest", str(manifest_path), "--output", str(env_path)]) - finally: - os.environ.clear() - os.environ.update(old_env) - self.assertEqual(result, 0) - self.assertEqual(env_path.read_text(), "") - self.assertIn("asset=beszel.sops.env\n", outputs_path.read_text()) - - if __name__ == "__main__": unittest.main() diff --git a/README.md b/README.md index 4c24b6d..48a2026 100644 --- a/README.md +++ b/README.md @@ -279,8 +279,6 @@ env: 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. -`env:` can be empty or omitted entirely for an app that genuinely needs zero vault-sourced values (e.g. its `docker-compose.yml` references no `${VAR}` placeholders at all - config lives in a mounted volume, or first-run setup happens through the app's own UI). The vault manifest is still required (`app..env_refs` must reference at least one), it just renders an empty `.env`. - --- ### `build-bundle` From d4e46285488c29cacf5c90c53d05fbb7e6b59bd4 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Tue, 25 Aug 2026 22:32:35 +0200 Subject: [PATCH 2/2] feat: let an app skip vaults entirely instead of using an empty one An empty vault (env: {}) has nothing to encrypt and gains nothing over just not attaching one - env_refs on a target's app entry is now optional, and deploy.py skips vault download/decryption for it. --- AGENTS.md | 4 ++-- README.md | 3 ++- deploy/deploy.py | 2 +- deploy/tests/test_deploy.py | 20 ++++++++++++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44e7278..193c314 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Standard shape used throughout the catalog: `start_period: 30s`, `interval: 30s` ### Environment Variable System -There is no root `.env` anywhere - not on a target host, not locally. Each app's env comes entirely from that app's own vault(s), declared in `targets/{target}.yml`'s `apps..env_refs` (see README's "Vaults And Targets"). `deploy/deploy.py` runs on the GitHub Actions runner: it downloads each app's still-encrypted vault assets, checks their key names for collisions from the ciphertext directly (no decryption needed for that check), decrypts them with the target's private SOPS age key, concatenates the plaintext, and writes it straight into that app's `.env` in the release tree before pushing. `deploy/render.py`'s `render_template` then does the same substitution `envsubst` would, also on the runner, for that app's `*.tpl` files, using the just-decrypted values (see "Config Templates" below). +There is no root `.env` anywhere - not on a target host, not locally. Each app's env comes entirely from that app's own vault(s), declared in `targets/{target}.yml`'s `apps..env_refs` (see README's "Vaults And Targets"). `env_refs` is optional - an app with no vault-sourced values at all (e.g. `apps/beszel/docker-compose.yml`, which reads nothing but `APP_NAME`/`DATA_DIR`) just omits it, rather than pointing at a vault manifest with an empty `env:`. `deploy/deploy.py` runs on the GitHub Actions runner: it downloads each app's still-encrypted vault assets, checks their key names for collisions from the ciphertext directly (no decryption needed for that check), decrypts them with the target's private SOPS age key, concatenates the plaintext, and writes it straight into that app's `.env` in the release tree before pushing. `deploy/render.py`'s `render_template` then does the same substitution `envsubst` would, also on the runner, for that app's `*.tpl` files, using the just-decrypted values (see "Config Templates" below). A vault declares the exact final variable name an app receives directly (e.g. `HTTP_PORT`, not `TRAEFIK_HTTP_PORT`) - there is no automatic prefix-stripping or filtering step anywhere. Variables for one app are never visible to another app, since each app's `.env` is built from that app's own vault(s) only. This allows running docker compose directly from the app folder without any `--env-file` flags while keeping app secrets scoped. @@ -402,7 +402,7 @@ The `releases/{timestamp}`/`current` symlink pattern exists for atomicity, not f App bundles listed in `app_refs` are release assets referenced as short refs like `/@latest` or `/@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved through GitHub's latest release API. Every bundle must contain an `apps/` directory; both per-app directories and shared top-level files (`common.yml`, `networks.yml`, etc.) merge the same way — copy if new, fail loud on any name conflict across bundles. -Each app in a target's `apps` mapping lists its own `env_refs` — release refs the same shape as app bundles, with no default asset name (every entry must specify an explicit `:asset-name` suffix, since there's no single obvious default under a per-app model). See "Environment Variable System" above for how these get resolved, checked for collisions, and decrypted. +Each app in a target's `apps` mapping may list its own `env_refs` — release refs the same shape as app bundles, with no default asset name (every entry must specify an explicit `:asset-name` suffix, since there's no single obvious default under a per-app model). Omit `env_refs` entirely for an app with no vault-sourced env. See "Environment Variable System" above for how these get resolved, checked for collisions, and decrypted. ## Notable App Configurations diff --git a/README.md b/README.md index 48a2026..481d525 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ apps: rybbit: env_refs: - owner/config@latest:mainframe-rybbit.sops.env + beszel: {} # no vault-sourced env at all hosts: - deploy@app1.example.com - deploy@app2.example.com @@ -239,7 +240,7 @@ credentials: sops_age_key: MAINFRAME_AGE_PRIVATE_KEY ``` -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. +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. `env_refs` is optional — omit it (or leave it `[]`) for an app that genuinely needs zero vault-sourced values (e.g. `beszel` above); it still gets a `.env` with `APP_NAME`/`DATA_DIR`, just no vault is fetched or decrypted for it. Don't create a vault manifest with an empty `env:` just to satisfy this field - there's nothing to encrypt, so there's nothing to gain from one. When `env_refs` is given, it must be non-empty; `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. diff --git a/deploy/deploy.py b/deploy/deploy.py index 45239f2..4e62c8f 100644 --- a/deploy/deploy.py +++ b/deploy/deploy.py @@ -79,7 +79,7 @@ def resolve_app_envs(config, work_dir, release_dir, age_key_file): for app, app_config in config["apps"].items(): downloaded = [ download_ref(ref, pull_dir / app / str(index)) - for index, ref in enumerate(app_config["env_refs"], start=1) + for index, ref in enumerate(app_config.get("env_refs") or [], start=1) ] paths = [path for path, _ in downloaded] resolved_env_refs[app] = [resolved_ref for _, resolved_ref in downloaded] diff --git a/deploy/tests/test_deploy.py b/deploy/tests/test_deploy.py index 56f90f3..c129edc 100644 --- a/deploy/tests/test_deploy.py +++ b/deploy/tests/test_deploy.py @@ -202,6 +202,26 @@ def test_writes_decrypted_env_and_renders_configs(self): rendered_path = release_dir / "apps" / "codecov" / "codecov.yml" self.assertEqual(rendered_path.read_text(), "email: a@example.com\n") + def test_app_without_env_refs_gets_no_vault_decrypted(self): + with tempfile.TemporaryDirectory() as directory: + work_dir = Path(directory) + release_dir = self._release_dir_with_app(work_dir, "beszel") + + config = {"apps": {"beszel": {}}} + + with ( + patch.object(deploy, "download_ref") as fake_download_ref, + patch.object(deploy, "decrypt_env") as fake_decrypt_env, + ): + resolved_env_refs = deploy.resolve_app_envs(config, work_dir / "work", release_dir, work_dir / "key.txt") + + fake_download_ref.assert_not_called() + fake_decrypt_env.assert_not_called() + self.assertEqual(resolved_env_refs, {"beszel": []}) + + env_path = release_dir / "apps" / "beszel" / ".env" + self.assertEqual(env_path.read_text(), "APP_NAME=beszel\n") + def test_raises_on_collision_within_one_apps_own_env_refs(self): with tempfile.TemporaryDirectory() as directory: work_dir = Path(directory)