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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.
15 changes: 2 additions & 13 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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$")


Expand DownExpand Up@@ -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}")
Expand DownExpand Up@@ -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)
Expand Down
34 changes: 3 additions & 31 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,59 +13,33 @@

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"},
{"DOMAIN": "var.example", "TIMEZONE": "Europe/Berlin"},
)
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:
path = Path(directory) / "manifest.yml"
path.write_text(
"asset: mainframe.sops.env\n"
"keys: [master, server]\n"
"apps: [traefik, rybbit]\n"
"env:\n"
" TOKEN: FIRST\n"
" TOKEN: SECOND\n"
Expand All@@ -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"
)
Expand All@@ -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())

Expand Down
18 changes: 13 additions & 5 deletions .github/workflows/deploy-shared.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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("^(?<user>[^@]+)@(?<host>.+)$")) as $ssh |
Expand All@@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<owner>/<repo>@latest` or `<owner>/<repo>@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: move apps to targets, support env_refs as a list by ineedjet · Pull Request #110 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.
15 changes: 2 additions & 13 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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$")


Expand DownExpand Up@@ -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}")
Expand DownExpand Up@@ -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)
Expand Down
34 changes: 3 additions & 31 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,59 +13,33 @@

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"},
{"DOMAIN": "var.example", "TIMEZONE": "Europe/Berlin"},
)
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:
path = Path(directory) / "manifest.yml"
path.write_text(
"asset: mainframe.sops.env\n"
"keys: [master, server]\n"
"apps: [traefik, rybbit]\n"
"env:\n"
" TOKEN: FIRST\n"
" TOKEN: SECOND\n"
Expand All@@ -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"
)
Expand All@@ -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())

Expand Down
18 changes: 13 additions & 5 deletions .github/workflows/deploy-shared.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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("^(?<user>[^@]+)@(?<host>.+)$")) as $ssh |
Expand All@@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<owner>/<repo>@latest` or `<owner>/<repo>@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: move apps to targets, support env_refs as a list by ineedjet · Pull Request #110 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.
15 changes: 2 additions & 13 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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$")


Expand DownExpand Up@@ -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}")
Expand DownExpand Up@@ -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)
Expand Down
34 changes: 3 additions & 31 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,59 +13,33 @@

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"},
{"DOMAIN": "var.example", "TIMEZONE": "Europe/Berlin"},
)
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:
path = Path(directory) / "manifest.yml"
path.write_text(
"asset: mainframe.sops.env\n"
"keys: [master, server]\n"
"apps: [traefik, rybbit]\n"
"env:\n"
" TOKEN: FIRST\n"
" TOKEN: SECOND\n"
Expand All@@ -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"
)
Expand All@@ -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())

Expand Down
18 changes: 13 additions & 5 deletions .github/workflows/deploy-shared.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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("^(?<user>[^@]+)@(?<host>.+)$")) as $ssh |
Expand All@@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<owner>/<repo>@latest` or `<owner>/<repo>@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: move apps to targets, support env_refs as a list by ineedjet · Pull Request #110 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.
15 changes: 2 additions & 13 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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$")


Expand DownExpand Up@@ -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}")
Expand DownExpand Up@@ -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)
Expand Down
34 changes: 3 additions & 31 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,59 +13,33 @@

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"},
{"DOMAIN": "var.example", "TIMEZONE": "Europe/Berlin"},
)
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:
path = Path(directory) / "manifest.yml"
path.write_text(
"asset: mainframe.sops.env\n"
"keys: [master, server]\n"
"apps: [traefik, rybbit]\n"
"env:\n"
" TOKEN: FIRST\n"
" TOKEN: SECOND\n"
Expand All@@ -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"
)
Expand All@@ -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())

Expand Down
18 changes: 13 additions & 5 deletions .github/workflows/deploy-shared.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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("^(?<user>[^@]+)@(?<host>.+)$")) as $ssh |
Expand All@@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<owner>/<repo>@latest` or `<owner>/<repo>@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: move apps to targets, support env_refs as a list by ineedjet · Pull Request #110 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.
15 changes: 2 additions & 13 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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$")


