diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..d4e3d00 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +paths: + .github/workflows/deploy-shared.yml: + # actionlint 1.7.12 predates these GitHub job context properties. + ignore: + - 'property "workflow_(repository|sha)" is not defined in object type' diff --git a/.github/actions/build-apps-bundle/action.yml b/.github/actions/build-apps-bundle/action.yml new file mode 100644 index 0000000..52d522b --- /dev/null +++ b/.github/actions/build-apps-bundle/action.yml @@ -0,0 +1,31 @@ +name: Build and upload apps bundle +description: Build an apps/ catalog bundle and upload it as a GitHub Release asset. Thin defaults wrapper around build-bundle. +inputs: + paths: + description: Newline-separated paths to include in the bundle. + required: false + default: apps + bundle-name: + description: Bundle archive filename. + required: false + default: flightdeck-apps.zip + release-tag: + description: Release tag to upload the bundle asset to. + required: true + token: + description: Token with permission to upload release assets. + required: true +outputs: + bundle-path: + description: Path to the built bundle archive. + value: ${{ steps.build.outputs.bundle-path }} +runs: + using: composite + steps: + - uses: ./.github/actions/build-bundle + id: build + with: + paths: ${{ inputs.paths }} + bundle-name: ${{ inputs.bundle-name }} + release-tag: ${{ inputs.release-tag }} + token: ${{ inputs.token }} diff --git a/.github/actions/build-bundle/action.yml b/.github/actions/build-bundle/action.yml index 11a5273..54c192b 100644 --- a/.github/actions/build-bundle/action.yml +++ b/.github/actions/build-bundle/action.yml @@ -1,13 +1,31 @@ -name: Build app bundle -description: Build and validate a zip bundle from specified paths. +name: Build and upload bundle +description: Build a zip bundle from specified paths and upload it as a GitHub Release asset. Defaults to Flightdeck's own machinery bundle. inputs: paths: description: Newline-separated paths to include in the bundle. - required: true + required: false + default: | + ansible.cfg + .env.example + backup.sh + deploy.sh + down.sh + generate-env.sh + lib.sh + logs.sh + restart.sh + up.sh + README.md bundle-name: description: Bundle archive filename. required: false - default: bundle.zip + default: flightdeck.zip + release-tag: + description: Release tag to upload the bundle asset to. + required: true + token: + description: Token with permission to upload release assets. + required: true outputs: bundle-path: description: Path to the built bundle archive. @@ -40,3 +58,10 @@ runs: exit 1 fi echo "bundle-path=.bundle/$BUNDLE_NAME" >> "$GITHUB_OUTPUT" + - name: Upload bundle + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + RELEASE_TAG: ${{ inputs.release-tag }} + BUNDLE_PATH: ${{ steps.build.outputs.bundle-path }} + run: gh release upload "$RELEASE_TAG" "$BUNDLE_PATH" --clobber diff --git a/.github/actions/discover-manifest-matrix/README.md b/.github/actions/discover-manifest-matrix/README.md deleted file mode 100644 index f4dbd77..0000000 --- a/.github/actions/discover-manifest-matrix/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# discover-manifest-matrix - -Composite GitHub Action that builds a GitHub Actions strategy matrix from files matching a glob pattern. - -## Usage - -```yaml -- id: discover - uses: rubykatzen/flightdeck/.github/actions/discover-manifest-matrix@main - with: - pattern: projects/*/*.yml # required -``` - -**Output:** `matrix` — JSON object `{"manifest": ["projects/a/server.yml", ...]}`. - -Fails if no files match the pattern. - -## Example - -```yaml -jobs: - discover: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.discover.outputs.matrix }} - steps: - - uses: actions/checkout@v6 - - id: discover - uses: rubykatzen/flightdeck/.github/actions/discover-manifest-matrix@main - with: - pattern: projects/*/*.yml - - publish: - needs: discover - strategy: - matrix: ${{ fromJson(needs.discover.outputs.matrix) }} - steps: - - run: echo "${{ matrix.manifest }}" -``` diff --git a/.github/actions/discover-manifest-matrix/action.yml b/.github/actions/discover-manifest-matrix/action.yml deleted file mode 100644 index 4319f3f..0000000 --- a/.github/actions/discover-manifest-matrix/action.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Discover manifest matrix -description: Build a GitHub Actions strategy matrix from files matching a glob pattern. -inputs: - pattern: - description: Glob pattern to match manifest files. - required: true -outputs: - matrix: - description: JSON strategy matrix with a "manifest" key containing matched file paths. - value: ${{ steps.matrix.outputs.matrix }} -runs: - using: composite - steps: - - name: Build matrix - id: matrix - shell: bash - env: - PATTERN: ${{ inputs.pattern }} - run: | - set -euo pipefail - matrix="$(find . -path "./$PATTERN" -not -path '*/.*' | sort | sed 's|^\./||' | jq -Rsc 'split("\n") | map(select(length > 0)) | {manifest: .}')" - if [ "$(jq '.manifest | length' <<< "$matrix")" -eq 0 ]; then - echo "No files found matching: $PATTERN" >&2 - exit 1 - fi - echo "matrix=$matrix" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/encrypt-env/README.md b/.github/actions/encrypt-env/README.md new file mode 100644 index 0000000..b02292b --- /dev/null +++ b/.github/actions/encrypt-env/README.md @@ -0,0 +1,43 @@ +# encrypt-env + +Composite GitHub Action that renders an encryption config from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` to an existing GitHub Release. + +The release must exist before this action runs. + +## Usage + +```yaml +- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main + with: + manifest: vaults/mainframe.yml # required + keys-directory: keys # default: keys + release-tag: ${{ needs.release.outputs.tag }} # required, must already exist + release-repo: "" # default: current repository + token: ${{ secrets.GITHUB_TOKEN }} # required + env: + GITHUB_SECRETS_JSON: ${{ toJson(secrets) }} + GITHUB_VARS_JSON: ${{ toJson(vars) }} +``` + +The calling job requires: + +```yaml +permissions: + contents: write +``` + +## Manifest + +```yaml +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. diff --git a/.github/actions/encrypt-env/action.yml b/.github/actions/encrypt-env/action.yml new file mode 100644 index 0000000..d694223 --- /dev/null +++ b/.github/actions/encrypt-env/action.yml @@ -0,0 +1,108 @@ +name: Encrypt env +description: Render a target env, encrypt it with SOPS age recipients, and upload it to a GitHub Release. +inputs: + manifest: + description: Path to the YAML env manifest. + required: true + release-repo: + description: GitHub repository containing the release. Defaults to the current repository. + required: false + default: "" + release-tag: + description: Existing release tag to publish the asset to. + required: true + keys-directory: + description: Directory containing public age recipients named .pub. + required: false + default: keys + token: + description: Token with permission to write releases. + required: true +outputs: + release-repo: + description: GitHub repository used for publication. + value: ${{ steps.publish.outputs.release_repo }} + tag: + description: Release tag used for publication. + value: ${{ steps.publish.outputs.tag }} + ref: + description: Release asset ref in owner/repo@tag:asset format. + value: ${{ steps.publish.outputs.ref }} +runs: + using: composite + steps: + - name: Install dependencies + shell: bash + env: + SOPS_VERSION: "3.13.1" + run: | + python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt" + mkdir -p "$RUNNER_TEMP/encrypt-env/bin" + curl --fail --location --silent --show-error \ + "https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \ + --output "$RUNNER_TEMP/encrypt-env/bin/sops" + chmod +x "$RUNNER_TEMP/encrypt-env/bin/sops" + echo "$RUNNER_TEMP/encrypt-env/bin" >> "$GITHUB_PATH" + - name: Render env + id: render + shell: bash + env: + MANIFEST: ${{ inputs.manifest }} + run: | + mkdir -p "$RUNNER_TEMP/encrypt-env" + python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \ + --manifest "$MANIFEST" \ + --output "$RUNNER_TEMP/encrypt-env/plain.env" + - name: Resolve release target + id: target + shell: bash + env: + INPUT_RELEASE_REPO: ${{ inputs.release-repo }} + DEFAULT_RELEASE_REPO: ${{ github.repository }} + ASSET_NAME: ${{ steps.render.outputs.asset }} + run: | + release_repo="${INPUT_RELEASE_REPO:-$DEFAULT_RELEASE_REPO}" + echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT" + echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT" + - name: Encrypt env + shell: bash + env: + KEYS: ${{ steps.render.outputs.keys }} + KEYS_DIRECTORY: ${{ inputs.keys-directory }} + run: | + recipients="" + IFS=',' read -ra names <<< "$KEYS" + for name in "${names[@]}"; do + recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")" + if [[ ! "$recipient" =~ ^age1 ]]; then + echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2 + exit 1 + fi + recipients="${recipients:+$recipients,}$recipient" + done + SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \ + --input-type dotenv \ + --output-type dotenv \ + "$RUNNER_TEMP/encrypt-env/plain.env" \ + > "$RUNNER_TEMP/encrypt-env/.sops.env" + rm "$RUNNER_TEMP/encrypt-env/plain.env" + - name: Upload encrypted env + id: publish + shell: bash + env: + RELEASE_REPO: ${{ steps.target.outputs.release_repo }} + TAG: ${{ inputs.release-tag }} + ASSET_NAME: ${{ steps.target.outputs.asset_name }} + GH_TOKEN: ${{ inputs.token }} + run: | + set -euo pipefail + asset_path="$RUNNER_TEMP/encrypt-env/$ASSET_NAME" + cp "$RUNNER_TEMP/encrypt-env/.sops.env" "$asset_path" + gh release upload "$TAG" "$asset_path" --repo "$RELEASE_REPO" --clobber + echo "release_repo=$RELEASE_REPO" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "ref=$RELEASE_REPO@$TAG:$ASSET_NAME" >> "$GITHUB_OUTPUT" + - name: Cleanup + if: always() + shell: bash + run: rm -rf "$RUNNER_TEMP/encrypt-env" diff --git a/.github/actions/publish-sops-env/requirements.txt b/.github/actions/encrypt-env/requirements.txt similarity index 100% rename from .github/actions/publish-sops-env/requirements.txt rename to .github/actions/encrypt-env/requirements.txt diff --git a/.github/actions/publish-sops-env/scripts/render-env.py b/.github/actions/encrypt-env/scripts/render-env.py similarity index 74% rename from .github/actions/publish-sops-env/scripts/render-env.py rename to .github/actions/encrypt-env/scripts/render-env.py index 2c9f177..1bf667a 100644 --- a/.github/actions/publish-sops-env/scripts/render-env.py +++ b/.github/actions/encrypt-env/scripts/render-env.py @@ -12,9 +12,8 @@ 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-]*$") -RELEASE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") -RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9_.-]+$") -RELEASE_ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$") +APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$") class ManifestError(Exception): @@ -59,32 +58,31 @@ 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") - if "package" in manifest: - raise ManifestError("package has been replaced by release-repo/release-tag GitHub Releases configuration") - release_repo = manifest.get("release_repo") - release_tag = manifest.get("release_tag") - release_asset = manifest.get("release_asset", f"{path.stem}.sops.env") + unknown = sorted(set(manifest) - {"asset", "keys", "apps", "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 "raw_env" in manifest: - raise ManifestError("raw_env is no longer supported; store values as data, not shell syntax") - if release_repo is not None and ( - not isinstance(release_repo, str) or not RELEASE_REPO_RE.fullmatch(release_repo) - ): - raise ManifestError("release_repo must be in owner/repo format") - if release_tag is not None and ( - not isinstance(release_tag, str) or not RELEASE_TAG_RE.fullmatch(release_tag) - ): - raise ManifestError("release_tag must contain only letters, numbers, dots, underscores, or hyphens") - if not isinstance(release_asset, str) or not RELEASE_ASSET_RE.fullmatch(release_asset): - raise ManifestError("release_asset must be named like server.sops.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}") @@ -113,7 +111,7 @@ def resolve_value(source_name, secrets, variables): def render_env(manifest, secrets, variables): - lines = [] + lines = [f"APPS={','.join(manifest['apps'])}"] missing = [] for output_name, source_name in manifest["env"].items(): value = resolve_value(source_name, secrets, variables) @@ -148,9 +146,7 @@ def main(argv=None): args.output.chmod(0o600) write_github_outputs( { - "release_repo": manifest.get("release_repo", ""), - "release_tag": manifest.get("release_tag", ""), - "release_asset": manifest.get("release_asset", f"{args.manifest.stem}.sops.env"), + "asset": manifest["asset"], "keys": ",".join(manifest["keys"]), } ) diff --git a/.github/actions/publish-sops-env/tests/test_render_env.py b/.github/actions/encrypt-env/tests/test_render_env.py similarity index 64% rename from .github/actions/publish-sops-env/tests/test_render_env.py rename to .github/actions/encrypt-env/tests/test_render_env.py index ef99fcc..6fe5d6f 100644 --- a/.github/actions/publish-sops-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 = {"apps": ["traefik", "rybbit"], "env": {"DOMAIN": "DOMAIN", "TIMEZONE": "TIMEZONE"}} output = render_env.render_env( manifest, {"DOMAIN": "secret.example"}, @@ -21,25 +21,51 @@ 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({"env": {"TOKEN": "TOKEN"}}, {"TOKEN": "hello world"}, {}) + output = render_env.render_env( + {"apps": ["traefik"], "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("raw_env: [APPS]\nenv:\n APPS: APPS\n")) + 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({"env": {"TOKEN": "TOKEN"}}, {}, {}) + render_env.render_env({"apps": ["traefik"], "env": {"TOKEN": "TOKEN"}}, {}, {}) def test_duplicate_yaml_keys_fail(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "manifest.yml" path.write_text( - "release_repo: example/secrets\n" + "asset: mainframe.sops.env\n" "keys: [master, server]\n" + "apps: [traefik, rybbit]\n" "env:\n" " TOKEN: FIRST\n" " TOKEN: SECOND\n" @@ -61,9 +87,9 @@ def test_main_writes_env_and_outputs(self): env_path = root / ".env" outputs_path = root / "outputs" manifest_path.write_text( - "release_repo: example/secrets\n" - "release_asset: mainframe.sops.env\n" + "asset: mainframe.sops.env\n" "keys: [master, server]\n" + "apps: [traefik, rybbit]\n" "env:\n" " TOKEN: TOKEN\n" ) @@ -82,9 +108,8 @@ 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("release_repo=example/secrets\n", outputs_path.read_text()) - self.assertIn("release_tag=\n", outputs_path.read_text()) - self.assertIn("release_asset=mainframe.sops.env\n", outputs_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/actions/load-yaml-matrix/README.md b/.github/actions/load-yaml-matrix/README.md new file mode 100644 index 0000000..7e86bd6 --- /dev/null +++ b/.github/actions/load-yaml-matrix/README.md @@ -0,0 +1,17 @@ +# load-yaml-matrix + +Composite GitHub Action that reads every YAML file in a directory into a GitHub Actions matrix. It does no schema validation — callers are responsible for the shape of their own manifests. + +## Usage + +```yaml +- uses: rubykatzen/flightdeck/.github/actions/load-yaml-matrix@main + id: matrix + with: + directory: targets # required + # name: all # optional; single manifest name to load, default: all +``` + +The action exposes `matrix`, containing `{ "include": [...] }`, and `count`. Each matrix item merges the manifest's own top-level YAML fields with `name` (the file's basename) and `manifest` (its path). + +Files may use either the `.yml` or `.yaml` extension. Filenames must match `^[a-z0-9][a-z0-9-]*$` and be unique per directory; duplicate top-level YAML keys within a manifest are rejected. Beyond that, the parsed YAML mapping is passed through as-is — validate anything else downstream. diff --git a/.github/actions/load-yaml-matrix/action.yml b/.github/actions/load-yaml-matrix/action.yml new file mode 100644 index 0000000..0b1f6a5 --- /dev/null +++ b/.github/actions/load-yaml-matrix/action.yml @@ -0,0 +1,31 @@ +name: Load YAML matrix +description: Read every YAML file in a directory into a GitHub Actions matrix. No schema validation. +inputs: + directory: + description: Directory containing YAML files. + required: true + name: + description: Single manifest name to load, or all. + required: false + default: all +outputs: + matrix: + description: JSON strategy matrix, one item per manifest, merging its parsed YAML fields with name and manifest. + value: ${{ steps.load.outputs.matrix }} + count: + description: Number of manifests in the matrix. + value: ${{ steps.load.outputs.count }} +runs: + using: composite + steps: + - name: Install dependencies + shell: bash + run: python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt" + - name: Load matrix + id: load + shell: bash + env: + DIRECTORY: ${{ inputs.directory }} + NAME: ${{ inputs.name }} + run: | + python3 "$GITHUB_ACTION_PATH/scripts/load-yaml-matrix.py" --directory "$DIRECTORY" --name "$NAME" diff --git a/.github/actions/load-yaml-matrix/requirements.txt b/.github/actions/load-yaml-matrix/requirements.txt new file mode 100644 index 0000000..8392d54 --- /dev/null +++ b/.github/actions/load-yaml-matrix/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0.2 diff --git a/.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.py b/.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.py new file mode 100644 index 0000000..de13c58 --- /dev/null +++ b/.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import re +import sys +from pathlib import Path + +import yaml + +NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +class ManifestError(Exception): + pass + + +class UniqueKeyLoader(yaml.SafeLoader): + pass + + +def construct_mapping(loader, node, deep=False): + mapping = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in mapping: + raise ManifestError(f"duplicate YAML key: {key}") + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + +UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + construct_mapping, +) + + +def load_manifest(path): + try: + value = yaml.load(path.read_text(), Loader=UniqueKeyLoader) + except yaml.YAMLError as exc: + raise ManifestError(f"{path} is not valid YAML: {exc}") from exc + if not isinstance(value, dict): + raise ManifestError(f"{path} must contain a YAML mapping") + return value + + +def build_matrix(directory, selected="all"): + paths = sorted(directory.glob("*.yml")) + sorted(directory.glob("*.yaml")) + if not paths: + raise ManifestError(f"no manifests found in {directory}") + names = {path.stem for path in paths} + if selected != "all" and selected not in names: + raise ManifestError(f"unknown name: {selected}") + include = [] + seen_names = set() + for path in paths: + name = path.stem + if not NAME_RE.fullmatch(name): + raise ManifestError(f"invalid manifest filename: {path.name}") + if name in seen_names: + raise ManifestError(f"duplicate manifest name: {name}") + seen_names.add(name) + manifest = load_manifest(path) + if selected != "all" and name != selected: + continue + item = {"name": name, "manifest": str(path)} + item.update(manifest) + include.append(item) + return {"include": include} + + +def write_github_output(name, value): + output_path = os.environ.get("GITHUB_OUTPUT") + if output_path: + with open(output_path, "a", encoding="utf-8") as output: + output.write(f"{name}={value}\n") + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--directory", required=True, type=Path) + parser.add_argument("--name", default="all") + args = parser.parse_args(argv) + try: + matrix = build_matrix(args.directory, args.name) + encoded = json.dumps(matrix, separators=(",", ":")) + write_github_output("matrix", encoded) + write_github_output("count", len(matrix["include"])) + except ManifestError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.py b/.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.py new file mode 100644 index 0000000..8d1cfb2 --- /dev/null +++ b/.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.py @@ -0,0 +1,81 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + +MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "load-yaml-matrix.py" +SPEC = importlib.util.spec_from_file_location("load_yaml_matrix", MODULE_PATH) +load_yaml_matrix = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(load_yaml_matrix) + + +HAWKEYE = """\ +flightdeck_ref: rubykatzen/flightdeck@v1.2.3 +hosts: [rubykatzen-com@100.75.50.2] +""" + +MAINFRAME = """\ +flightdeck_ref: rubykatzen/flightdeck@v1.0.0 +hosts: [deploy@100.64.0.1] +""" + + +class LoadYamlMatrixTest(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary_directory.cleanup) + self.directory = Path(self.temporary_directory.name) + (self.directory / "hawkeye.yml").write_text(HAWKEYE) + (self.directory / "mainframe.yml").write_text(MAINFRAME) + + def test_builds_matrix_from_all_manifests(self): + matrix = load_yaml_matrix.build_matrix(self.directory) + self.assertEqual( + sorted(item["name"] for item in matrix["include"]), + ["hawkeye", "mainframe"], + ) + + def test_merges_manifest_fields_with_name_and_manifest(self): + item = load_yaml_matrix.build_matrix(self.directory, "hawkeye")["include"][0] + self.assertEqual(item["name"], "hawkeye") + self.assertEqual(item["manifest"], str(self.directory / "hawkeye.yml")) + self.assertEqual(item["flightdeck_ref"], "rubykatzen/flightdeck@v1.2.3") + self.assertEqual(item["hosts"], ["rubykatzen-com@100.75.50.2"]) + + def test_filters_selected_manifest(self): + matrix = load_yaml_matrix.build_matrix(self.directory, "hawkeye") + self.assertEqual([item["name"] for item in matrix["include"]], ["hawkeye"]) + + def test_rejects_unknown_name(self): + with self.assertRaisesRegex(load_yaml_matrix.ManifestError, "unknown name"): + load_yaml_matrix.build_matrix(self.directory, "missing") + + def test_rejects_empty_directory(self): + empty = self.directory / "empty" + empty.mkdir() + with self.assertRaisesRegex(load_yaml_matrix.ManifestError, "no manifests found"): + load_yaml_matrix.build_matrix(empty) + + def test_rejects_invalid_manifest_filename(self): + (self.directory / "Hawkeye_Prod.yml").write_text(HAWKEYE) + with self.assertRaisesRegex(load_yaml_matrix.ManifestError, "invalid manifest filename"): + load_yaml_matrix.build_matrix(self.directory) + + def test_rejects_duplicate_manifest_name(self): + (self.directory / "hawkeye.yaml").write_text(HAWKEYE) + with self.assertRaisesRegex(load_yaml_matrix.ManifestError, "duplicate manifest name"): + load_yaml_matrix.build_matrix(self.directory) + + def test_rejects_non_mapping_manifest(self): + (self.directory / "hawkeye.yml").write_text("- one\n- two\n") + with self.assertRaisesRegex(load_yaml_matrix.ManifestError, "must contain a YAML mapping"): + load_yaml_matrix.build_matrix(self.directory) + + def test_rejects_duplicate_yaml_key(self): + (self.directory / "hawkeye.yml").write_text("hosts: [one]\nhosts: [two]\n") + with self.assertRaisesRegex(load_yaml_matrix.ManifestError, "duplicate YAML key"): + load_yaml_matrix.build_matrix(self.directory) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/actions/publish-sops-env/README.md b/.github/actions/publish-sops-env/README.md deleted file mode 100644 index 4f0765c..0000000 --- a/.github/actions/publish-sops-env/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# publish-sops-env - -Composite GitHub Action that renders an env manifest from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` as a GitHub Release asset. - -The release must exist before this action runs. Create it in a dedicated job and pass the tag explicitly via `release-tag`. - -## Usage - -```yaml -- uses: rubykatzen/flightdeck/.github/actions/publish-sops-env@main - with: - manifest: projects/flightdeck/mainframe.yml # required - keys-directory: keys # default: keys - release-tag: ${{ needs.release.outputs.tag }} # default: manifest release_tag or repo name - release-repo: "" # default: current repository - asset-name: "" # default: manifest release_asset or .sops.env - token: ${{ secrets.GITHUB_TOKEN }} # required - env: - GITHUB_SECRETS_JSON: ${{ toJson(secrets) }} - GITHUB_VARS_JSON: ${{ toJson(vars) }} -``` - -The calling job requires: - -```yaml -permissions: - contents: write -``` - -## Manifest - -```yaml -release_asset: flightdeck--mainframe.sops.env - -keys: - - mainframe - -env: - APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name - APPS_TIMEZONE: APPS_TIMEZONE - APPS: APPS_MAINFRAME -``` - -For each name in `keys`, the action loads `/.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. diff --git a/.github/actions/publish-sops-env/action.yml b/.github/actions/publish-sops-env/action.yml deleted file mode 100644 index 9f7df9e..0000000 --- a/.github/actions/publish-sops-env/action.yml +++ /dev/null @@ -1,125 +0,0 @@ -name: Publish SOPS env -description: Render an env manifest, encrypt it with SOPS age recipients, and publish it as a GitHub Release asset. -inputs: - manifest: - description: Path to the YAML env manifest. - required: true - release-repo: - description: GitHub repository to publish the release asset to. Defaults to the current repository or manifest release_repo. - required: false - default: "" - release-tag: - description: Release tag to publish. Defaults to manifest release_tag or current repository name. - required: false - default: "" - asset-name: - description: Release asset name. Defaults to manifest release_asset or .sops.env. - required: false - default: "" - keys-directory: - description: Directory containing public age recipients named .pub. - required: false - default: keys - token: - description: Token with permission to write releases. - required: true -outputs: - release-repo: - description: GitHub repository used for publication. - value: ${{ steps.publish.outputs.release_repo }} - tag: - description: Release tag used for publication. - value: ${{ steps.publish.outputs.tag }} - ref: - description: Short release ref in owner/repo@tag format. - value: ${{ steps.publish.outputs.ref }} -runs: - using: composite - steps: - - name: Install dependencies - shell: bash - env: - SOPS_VERSION: "3.13.1" - run: | - python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt" - mkdir -p "$RUNNER_TEMP/publish-sops-env/bin" - curl --fail --location --silent --show-error \ - "https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \ - --output "$RUNNER_TEMP/publish-sops-env/bin/sops" - chmod +x "$RUNNER_TEMP/publish-sops-env/bin/sops" - echo "$RUNNER_TEMP/publish-sops-env/bin" >> "$GITHUB_PATH" - - name: Render env - id: render - shell: bash - env: - MANIFEST: ${{ inputs.manifest }} - run: | - mkdir -p "$RUNNER_TEMP/publish-sops-env" - python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \ - --manifest "$MANIFEST" \ - --output "$RUNNER_TEMP/publish-sops-env/plain.env" - - name: Encrypt env - shell: bash - env: - KEYS: ${{ steps.render.outputs.keys }} - KEYS_DIRECTORY: ${{ inputs.keys-directory }} - run: | - recipients="" - IFS=',' read -ra names <<< "$KEYS" - for name in "${names[@]}"; do - recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")" - if [[ ! "$recipient" =~ ^age1 ]]; then - echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2 - exit 1 - fi - recipients="${recipients:+$recipients,}$recipient" - done - SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \ - --input-type dotenv \ - --output-type dotenv \ - "$RUNNER_TEMP/publish-sops-env/plain.env" \ - > "$RUNNER_TEMP/publish-sops-env/.sops.env" - rm "$RUNNER_TEMP/publish-sops-env/plain.env" - - name: Publish release asset - id: publish - shell: bash - env: - INPUT_RELEASE_REPO: ${{ inputs.release-repo }} - MANIFEST_RELEASE_REPO: ${{ steps.render.outputs.release_repo }} - DEFAULT_RELEASE_REPO: ${{ github.repository }} - DEFAULT_RELEASE_TAG: latest - INPUT_RELEASE_TAG: ${{ inputs.release-tag }} - MANIFEST_RELEASE_TAG: ${{ steps.render.outputs.release_tag }} - INPUT_ASSET_NAME: ${{ inputs.asset-name }} - MANIFEST_ASSET_NAME: ${{ steps.render.outputs.release_asset }} - GH_TOKEN: ${{ inputs.token }} - run: | - set -euo pipefail - release_repo="$INPUT_RELEASE_REPO" - if [ -z "$release_repo" ]; then - release_repo="$MANIFEST_RELEASE_REPO" - fi - if [ -z "$release_repo" ]; then - release_repo="$DEFAULT_RELEASE_REPO" - fi - tag="$INPUT_RELEASE_TAG" - if [ -z "$tag" ]; then - tag="$MANIFEST_RELEASE_TAG" - fi - if [ -z "$tag" ]; then - tag="$DEFAULT_RELEASE_TAG" - fi - asset_name="$INPUT_ASSET_NAME" - if [ -z "$asset_name" ]; then - asset_name="$MANIFEST_ASSET_NAME" - fi - asset_path="$RUNNER_TEMP/publish-sops-env/$asset_name" - cp "$RUNNER_TEMP/publish-sops-env/.sops.env" "$asset_path" - gh release upload "$tag" "$asset_path" --repo "$release_repo" --clobber - echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "ref=$release_repo@$tag:$asset_name" >> "$GITHUB_OUTPUT" - - name: Cleanup - if: always() - shell: bash - run: rm -rf "$RUNNER_TEMP/publish-sops-env" diff --git a/.github/workflows/deploy-shared.yml b/.github/workflows/deploy-shared.yml index 73ba170..5f58bed 100644 --- a/.github/workflows/deploy-shared.yml +++ b/.github/workflows/deploy-shared.yml @@ -2,20 +2,36 @@ name: Deploy (shared) on: workflow_call: inputs: - inventory: - description: Ansible inventory, e.g. a comma-separated Tailscale host list. + hosts: + description: JSON array of user@host SSH destinations to deploy to. type: string required: true - user: - description: SSH user for the Ansible connection. + app-ref: + description: Full release ref of the Flightdeck bundle to deploy, in owner/repo@tag format. type: string - default: root - extra-vars: - description: "JSON object passed to ansible-playbook as -e (flightdeck_env_ref, flightdeck_path, etc). flightdeck_app_ref defaults to the ref this workflow was called at (the @tag on the uses: line) and only needs to be set here to override that." + required: true + env-ref: + description: Release ref for the encrypted env package, 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 + path: + description: Base path on the target host for releases, shared files, and the current symlink. + type: string + default: "~/flightdeck" + keep-releases: + description: Number of past releases to keep on the target host. + type: number + default: 5 + sops-age-key-file: + description: Path to the server-local SOPS age key file, relative to each SSH user's home when it starts with ~. + type: string + default: "~/.config/sops/age/keys.txt" tailscale-oauth-client-id: - description: Tailscale OAuth client ID used to join the tailnet. Leave unset to skip joining a tailnet (e.g. when the runner already has network access to the inventory hosts). + description: Tailscale OAuth client ID used to join the tailnet. Leave unset to skip joining a tailnet (e.g. when the runner already has network access to the hosts). type: string default: "" tailscale-tags: @@ -24,7 +40,7 @@ on: default: tag:ci secrets: ssh-private-key: - description: SSH private key used to connect to the inventory hosts. + description: SSH private key used to connect to the hosts. required: true tailscale-oauth-secret: description: Tailscale OAuth client secret used to join the tailnet. Required only when tailscale-oauth-client-id is set. @@ -33,19 +49,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - name: Resolve flightdeck ref - id: ref - shell: bash - run: | - ref="${{ github.workflow_ref }}" - ref="${ref#*@}" - ref="${ref#refs/tags/}" - ref="${ref#refs/heads/}" - echo "ref=$ref" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v7 with: - repository: rubykatzen/flightdeck - ref: ${{ steps.ref.outputs.ref }} + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} - name: Install ansible-core shell: bash run: pip install --user --break-system-packages ansible-core @@ -62,13 +69,41 @@ jobs: echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV" echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV" ssh-add - <<< "${{ secrets.ssh-private-key }}" + - name: Build extra-vars + id: vars + shell: bash + env: + APP_REF: ${{ inputs.app-ref }} + ENV_REF: ${{ inputs.env-ref }} + APP_REFS: ${{ inputs.app-refs }} + 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")" + 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")" + jq -ce ' + reduce .[] as $destination ({all: {hosts: {}}}; + ($destination | capture("^(?[^@]+)@(?.+)$")) as $ssh | + .all.hosts[$ssh.host] = {ansible_user: $ssh.user} + ) + ' <<< "$hosts_json" > "$RUNNER_TEMP/flightdeck-inventory.json" + json="$(jq -n \ + --arg app_ref "$APP_REF" \ + --arg env_ref "$ENV_REF" \ + --argjson app_refs "$app_refs_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}')" + echo "json=$json" >> "$GITHUB_OUTPUT" + echo "inventory=$RUNNER_TEMP/flightdeck-inventory.json" >> "$GITHUB_OUTPUT" - name: Run playbook shell: bash env: ANSIBLE_HOST_KEY_CHECKING: "false" run: | ansible-playbook ansible/deploy.yml \ - -i "${{ inputs.inventory }}" \ - -u "${{ inputs.user }}" \ - -e "{\"flightdeck_app_ref\":\"rubykatzen/flightdeck@${{ steps.ref.outputs.ref }}\"}" \ - -e "${{ inputs.extra-vars }}" + -i "${{ steps.vars.outputs.inventory }}" \ + -e "${{ steps.vars.outputs.json }}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b2034d8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,39 @@ +name: Deploy +on: + workflow_dispatch: + inputs: + target: + description: Deploy target to redeploy. Use "all" to redeploy every target. + type: string + default: all +jobs: + deploy-targets: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + count: ${{ steps.matrix.outputs.count }} + steps: + - uses: actions/checkout@v7 + - uses: $/.github/actions/load-yaml-matrix + id: matrix + with: + directory: targets + name: ${{ inputs.target || 'all' }} + deploy: + needs: deploy-targets + if: needs.deploy-targets.outputs.count != '0' + strategy: + matrix: ${{ fromJson(needs.deploy-targets.outputs.matrix) }} + uses: $/.github/workflows/deploy-shared.yml + with: + hosts: ${{ toJson(matrix.hosts) }} + app-ref: ${{ matrix.flightdeck_ref }} + env-ref: ${{ matrix.env_ref }} + app-refs: ${{ toJson(matrix.app_refs) }} + path: ${{ matrix.path || '~/flightdeck' }} + keep-releases: ${{ matrix.keep_releases || 5 }} + sops-age-key-file: ${{ matrix.sops_age_key_file || '~/.config/sops/age/keys.txt' }} + tailscale-oauth-client-id: ${{ vars[matrix.credentials.variables.tailscale_oauth_client_id] }} + secrets: + ssh-private-key: ${{ secrets[matrix.credentials.secrets.ssh_private_key] }} + tailscale-oauth-secret: ${{ secrets[matrix.credentials.secrets.tailscale_oauth_secret] }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index 8a61335..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Release Please -on: - push: - branches: [main] -permissions: - contents: write - issues: write - pull-requests: write -jobs: - release: - runs-on: ubuntu-latest - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - steps: - - uses: googleapis/release-please-action@v5 - id: release - with: - token: ${{ secrets.RELEASE_TOKEN }} - - upload: - needs: release - if: needs.release.outputs.release_created == 'true' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.release.outputs.tag_name }} - - id: bundle - uses: ./.github/actions/build-bundle - with: - bundle-name: flightdeck.zip - paths: | - apps - ansible.cfg - .env.example - backup.sh - deploy.sh - down.sh - generate-env.sh - lib.sh - logs.sh - restart.sh - up.sh - README.md - - run: gh release upload "${{ needs.release.outputs.tag_name }}" "${{ steps.bundle.outputs.bundle-path }}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3676911 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,109 @@ +name: Release +on: + push: + branches: [main] +permissions: + contents: write + issues: write + pull-requests: write +jobs: + release: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v5 + id: release + with: + token: ${{ secrets.RELEASE_TOKEN }} + upload: + needs: release + if: needs.release.outputs.release_created == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.tag_name }} + - uses: $/.github/actions/build-bundle + with: + release-tag: ${{ needs.release.outputs.tag_name }} + token: ${{ secrets.GITHUB_TOKEN }} + upload-apps: + needs: release + if: needs.release.outputs.release_created == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.tag_name }} + - uses: $/.github/actions/build-apps-bundle + with: + release-tag: ${{ needs.release.outputs.tag_name }} + token: ${{ secrets.GITHUB_TOKEN }} + encrypt-vaults: + needs: release + if: needs.release.outputs.release_created == 'true' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + count: ${{ steps.matrix.outputs.count }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.tag_name }} + - uses: $/.github/actions/load-yaml-matrix + id: matrix + with: + directory: vaults + encrypt: + needs: [release, encrypt-vaults] + if: needs.encrypt-vaults.outputs.count != '0' + strategy: + matrix: ${{ fromJson(needs.encrypt-vaults.outputs.matrix) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.tag_name }} + - uses: $/.github/actions/encrypt-env + with: + manifest: ${{ matrix.manifest }} + release-tag: ${{ needs.release.outputs.tag_name }} + token: ${{ secrets.GITHUB_TOKEN }} + env: + GITHUB_SECRETS_JSON: ${{ toJson(secrets) }} + GITHUB_VARS_JSON: ${{ toJson(vars) }} + deploy-targets: + needs: release + if: needs.release.outputs.release_created == 'true' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + count: ${{ steps.matrix.outputs.count }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.tag_name }} + - uses: $/.github/actions/load-yaml-matrix + id: matrix + with: + directory: targets + deploy: + needs: [upload, upload-apps, encrypt, deploy-targets] + if: needs.deploy-targets.outputs.count != '0' + strategy: + matrix: ${{ fromJson(needs.deploy-targets.outputs.matrix) }} + uses: $/.github/workflows/deploy-shared.yml + with: + hosts: ${{ toJson(matrix.hosts) }} + app-ref: ${{ matrix.flightdeck_ref }} + env-ref: ${{ matrix.env_ref }} + app-refs: ${{ toJson(matrix.app_refs) }} + path: ${{ matrix.path || '~/flightdeck' }} + keep-releases: ${{ matrix.keep_releases || 5 }} + sops-age-key-file: ${{ matrix.sops_age_key_file || '~/.config/sops/age/keys.txt' }} + tailscale-oauth-client-id: ${{ vars[matrix.credentials.variables.tailscale_oauth_client_id] }} + secrets: + ssh-private-key: ${{ secrets[matrix.credentials.secrets.ssh_private_key] }} + tailscale-oauth-secret: ${{ secrets[matrix.credentials.secrets.tailscale_oauth_secret] }} diff --git a/AGENTS.md b/AGENTS.md index 1cd93ec..7547fda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -404,15 +404,15 @@ GitHub Actions workflow (`.github/workflows/release-please.yml`) manages release 1. On every push to `main`, Release Please opens/updates a `chore(main): release X.Y.Z` PR with the computed version and generated `CHANGELOG.md` entry 2. Merging that PR tags the release and publishes a GitHub Release -3. A second job then builds `flightdeck.zip` from compose files, helper scripts, examples, and README (verifying runtime state such as `.env`, `apps-data`, `backups`, and generated `apps/*/.env` is excluded) and uploads it as a release asset. Deploy refs may use `@latest` as a playbook-side alias for GitHub's latest release API; no mutable `latest` release/tag is created. +3. A second and third job then build and upload two release assets: `flightdeck.zip` from helper scripts, examples, and README (verifying runtime state such as `.env`, `apps-data`, `backups`, and generated `apps/*/.env` is excluded), and `flightdeck-apps.zip` from the `apps/` catalog alone. Deploy refs may use `@latest` as a playbook-side alias for GitHub's latest release API; no mutable `latest` release/tag is created. Deployment helpers live in this repository: -- `ansible/deploy.yml` pulls `flightdeck_app_ref`, merges optional `flightdeck_extra_refs`, 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/publish-sops-env/` is a local composite action for rendering env 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), 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 - `.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 -Extra Flightdeck bundles are release assets referenced as short refs like `/@latest` or `/@v1.2.3`. `@latest` is resolved by the deploy playbook through GitHub's latest release API. Extra bundles must contain an `apps/` directory only adding app directories; app names may not conflict with the core bundle or earlier extras. +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. 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`. diff --git a/README.md b/README.md index 6ec9638..d6fd0e7 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,13 @@ Target servers need Docker, Docker Compose, GitHub CLI (`gh`), SOPS, and the ser ansible-playbook ansible/deploy.yml \ -i mainframe, \ -u root \ - -e flightdeck_env_ref=/@latest:.sops.env + -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`. -The `flightdeck_app_ref` defaults to `rubykatzen/flightdeck@latest`. +`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 @@ -109,17 +110,17 @@ 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. -Optional extra app bundles can be merged into the release before deploy: +Additional app bundles merge in the exact same way, as further entries in `flightdeck_app_refs`: ```bash ansible-playbook ansible/deploy.yml \ -i mainframe, \ -u root \ -e flightdeck_env_ref=/@latest:.sops.env \ - -e '{"flightdeck_extra_refs":["/@latest"]}' + -e '{"flightdeck_app_refs":["rubykatzen/flightdeck@latest","/@latest"]}' ``` -Extra bundles must contain an `apps/` directory. Extra app names cannot conflict with apps from the core bundle or earlier extra bundles. +Every bundle in `flightdeck_app_refs` must contain an `apps/` directory. App names cannot conflict across bundles. ### 3. Select Applications @@ -173,11 +174,16 @@ flightdeck/ │ └── deploy.yml # Deploy published bundle and encrypted env ├── .github/ │ ├── actions/ -│ │ ├── discover-manifest-matrix/ # Build a strategy matrix from files matching a glob -│ │ └── publish-sops-env/ # Encrypt env manifest and upload to GitHub Release +│ │ ├── build-bundle/ # Build and upload the machinery bundle +│ │ ├── build-apps-bundle/ # Build and upload an apps/ catalog bundle +│ │ ├── encrypt-env/ # Encrypt a target env and upload it to a release +│ │ └── load-yaml-matrix/ # Read a directory of YAML manifests into a workflow matrix │ └── workflows/ -│ └── release-please.yml # Release Please + publish Flightdeck release bundle +│ ├── deploy-shared.yml # Reusable deployment workflow +│ └── release.yml # Release Please + publish Flightdeck assets │ +├── vaults/ # Encrypted env asset configurations +├── targets/ # Deployment targets ├── .env # All server configuration incl. APPS list (git-ignored) ├── .env.example # Configuration template │ @@ -253,7 +259,7 @@ The script stops each app one at a time, creates a zip archive, restarts it, the | **databasus** | Database management UI | | **rybbit** | Web analytics | -Additional apps can live in an optional extra catalog repo (`apps/` directory) and be merged at deploy time with `flightdeck_extra_refs`. +This catalog is itself published as its own release asset (`flightdeck-apps.zip`), merged at deploy time like any other entry in `flightdeck_app_refs`. Additional apps can live in any other repo's own `apps/`-shaped catalog, published the same way, and merged in by listing its ref alongside flightdeck's own. ## ⚙️ Configuration @@ -437,61 +443,65 @@ If you're evaluating alternatives, these projects solve a similar problem from d ## ⚙️ GitHub Actions -This repository provides two reusable composite actions under `.github/actions/` and one reusable workflow, `deploy-shared.yml`. +This repository provides four composite actions under `.github/actions/` (`build-bundle`, `build-apps-bundle`, `encrypt-env`, and `load-yaml-matrix`) and one reusable workflow, `deploy-shared.yml`. --- -### `discover-manifest-matrix` +### 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. -Builds a GitHub Actions strategy matrix from files matching a glob pattern. +`vaults/mainframe.yml`: ```yaml -- id: discover - uses: rubykatzen/flightdeck/.github/actions/discover-manifest-matrix@main - with: - pattern: projects/*/*.yml # required +asset: mainframe.sops.env +keys: + - mainframe +apps: + - traefik + - rybbit +env: + APPS_DOMAIN: MAINFRAME_DOMAIN ``` -**Outputs:** `matrix` — JSON object `{"manifest": ["path/a.yml", "path/b.yml", ...]}`. - -**Typical use** — feed the output into a matrix job: +`targets/mainframe.yml`: ```yaml -jobs: - discover: - outputs: - matrix: ${{ steps.discover.outputs.matrix }} - steps: - - uses: actions/checkout@v6 - - id: discover - uses: rubykatzen/flightdeck/.github/actions/discover-manifest-matrix@main - with: - pattern: projects/*/*.yml - - publish: - needs: discover - strategy: - matrix: ${{ fromJson(needs.discover.outputs.matrix) }} - steps: - - run: echo ${{ matrix.manifest }} +flightdeck_ref: rubykatzen/flightdeck@latest +env_ref: owner/config@latest:mainframe.sops.env +app_refs: + - rubykatzen/flightdeck@latest + - owner/extra-apps@latest +hosts: + - deploy@100.64.0.1 + - deploy@100.64.0.2 +path: ~/flightdeck # optional, default shown +sops_age_key_file: ~/.config/sops/age/keys.txt # optional, default shown +credentials: + variables: + tailscale_oauth_client_id: TAILSCALE_OAUTH_CLIENT_ID + secrets: + ssh_private_key: DEPLOY_SSH_PRIVATE_KEY + 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. -### `publish-sops-env` +`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. -Renders an env manifest from GitHub Secrets/Variables, encrypts it with SOPS age recipients, and uploads `.sops.env` as a GitHub Release asset. +--- + +### `encrypt-env` -The release must already exist before this action runs. Create it in a separate job and pass the tag explicitly. +Renders an encryption config from GitHub Secrets/Variables, encrypts it with SOPS age recipients, and uploads `.sops.env` to an existing GitHub Release. Release creation remains the calling workflow's responsibility. ```yaml -- uses: rubykatzen/flightdeck/.github/actions/publish-sops-env@main +- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main with: - manifest: projects/flightdeck/mainframe.yml # required + manifest: vaults/mainframe.yml # required keys-directory: keys # default: keys - release-tag: latest # default: manifest release_tag or repo name + release-tag: latest # required, must already exist release-repo: "" # default: current repository - asset-name: "" # default: manifest release_asset or .sops.env token: ${{ secrets.GITHUB_TOKEN }} # required env: GITHUB_SECRETS_JSON: ${{ toJson(secrets) }} @@ -503,39 +513,80 @@ Requires `contents: write` permission on the calling job. **Manifest format:** ```yaml -release_asset: flightdeck--mainframe.sops.env - +asset: mainframe.sops.env keys: - mainframe - +apps: + - traefik + - rybbit env: APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name - APPS: APPS_AGATHA ``` Secrets take precedence over Variables when both contain the same source key. Every source key must exist or the action fails. --- +### `build-bundle` + +Builds a zip archive from caller-selected paths, rejects runtime state and env files, and uploads it to an existing GitHub Release. `paths` and `bundle-name` default to Flightdeck's own machinery bundle (everything except `apps/`, uploaded as `flightdeck.zip`) but are fully overridable. + +```yaml +steps: + - uses: actions/checkout@v7 + with: + ref: v1.2.3 + - uses: rubykatzen/flightdeck/.github/actions/build-bundle@v1.2.3 + with: + release-tag: v1.2.3 + token: ${{ secrets.GITHUB_TOKEN }} + # paths: ... # optional, defaults to the machinery file list + # bundle-name: ... # optional, defaults to flightdeck.zip +``` + +Requires `contents: write` permission on the calling job. + +--- + +### `build-apps-bundle` + +A thin defaults wrapper around `build-bundle`: `paths` defaults to `apps`, `bundle-name` defaults to `flightdeck-apps.zip`. The same action publishes flightdeck's own `apps/` catalog and any consumer repository's own app bundle. + +```yaml +steps: + - uses: actions/checkout@v7 + with: + ref: v1.2.3 + - uses: rubykatzen/flightdeck/.github/actions/build-apps-bundle@v1.2.3 + with: + release-tag: v1.2.3 + token: ${{ secrets.GITHUB_TOKEN }} +``` + +Requires `contents: write` permission on the calling job. `flightdeck-apps.zip` is the default asset name a `flightdeck_app_refs` entry resolves to when it doesn't specify an explicit `:asset-name` suffix; override `bundle-name` and use that suffix when publishing under a different filename. + +--- + ### `deploy-shared.yml` -Runs [`ansible/deploy.yml`](ansible/deploy.yml) from this repository against the caller-supplied inventory. 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. `flightdeck_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-ref` typically references that same calling repository via `${{ github.repository }}`, since it's both the config and secrets source. -Tailscale is optional, not a dependency of this workflow: set `tailscale-oauth-client-id` (and the matching `tailscale-oauth-secret`) to have the runner join a tailnet as an ephemeral node before deploying. Leave both unset to skip that step entirely — e.g. when the job already runs on a self-hosted runner with network access to the inventory hosts, or reaches them some other way. +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. + +Tailscale is optional, not a dependency of this workflow: set `tailscale-oauth-client-id` (and the matching `tailscale-oauth-secret`) to have the runner join a tailnet as an ephemeral node before deploying. Leave both unset to skip that step entirely — e.g. when the job already runs on a self-hosted runner with network access to the hosts, or reaches them some other way. ```yaml jobs: deploy: uses: rubykatzen/flightdeck/.github/workflows/deploy-shared.yml@v1.2.3 with: - inventory: 100.64.0.1,100.64.0.2 # required - user: root # default: root - extra-vars: | - {"flightdeck_env_ref":"${{ github.repository }}@latest:.sops.env", - "flightdeck_extra_refs":[], - "flightdeck_path":"~/flightdeck", - "flightdeck_keep_releases":5, - "flightdeck_sops_age_key_file":"/home/deploy/.config/sops/age/keys.txt"} + 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 + app-refs: '["rubykatzen/flightdeck@latest"]' # 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` tailscale-oauth-client-id: ${{ vars.TAILSCALE_OAUTH_CLIENT_ID }} # optional, default: unset (skip joining a tailnet) tailscale-tags: tag:ci # default: tag:ci secrets: @@ -543,9 +594,7 @@ jobs: tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} # optional, required only if tailscale-oauth-client-id is set ``` -The `@v1.2.3` pin on the `uses:` line is the only place the Flightdeck version needs to be written: it's what gets checked out to run `ansible/deploy.yml`, and it's also the default for `flightdeck_app_ref` (the release bundle the playbook downloads and deploys) unless `extra-vars` explicitly overrides it. - -`extra-vars` is a JSON object passed through as `ansible-playbook -e` — see [Ansible Deploy](#ansible-deploy) above for what each `flightdeck_*` key means. +The `@v1.2.3` pin on the `uses:` line only controls which ref runs the playbook mechanism itself. `app-ref` is separate and required - it is the full release ref for the bundle the playbook downloads and deploys, and does not have to match the workflow pin. ## 📝 License diff --git a/ansible/deploy.yml b/ansible/deploy.yml index cbb63a2..279bebf 100644 --- a/ansible/deploy.yml +++ b/ansible/deploy.yml @@ -11,12 +11,18 @@ that: - flightdeck_app_ref is defined and flightdeck_app_ref | length > 0 - flightdeck_env_ref is defined and flightdeck_env_ref | length > 0 - - flightdeck_extra_refs is defined - - flightdeck_github_token | length > 0 + - flightdeck_app_refs is defined and flightdeck_app_refs | 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_extra_refs, flightdeck_path, flightdeck_keep_releases, flightdeck_sops_age_key_file, and the FLIGHTDECK_GITHUB_TOKEN environment variable" + fail_msg: "Set flightdeck_app_ref, flightdeck_env_ref, flightdeck_app_refs, 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 }}" + - name: Expand Flightdeck user paths + set_fact: + flightdeck_path: "{{ flightdeck_path | regex_replace('^~', flightdeck_user_home) }}" + flightdeck_sops_age_key_file: "{{ flightdeck_sops_age_key_file | regex_replace('^~', flightdeck_user_home) }}" - name: Apply Flightdeck paths set_fact: flightdeck_release_name: "{{ ansible_facts['date_time'].iso8601_basic_short }}" @@ -97,16 +103,20 @@ src: "{{ flightdeck_app_pull.path }}/flightdeck.zip" dest: "{{ flightdeck_release_path }}" remote_src: true - - name: Create extra packages pull directory + - name: Ensure release apps directory + file: + path: "{{ flightdeck_release_path }}/apps" + state: directory + mode: "0755" + - name: Create app packages pull directory tempfile: state: directory - suffix: flightdeck-extra-packages - register: flightdeck_extra_pull - when: flightdeck_extra_refs | length > 0 - - name: Pull and merge extra app packages + suffix: flightdeck-app-packages + register: flightdeck_app_packages_pull + - name: Pull and merge app packages shell: | set -euo pipefail - extra_root={{ flightdeck_extra_pull.path | quote }} + packages_root={{ flightdeck_app_packages_pull.path | quote }} release_apps={{ (flightdeck_release_path + '/apps') | quote }} download_release_ref() { ref="$1" @@ -139,20 +149,20 @@ if [ -n "${FLIGHTDECK_GITHUB_TOKEN:-}" ]; then export GH_TOKEN="$FLIGHTDECK_GITHUB_TOKEN" fi - {% for ref in flightdeck_extra_refs %} - package_dir="$extra_root/{{ loop.index }}" + {% for ref in flightdeck_app_refs %} + package_dir="$packages_root/{{ loop.index }}" mkdir -p "$package_dir/pull" "$package_dir/extract" - bundle="$(download_release_ref {{ ref | quote }} "$package_dir/pull" flightdeck-extra.zip)" + bundle="$(download_release_ref {{ ref | quote }} "$package_dir/pull" flightdeck-apps.zip)" unzip "$bundle" -d "$package_dir/extract" if [ ! -d "$package_dir/extract/apps" ]; then - echo "Extra package {{ ref }} does not contain apps/" >&2 + echo "Package {{ ref }} does not contain apps/" >&2 exit 1 fi for app_path in "$package_dir/extract/apps"/*; do [ -d "$app_path" ] || continue app="$(basename "$app_path")" if [ -e "$release_apps/$app" ]; then - echo "Extra app conflicts with an existing app: $app" >&2 + echo "App conflicts with an existing app: $app" >&2 exit 1 fi cp -a "$app_path" "$release_apps/" @@ -162,7 +172,6 @@ executable: /bin/bash environment: FLIGHTDECK_GITHUB_TOKEN: "{{ flightdeck_github_token }}" - when: flightdeck_extra_refs | length > 0 - name: Create env package pull directory tempfile: state: directory @@ -275,8 +284,8 @@ path: "{{ flightdeck_env_pull.path }}" state: absent when: flightdeck_env_pull is defined and flightdeck_env_pull.path is defined - - name: Remove extra packages pull directory + - name: Remove app packages pull directory file: - path: "{{ flightdeck_extra_pull.path }}" + path: "{{ flightdeck_app_packages_pull.path }}" state: absent - when: flightdeck_extra_pull is defined and flightdeck_extra_pull.path is defined + when: flightdeck_app_packages_pull is defined and flightdeck_app_packages_pull.path is defined diff --git a/keys/hawkeye.pub b/keys/hawkeye.pub new file mode 100644 index 0000000..106f30c --- /dev/null +++ b/keys/hawkeye.pub @@ -0,0 +1 @@ +age1tc5rvv3h80w6er4888rnah8w68nvz2lwqr2fg32s5hu0u2pfcdrqhradth diff --git a/targets/hawkeye.yml b/targets/hawkeye.yml new file mode 100644 index 0000000..387f034 --- /dev/null +++ b/targets/hawkeye.yml @@ -0,0 +1,12 @@ +flightdeck_ref: rubykatzen/flightdeck@latest +env_ref: rubykatzen/flightdeck@latest:hawkeye.sops.env +app_refs: + - rubykatzen/flightdeck@latest +hosts: + - rubykatzen-com@100.75.50.2 +credentials: + variables: + tailscale_oauth_client_id: TAILSCALE_OAUTH_CLIENT_ID + secrets: + ssh_private_key: DEPLOY_SSH_PRIVATE_KEY + tailscale_oauth_secret: TAILSCALE_OAUTH_SECRET diff --git a/vaults/hawkeye.yml b/vaults/hawkeye.yml new file mode 100644 index 0000000..fb84284 --- /dev/null +++ b/vaults/hawkeye.yml @@ -0,0 +1,16 @@ +asset: hawkeye.sops.env +keys: + - hawkeye +apps: + - traefik + - rybbit +env: + APPS_DOMAIN: RUBYKATZEN_COM_DOMAIN + APPS_ADMIN_MAIL: RUBYKATZEN_COM_ADMIN_MAIL + APPS_CERTIFICATE_RESOLVER: RUBYKATZEN_COM_CERT_RESOLVER + APPS_CLOUDFLARE_DNS_API_TOKEN: RUBYKATZEN_COM_CLOUDFLARE_TOKEN + APPS_DATABASE_PASSWORD: RUBYKATZEN_COM_DATABASE_PASSWORD + APPS_KEY_HEX_32: RUBYKATZEN_COM_KEY_HEX_32 + APPS_TIMEZONE: RUBYKATZEN_COM_TIMEZONE + TRAEFIK_HTTP_PORT: RUBYKATZEN_COM_TRAEFIK_HTTP_PORT + TRAEFIK_HTTPS_PORT: RUBYKATZEN_COM_TRAEFIK_HTTPS_PORT