diff --git a/.github/actions/encrypt-env/README.md b/.github/actions/encrypt-env/README.md index b02292b..482da85 100644 --- a/.github/actions/encrypt-env/README.md +++ b/.github/actions/encrypt-env/README.md @@ -32,12 +32,9 @@ permissions: asset: mainframe.sops.env keys: - mainframe -apps: - - traefik - - rybbit env: APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name APPS_TIMEZONE: APPS_TIMEZONE ``` -The action renders `apps` as the comma-separated `APPS` dotenv value. 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. +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. diff --git a/.github/actions/encrypt-env/scripts/render-env.py b/.github/actions/encrypt-env/scripts/render-env.py index 1bf667a..9d3d8ef 100644 --- a/.github/actions/encrypt-env/scripts/render-env.py +++ b/.github/actions/encrypt-env/scripts/render-env.py @@ -12,7 +12,6 @@ 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_]+)*$") KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") -APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$") @@ -58,31 +57,21 @@ def load_manifest(path): raise ManifestError(f"{path} is not valid YAML: {exc}") from exc if not isinstance(manifest, dict): raise ManifestError(f"{path} must contain a YAML mapping") - unknown = sorted(set(manifest) - {"asset", "keys", "apps", "env"}) + unknown = sorted(set(manifest) - {"asset", "keys", "env"}) if unknown: raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown)) asset = manifest.get("asset") keys = manifest.get("keys") - apps = manifest.get("apps") 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(apps, list) or not apps: - raise ManifestError("apps must be a non-empty list") 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}") - for app in apps: - if not isinstance(app, str) or not APP_NAME_RE.fullmatch(app): - raise ManifestError(f"invalid app name: {app!r}") - if len(apps) != len(set(apps)): - raise ManifestError("apps contains duplicate app names") - if "APPS" in env: - raise ManifestError("APPS must be configured through apps") 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}") @@ -111,7 +100,7 @@ def resolve_value(source_name, secrets, variables): def render_env(manifest, secrets, variables): - lines = [f"APPS={','.join(manifest['apps'])}"] + lines = [] missing = [] for output_name, source_name in manifest["env"].items(): value = resolve_value(source_name, secrets, variables) diff --git a/.github/actions/encrypt-env/tests/test_render_env.py b/.github/actions/encrypt-env/tests/test_render_env.py index 6fe5d6f..b79786c 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 = {"apps": ["traefik", "rybbit"], "env": {"DOMAIN": "DOMAIN", "TIMEZONE": "TIMEZONE"}} + manifest = {"env": {"DOMAIN": "DOMAIN", "TIMEZONE": "TIMEZONE"}} output = render_env.render_env( manifest, {"DOMAIN": "secret.example"}, @@ -21,43 +21,18 @@ def test_render_prefers_secrets_over_variables(self): ) self.assertIn("DOMAIN=secret.example\n", output) self.assertIn("TIMEZONE=Europe/Berlin\n", output) - self.assertIn("APPS=traefik,rybbit\n", output) def test_quotes_shell_sensitive_values(self): - output = render_env.render_env( - {"apps": ["traefik"], "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): with self.assertRaises(render_env.ManifestError): render_env.load_manifest(self.write_manifest("asset: test.sops.env\nraw_env: [APPS]\n")) - def test_rejects_apps_in_env(self): - manifest = ( - "asset: test.sops.env\n" - "keys: [test]\n" - "apps: [traefik]\n" - "env:\n" - " APPS: TEST_APPS\n" - ) - with self.assertRaisesRegex(render_env.ManifestError, "must be configured through"): - render_env.load_manifest(self.write_manifest(manifest)) - - def test_rejects_duplicate_apps(self): - manifest = ( - "asset: test.sops.env\n" - "keys: [test]\n" - "apps: [traefik, traefik]\n" - "env:\n" - " TOKEN: TOKEN\n" - ) - with self.assertRaisesRegex(render_env.ManifestError, "duplicate app names"): - render_env.load_manifest(self.write_manifest(manifest)) - def test_missing_source_fails(self): with self.assertRaises(render_env.ManifestError): - render_env.render_env({"apps": ["traefik"], "env": {"TOKEN": "TOKEN"}}, {}, {}) + render_env.render_env({"env": {"TOKEN": "TOKEN"}}, {}, {}) def test_duplicate_yaml_keys_fail(self): with tempfile.TemporaryDirectory() as directory: @@ -65,7 +40,6 @@ def test_duplicate_yaml_keys_fail(self): path.write_text( "asset: mainframe.sops.env\n" "keys: [master, server]\n" - "apps: [traefik, rybbit]\n" "env:\n" " TOKEN: FIRST\n" " TOKEN: SECOND\n" @@ -89,7 +63,6 @@ def test_main_writes_env_and_outputs(self): manifest_path.write_text( "asset: mainframe.sops.env\n" "keys: [master, server]\n" - "apps: [traefik, rybbit]\n" "env:\n" " TOKEN: TOKEN\n" ) @@ -108,7 +81,6 @@ def test_main_writes_env_and_outputs(self): os.environ.update(old_env) self.assertEqual(result, 0) self.assertIn("TOKEN=secret\n", env_path.read_text()) - self.assertIn("APPS=traefik,rybbit\n", env_path.read_text()) self.assertIn("asset=mainframe.sops.env\n", outputs_path.read_text()) self.assertIn("keys=master,server\n", outputs_path.read_text()) diff --git a/.github/workflows/deploy-shared.yml b/.github/workflows/deploy-shared.yml index 5f58bed..54bf3fc 100644 --- a/.github/workflows/deploy-shared.yml +++ b/.github/workflows/deploy-shared.yml @@ -10,14 +10,18 @@ on: description: Full release ref of the Flightdeck bundle to deploy, in owner/repo@tag format. type: string required: true - env-ref: - description: Release ref for the encrypted env package, in owner/repo@tag:asset format. + env-refs: + description: JSON array of release refs for encrypted env packages to decrypt and merge, in owner/repo@tag[:asset] format. type: string required: true app-refs: description: JSON array of release refs for app bundles to merge into the release, in owner/repo@tag[:asset] format. type: string required: true + apps: + description: JSON array of app names to run on this target, rendered into the deployed env as APPS. + type: string + required: true path: description: Base path on the target host for releases, shared files, and the current symlink. type: string @@ -74,15 +78,18 @@ jobs: shell: bash env: APP_REF: ${{ inputs.app-ref }} - ENV_REF: ${{ inputs.env-ref }} + ENV_REFS: ${{ inputs.env-refs }} APP_REFS: ${{ inputs.app-refs }} + APPS: ${{ inputs.apps }} HOSTS: ${{ inputs.hosts }} DEPLOY_PATH: ${{ inputs.path }} KEEP_RELEASES: ${{ inputs.keep-releases }} SOPS_KEY_FILE: ${{ inputs.sops-age-key-file }} run: | hosts_json="$(jq -ce 'if type == "array" and length > 0 and all(.[]; type == "string" and test("^[a-z_][a-z0-9_-]*@[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$")) then . else error("hosts must be a non-empty user@host string array") end' <<< "$HOSTS")" + env_refs_json="$(jq -ce 'if type == "array" and length > 0 and all(.[]; type == "string" and length > 0) then . else error("env-refs must be a non-empty string array") end' <<< "$ENV_REFS")" app_refs_json="$(jq -ce 'if type == "array" and length > 0 and all(.[]; type == "string" and length > 0) then . else error("app-refs must be a non-empty string array") end' <<< "$APP_REFS")" + apps_json="$(jq -ce 'if type == "array" and length > 0 and all(.[]; type == "string" and length > 0) then . else error("apps must be a non-empty string array") end' <<< "$APPS")" jq -ce ' reduce .[] as $destination ({all: {hosts: {}}}; ($destination | capture("^(?[^@]+)@(?.+)$")) as $ssh | @@ -91,12 +98,13 @@ jobs: ' <<< "$hosts_json" > "$RUNNER_TEMP/flightdeck-inventory.json" json="$(jq -n \ --arg app_ref "$APP_REF" \ - --arg env_ref "$ENV_REF" \ + --argjson env_refs "$env_refs_json" \ --argjson app_refs "$app_refs_json" \ + --argjson apps "$apps_json" \ --arg path "$DEPLOY_PATH" \ --argjson keep_releases "$KEEP_RELEASES" \ --arg sops_key_file "$SOPS_KEY_FILE" \ - '{flightdeck_app_ref: $app_ref, flightdeck_env_ref: $env_ref, flightdeck_app_refs: $app_refs, flightdeck_path: $path, flightdeck_keep_releases: $keep_releases, flightdeck_sops_age_key_file: $sops_key_file}')" + '{flightdeck_app_ref: $app_ref, flightdeck_env_refs: $env_refs, flightdeck_app_refs: $app_refs, flightdeck_apps: $apps, flightdeck_path: $path, flightdeck_keep_releases: $keep_releases, flightdeck_sops_age_key_file: $sops_key_file}')" echo "json=$json" >> "$GITHUB_OUTPUT" echo "inventory=$RUNNER_TEMP/flightdeck-inventory.json" >> "$GITHUB_OUTPUT" - name: Run playbook diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b2034d8..8dbb31f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,8 +28,9 @@ jobs: with: hosts: ${{ toJson(matrix.hosts) }} app-ref: ${{ matrix.flightdeck_ref }} - env-ref: ${{ matrix.env_ref }} + env-refs: ${{ toJson(matrix.env_refs) }} app-refs: ${{ toJson(matrix.app_refs) }} + apps: ${{ toJson(matrix.apps) }} path: ${{ matrix.path || '~/flightdeck' }} keep-releases: ${{ matrix.keep_releases || 5 }} sops-age-key-file: ${{ matrix.sops_age_key_file || '~/.config/sops/age/keys.txt' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3676911..0962f4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,8 +98,9 @@ jobs: with: hosts: ${{ toJson(matrix.hosts) }} app-ref: ${{ matrix.flightdeck_ref }} - env-ref: ${{ matrix.env_ref }} + env-refs: ${{ toJson(matrix.env_refs) }} app-refs: ${{ toJson(matrix.app_refs) }} + apps: ${{ toJson(matrix.apps) }} path: ${{ matrix.path || '~/flightdeck' }} keep-releases: ${{ matrix.keep_releases || 5 }} sops-age-key-file: ${{ matrix.sops_age_key_file || '~/.config/sops/age/keys.txt' }} diff --git a/AGENTS.md b/AGENTS.md index 7547fda..ff0699a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -408,12 +408,14 @@ GitHub Actions workflow (`.github/workflows/release-please.yml`) manages release Deployment helpers live in this repository: -- `ansible/deploy.yml` pulls `flightdeck_app_ref` (the machinery bundle), merges every ref in `flightdeck_app_refs` (the app bundles, at least one required — flightdeck's own `apps/` catalog is just another entry, not implicit), pulls the server-specific encrypted env package from `flightdeck_env_ref`, decrypts `.sops.env` on the server, switches a timestamped release, and runs `./deploy.sh` -- `.github/actions/encrypt-env/` is a local composite action for rendering `vaults/` manifests from GitHub Secrets/Variables, encrypting them for age recipients, and publishing `.sops.env` as a GitHub Release asset +- `ansible/deploy.yml` pulls `flightdeck_app_ref` (the machinery bundle), merges every ref in `flightdeck_app_refs` (the app bundles, at least one required — flightdeck's own `apps/` catalog is just another entry, not implicit), decrypts and merges every ref in `flightdeck_env_refs` (at least one required) into the server's `.env`, switches a timestamped release, and runs `./deploy.sh` +- `.github/actions/encrypt-env/` is a local composite action for rendering `vaults/` manifests from GitHub Secrets/Variables, encrypting them for age recipients, and publishing `.sops.env` as a GitHub Release asset — vault manifests hold only env/secrets, not app selection - `.github/workflows/deploy-shared.yml` is a reusable workflow consumer repos call to run `ansible/deploy.yml` from GitHub Actions over an optional Tailscale connection, without holding any deploy secrets in this repository App bundles listed in `flightdeck_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 by the deploy playbook through GitHub's latest release API. Every bundle must contain an `apps/` directory; app names may not conflict across bundles. +Env packages listed in `flightdeck_env_refs` are release refs the same shape as app bundles, defaulting to a `$tag.sops.env`-named asset. The playbook decrypts each with the server-local SOPS age key, then merges them alongside a synthesized `APPS` line (built from `flightdeck_apps`, the target's own desired app set) — failing loud on any key collision across sources, `APPS` included. `apps` moved off the vault schema onto the target for exactly this reason: multiple vaults can be merged without having to reconcile per-vault `apps` lists. + Private release assets are supported by passing `FLIGHTDECK_GITHUB_TOKEN` as a secret environment variable to `ansible/deploy.yml`. Store it as a secret in whatever system runs the playbook (e.g. a GitHub Actions secret when using `deploy-shared.yml`), not in plain configuration. When the token is present, the playbook exports it as `GH_TOKEN` for `gh release download`. ## Notable App Configurations diff --git a/README.md b/README.md index d6fd0e7..11345cf 100644 --- a/README.md +++ b/README.md @@ -80,47 +80,15 @@ APPS_DATABASE_PASSWORD=... APPS_TIMEZONE=... ``` -### Ansible Deploy +### Automated Deploy -The repository includes an Ansible playbook for deploying the published Flightdeck bundle and encrypted env package: +Deployment to a remote server goes through [`deploy-shared.yml`](.github/workflows/deploy-shared.yml) (documented in the GitHub Actions section below), a reusable workflow wrapping `ansible/deploy.yml` behind plain deploy vocabulary — `hosts`, `app-ref`, `env-refs`, `app-refs`, `apps`. `ansible/deploy.yml` is an implementation detail: callers of `deploy-shared.yml` never invoke `ansible-playbook` or write `flightdeck_*` `-e` variables themselves. -Target servers need Docker, Docker Compose, GitHub CLI (`gh`), SOPS, and the server-local age key. - -```bash -ansible-playbook ansible/deploy.yml \ - -i mainframe, \ - -u root \ - -e flightdeck_env_ref=/@latest:.sops.env \ - -e '{"flightdeck_app_refs":["rubykatzen/flightdeck@latest"]}' -``` - -The `flightdeck_env_ref` format is `owner/repo@tag:asset`. Use an immutable semver tag for a pinned deploy, or `@latest` to resolve GitHub's latest release at deploy time. The playbook downloads the asset, decrypts it with the server-local SOPS age key (`flightdeck_sops_age_key_file`), links shared `.env` and `apps-data` into a timestamped release, switches `current`, and runs `./deploy.sh`. - -`flightdeck_app_ref` (the machinery bundle) and `flightdeck_app_refs` (the app bundles to merge, `owner/repo@tag[:asset]` each) are both required — there's no default and no implicit `apps/`. `flightdeck_app_refs` must list at least one ref; for the default catalog, that's `rubykatzen/flightdeck@latest`. - -For private GitHub Releases, pass a token through the `FLIGHTDECK_GITHUB_TOKEN` -environment variable. Store it as a secret in whatever system runs this -playbook (e.g. a GitHub Actions secret when using `deploy-shared.yml` below), -not in plain configuration. - -```bash -FLIGHTDECK_GITHUB_TOKEN=... -``` - -When `FLIGHTDECK_GITHUB_TOKEN` is set, the playbook exports it as `GH_TOKEN` -for `gh release download`. Public releases do not need this variable. - -Additional app bundles merge in the exact same way, as further entries in `flightdeck_app_refs`: +What gets deployed — which app bundles, which encrypted env sources, which apps actually run — is configured declaratively per target, not passed as ad-hoc flags; see "Vaults And Targets" below for the manifest format, including how multiple `app_refs`/`env_refs` entries merge (env merging fails loud on any key collision across sources, including against the synthesized `APPS` line). -```bash -ansible-playbook ansible/deploy.yml \ - -i mainframe, \ - -u root \ - -e flightdeck_env_ref=/@latest:.sops.env \ - -e '{"flightdeck_app_refs":["rubykatzen/flightdeck@latest","/@latest"]}' -``` +Target servers need Docker, Docker Compose, GitHub CLI (`gh`), SOPS, and the server-local age key. -Every bundle in `flightdeck_app_refs` must contain an `apps/` directory. App names cannot conflict across bundles. +For private GitHub Releases, `deploy-shared.yml` passes a token through as `FLIGHTDECK_GITHUB_TOKEN`; the playbook exports it as `GH_TOKEN` for `gh release download`. Public releases don't need this. ### 3. Select Applications @@ -449,7 +417,7 @@ This repository provides four composite actions under `.github/actions/` (`build ### Vaults And Targets -Files in `vaults/` describe encrypted env assets. Files in `targets/` describe deployments. The two collections are independent; a deployment links to an encrypted asset explicitly through `env_ref`. Matching filenames are a convenience, not an implicit relationship. +Files in `vaults/` describe encrypted env assets — pure secrets/config, no app selection. Files in `targets/` describe deployments, including the desired `apps` set. The two collections are independent; a deployment links to encrypted assets explicitly through `env_refs`. Matching filenames are a convenience, not an implicit relationship. `vaults/mainframe.yml`: @@ -457,9 +425,6 @@ Files in `vaults/` describe encrypted env assets. Files in `targets/` describe d asset: mainframe.sops.env keys: - mainframe -apps: - - traefik - - rybbit env: APPS_DOMAIN: MAINFRAME_DOMAIN ``` @@ -468,10 +433,14 @@ env: ```yaml flightdeck_ref: rubykatzen/flightdeck@latest -env_ref: owner/config@latest:mainframe.sops.env +env_refs: + - owner/config@latest:mainframe.sops.env app_refs: - rubykatzen/flightdeck@latest - owner/extra-apps@latest +apps: + - traefik + - rybbit hosts: - deploy@100.64.0.1 - deploy@100.64.0.2 @@ -485,7 +454,7 @@ credentials: tailscale_oauth_secret: TAILSCALE_OAUTH_SECRET ``` -Credential fields contain GitHub Variable/Secret names, never credential values. `apps`, `app_refs`, and `hosts` are YAML arrays. Each host uses the SSH `user@host` format. The app list is rendered into the encrypted asset as a comma-separated `APPS` value. `app_refs` must list at least one app bundle — flightdeck's own `apps/` catalog is just another entry, not implicit. +Credential fields contain GitHub Variable/Secret names, never credential values. `env_refs`, `app_refs`, `apps`, and `hosts` are YAML arrays. 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` must list at least one encrypted env asset; the deploy playbook decrypts and merges all of them plus a synthesized `APPS` line built from the target's own `apps`, failing loud on any key collision across sources (including against `APPS` itself, if a vault ever tried to define 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`/`sops-age-key-file` defaults and pull `credentials.secrets`/`credentials.variables` values directly from the matrix item. @@ -516,9 +485,6 @@ Requires `contents: write` permission on the calling job. asset: mainframe.sops.env keys: - mainframe -apps: - - traefik - - rybbit env: APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name ``` @@ -569,7 +535,7 @@ Requires `contents: write` permission on the calling job. `flightdeck-apps.zip` ### `deploy-shared.yml` -Runs [`ansible/deploy.yml`](ansible/deploy.yml) from this repository against the caller-supplied hosts. Intended to be called from a private consumer repository that owns both the config and secrets side (SSH key, encrypted `.sops.env` releases, etc.) — this repository does not hold any deploy secrets itself. `env-ref` typically references that same calling repository via `${{ github.repository }}`, since it's both the config and secrets source. +Runs [`ansible/deploy.yml`](ansible/deploy.yml) from this repository against the caller-supplied hosts. Intended to be called from a private consumer repository that owns both the config and secrets side (SSH key, encrypted `.sops.env` releases, etc.) — this repository does not hold any deploy secrets itself. `env-refs` entries typically reference that same calling repository via `${{ github.repository }}`, since it's both the config and secrets source. The interface is plain deploy vocabulary, not Ansible's — callers never see `flightdeck_*` variable names or hand-write `-e` JSON; the workflow builds that internally. @@ -582,8 +548,9 @@ jobs: with: hosts: '["deploy@100.64.0.1", "deploy@100.64.0.2"]' # required JSON array app-ref: rubykatzen/flightdeck@latest # required full release ref - env-ref: "${{ github.repository }}@latest:.sops.env" # required + env-refs: '["${{ github.repository }}@latest:.sops.env"]' # required non-empty JSON array app-refs: '["rubykatzen/flightdeck@latest"]' # required non-empty JSON array + apps: '["traefik", "rybbit"]' # required non-empty JSON array # path: ~/flightdeck # optional, default shown # keep-releases: 5 # optional, default shown # sops-age-key-file: /home/deploy/.config/sops/age/keys.txt # optional, default: ~/.config/sops/age/keys.txt for `user` diff --git a/ansible/deploy.yml b/ansible/deploy.yml index 279bebf..bf465c4 100644 --- a/ansible/deploy.yml +++ b/ansible/deploy.yml @@ -10,12 +10,13 @@ assert: that: - flightdeck_app_ref is defined and flightdeck_app_ref | length > 0 - - flightdeck_env_ref is defined and flightdeck_env_ref | length > 0 + - flightdeck_env_refs is defined and flightdeck_env_refs | length > 0 - flightdeck_app_refs is defined and flightdeck_app_refs | length > 0 + - flightdeck_apps is defined and flightdeck_apps | length > 0 - flightdeck_path is defined and flightdeck_path | length > 0 - flightdeck_keep_releases is defined - flightdeck_sops_age_key_file is defined and flightdeck_sops_age_key_file | length > 0 - fail_msg: "Set flightdeck_app_ref, flightdeck_env_ref, flightdeck_app_refs, flightdeck_path, flightdeck_keep_releases, and flightdeck_sops_age_key_file" + fail_msg: "Set flightdeck_app_ref, flightdeck_env_refs, flightdeck_app_refs, flightdeck_apps, flightdeck_path, flightdeck_keep_releases, and flightdeck_sops_age_key_file" - name: Resolve Flightdeck user paths set_fact: flightdeck_user_home: "{{ '/root' if ansible_user == 'root' else '/home/' + ansible_user }}" @@ -172,16 +173,36 @@ executable: /bin/bash environment: FLIGHTDECK_GITHUB_TOKEN: "{{ flightdeck_github_token }}" - - name: Create env package pull directory + - name: Create env packages pull directory tempfile: state: directory - suffix: flightdeck-env-package - register: flightdeck_env_pull - - name: Pull encrypted env package + suffix: flightdeck-env-packages + register: flightdeck_env_packages_pull + - name: Pull, decrypt, and merge env packages shell: | set -euo pipefail - ref={{ flightdeck_env_ref | quote }} - out={{ flightdeck_env_pull.path | quote }} + packages_root={{ flightdeck_env_packages_pull.path | quote }} + merged={{ (flightdeck_shared_path + '/.env.tmp') | quote }} + seen_keys_file="$packages_root/seen-keys" + : > "$seen_keys_file" + : > "$merged" + add_line() { + key="${1%%=*}" + if grep -qxF "$key" "$seen_keys_file"; then + echo "Env key conflicts with an existing source: $key" >&2 + exit 1 + fi + echo "$key" >> "$seen_keys_file" + printf '%s\n' "$1" >> "$merged" + } + add_line {{ ('APPS=' + (flightdeck_apps | join(','))) | quote }} + if [ -n "${FLIGHTDECK_GITHUB_TOKEN:-}" ]; then + export GH_TOKEN="$FLIGHTDECK_GITHUB_TOKEN" + fi + {% for ref in flightdeck_env_refs %} + pkg_dir="$packages_root/{{ loop.index }}" + mkdir -p "$pkg_dir" + ref={{ ref | quote }} asset= repo="${ref%@*}" tag="${ref#*@}" @@ -193,41 +214,31 @@ asset="${tag#*:}" tag="${tag%%:*}" fi - if [ -n "${FLIGHTDECK_GITHUB_TOKEN:-}" ]; then - export GH_TOKEN="$FLIGHTDECK_GITHUB_TOKEN" - fi if [ "$tag" = "latest" ]; then resolved_tag="$(gh release view --repo "$repo" --json tagName --jq .tagName)" if [ -z "$resolved_tag" ] || [ "$resolved_tag" = "null" ]; then echo "Could not resolve GitHub latest release for $repo" >&2 exit 1 fi - echo "Resolved $repo@latest to $repo@$resolved_tag" + echo "Resolved $repo@latest to $repo@$resolved_tag" >&2 tag="$resolved_tag" fi if [ -z "$asset" ]; then asset="$tag.sops.env" fi - gh release download "$tag" --repo "$repo" --pattern "$asset" --dir "$out" - test -f "$out/$asset" - cp "$out/$asset" "$out/.sops.env" + gh release download "$tag" --repo "$repo" --pattern "$asset" --dir "$pkg_dir" + test -f "$pkg_dir/$asset" + plain="$pkg_dir/plain.env" + SOPS_AGE_KEY_FILE={{ flightdeck_sops_age_key_file | quote }} sops decrypt "$pkg_dir/$asset" > "$plain" + while IFS= read -r line; do + [ -n "$line" ] || continue + add_line "$line" + done < "$plain" + {% endfor %} args: executable: /bin/bash environment: FLIGHTDECK_GITHUB_TOKEN: "{{ flightdeck_github_token }}" - - name: Check encrypted env payload - stat: - path: "{{ flightdeck_env_pull.path }}/.sops.env" - register: flightdeck_sops_env - - name: Require encrypted env payload - fail: - msg: ".sops.env was not found in {{ flightdeck_env_ref }}" - when: not flightdeck_sops_env.stat.exists - - name: Decrypt env to shared file - shell: > - SOPS_AGE_KEY_FILE={{ flightdeck_sops_age_key_file | quote }} - sops decrypt {{ (flightdeck_env_pull.path + '/.sops.env') | quote }} - > {{ (flightdeck_shared_path + '/.env.tmp') | quote }} no_log: true - name: Set env file permissions file: @@ -279,11 +290,11 @@ path: "{{ flightdeck_app_pull.path }}" state: absent when: flightdeck_app_pull is defined and flightdeck_app_pull.path is defined - - name: Remove env package pull directory + - name: Remove env packages pull directory file: - path: "{{ flightdeck_env_pull.path }}" + path: "{{ flightdeck_env_packages_pull.path }}" state: absent - when: flightdeck_env_pull is defined and flightdeck_env_pull.path is defined + when: flightdeck_env_packages_pull is defined and flightdeck_env_packages_pull.path is defined - name: Remove app packages pull directory file: path: "{{ flightdeck_app_packages_pull.path }}" diff --git a/targets/hawkeye.yml b/targets/hawkeye.yml index 387f034..43b79c5 100644 --- a/targets/hawkeye.yml +++ b/targets/hawkeye.yml @@ -1,7 +1,11 @@ flightdeck_ref: rubykatzen/flightdeck@latest -env_ref: rubykatzen/flightdeck@latest:hawkeye.sops.env +env_refs: + - rubykatzen/flightdeck@latest:hawkeye.sops.env app_refs: - rubykatzen/flightdeck@latest +apps: + - traefik + - rybbit hosts: - rubykatzen-com@100.75.50.2 credentials: diff --git a/vaults/hawkeye.yml b/vaults/hawkeye.yml index fb84284..12d7b9b 100644 --- a/vaults/hawkeye.yml +++ b/vaults/hawkeye.yml @@ -1,9 +1,6 @@ asset: hawkeye.sops.env keys: - hawkeye -apps: - - traefik - - rybbit env: APPS_DOMAIN: RUBYKATZEN_COM_DOMAIN APPS_ADMIN_MAIL: RUBYKATZEN_COM_ADMIN_MAIL