Expand DownExpand Up@@ -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}")
Expand DownExpand Up@@ -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)
Expand Down
34 changes: 3 additions & 31 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,59 +13,33 @@

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"},
{"DOMAIN": "var.example", "TIMEZONE": "Europe/Berlin"},
)
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:
path = Path(directory) / "manifest.yml"
path.write_text(
"asset: mainframe.sops.env\n"
"keys: [master, server]\n"
"apps: [traefik, rybbit]\n"
"env:\n"
" TOKEN: FIRST\n"
" TOKEN: SECOND\n"
Expand All@@ -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"
)
Expand All@@ -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())

Expand Down
18 changes: 13 additions & 5 deletions .github/workflows/deploy-shared.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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("^(?<user>[^@]+)@(?<host>.+)$")) as $ssh |
Expand All@@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<owner>/<repo>@latest` or `<owner>/<repo>@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: move apps to targets, support env_refs as a list by ineedjet · Pull Request #110 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.
15 changes: 2 additions & 13 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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$")


Expand DownExpand Up@@ -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}")
Expand DownExpand Up@@ -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)
Expand Down
34 changes: 3 additions & 31 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,59 +13,33 @@

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"},
{"DOMAIN": "var.example", "TIMEZONE": "Europe/Berlin"},
)
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:
path = Path(directory) / "manifest.yml"
path.write_text(
"asset: mainframe.sops.env\n"
"keys: [master, server]\n"
"apps: [traefik, rybbit]\n"
"env:\n"
" TOKEN: FIRST\n"
" TOKEN: SECOND\n"
Expand All@@ -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"
)
Expand All@@ -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())

Expand Down
18 changes: 13 additions & 5 deletions .github/workflows/deploy-shared.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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("^(?<user>[^@]+)@(?<host>.+)$")) as $ssh |
Expand All@@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<owner>/<repo>@latest` or `<owner>/<repo>@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: move apps to targets, support env_refs as a list by ineedjet · Pull Request #110 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.
15 changes: 2 additions & 13 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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$")


Expand DownExpand Up@@ -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}")
Expand DownExpand Up@@ -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)
Expand Down
34 changes: 3 additions & 31 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,59 +13,33 @@

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"},
{"DOMAIN": "var.example", "TIMEZONE": "Europe/Berlin"},
)
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:
path = Path(directory) / "manifest.yml"
path.write_text(
"asset: mainframe.sops.env\n"
"keys: [master, server]\n"
"apps: [traefik, rybbit]\n"
"env:\n"
" TOKEN: FIRST\n"
" TOKEN: SECOND\n"
Expand All@@ -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"
)
Expand All@@ -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())

Expand Down
18 changes: 13 additions & 5 deletions .github/workflows/deploy-shared.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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("^(?<user>[^@]+)@(?<host>.+)$")) as $ssh |
Expand All@@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<owner>/<repo>@latest` or `<owner>/<repo>@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: move apps to targets, support env_refs as a list by ineedjet · Pull Request #110 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section.
15 changes: 2 additions & 13 deletions .github/actions/encrypt-env/scripts/render-env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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$")


Expand DownExpand Up@@ -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}")
Expand DownExpand Up@@ -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)
Expand Down
34 changes: 3 additions & 31 deletions .github/actions/encrypt-env/tests/test_render_env.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,59 +13,33 @@

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"},
{"DOMAIN": "var.example", "TIMEZONE": "Europe/Berlin"},
)
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:
path = Path(directory) / "manifest.yml"
path.write_text(
"asset: mainframe.sops.env\n"
"keys: [master, server]\n"
"apps: [traefik, rybbit]\n"
"env:\n"
" TOKEN: FIRST\n"
" TOKEN: SECOND\n"
Expand All@@ -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"
)
Expand All@@ -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())

Expand Down
18 changes: 13 additions & 5 deletions .github/workflows/deploy-shared.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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("^(?<user>[^@]+)@(?<host>.+)$")) as $ssh |
Expand All@@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }}
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<owner>/<repo>@latest` or `<owner>/<repo>@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved 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
Expand Down
Loading