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
2 changes: 1 addition & 1 deletion .github/actions/encrypt-env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ env:
DISABLE_SIGNUP: true # output name: literal value, no lookup at all
```

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." `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 `<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.
9 changes: 3 additions & 6 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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"


Expand Down
39 changes: 0 additions & 39 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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.<name>.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.

Expand Down Expand Up @@ -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 `<owner>/<repo>@latest` or `<owner>/<repo>@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

Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<target>.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/<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.

Expand Down Expand Up @@ -279,8 +280,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.<name>.env_refs` must reference at least one), it just renders an empty `.env`.

---

### `build-bundle`
Expand Down
2 changes: 1 addition & 1 deletion deploy/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
20 changes: 20 additions & 0 deletions deploy/tests/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down