From 512afe26ca53c27e76267877f746877738947beb Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Wed, 7 Oct 2026 11:43:02 +0200 Subject: [PATCH] feat(app_deploy): add a CI action that deploys a pushed digest through Gandalf --- LICENSE | 21 +++++ README.md | 89 +++++++++++++++++++++ action.yml | 40 ++++++++++ app_deploy.py | 167 +++++++++++++++++++++++++++++++++++++++ tests/test_app_deploy.py | 119 ++++++++++++++++++++++++++++ 5 files changed, 436 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 action.yml create mode 100644 app_deploy.py create mode 100644 tests/test_app_deploy.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11d56e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Eric Wendland + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8c2c18 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# App Deploy + +A small, dependency-free action for GitHub Actions and Forgejo Actions that +deploys a pushed container image digest through the Gandalf gateway. + +**How it works:** +1. The job's OIDC identity token proves its repository and branch, so the job needs no + secret. +2. The gateway accepts only digests of the image repository declared for the app. +3. It records the deployment and notifies the node the app is placed on. +4. The node applies it: backup gate, pull, restart, health check, and rollback on + failure. +5. This action follows the deployment and prints the node's progress. +6. It fails the job when the deployment is rolled back, fails, or gets no result within + `timeout`. + +A newer deployment of the same app that supersedes this one ends the job with a notice. + +The runner needs Bash and Python 3.11 or newer. The action builds nothing: build and +push the image first, then pass the pushed digest. + +## GitHub Actions + +```yaml +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: build + uses: docker/build-push-action@v7 + with: + push: true + tags: ghcr.io/${{ github.repository }}:latest + - name: Deploy through Gandalf + if: github.ref == 'refs/heads/main' + uses: tionis/app-deploy@ + with: + service: notes + image: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} +``` + +## Forgejo Actions + +The job needs `enable-openid-connect: true`. The build steps depend on the runner; +with Docker available: + +```yaml +jobs: + publish: + runs-on: docker + enable-openid-connect: true + steps: + - uses: https://data.forgejo.org/actions/checkout@v6 + - name: Build and push + id: build + run: | + image=forge.tionis.dev/${{ github.repository }} + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login forge.tionis.dev -u "${{ github.actor }}" --password-stdin + docker build -t "$image:latest" . + docker push "$image:latest" + echo "image=$(docker inspect --format '{{index .RepoDigests 0}}' "$image:latest")" >> "$GITHUB_OUTPUT" + - name: Deploy through Gandalf + uses: https://forge.tionis.dev/actions/app-deploy@ + with: + service: notes + image: ${{ steps.build.outputs.image }} +``` + +## Inputs + +| Input | Default | Purpose | +| --- | --- | --- | +| `service` | required | The app's service id in Gandalf (a `web_app` with a `deploy` block). | +| `image` | required | The pushed image as `repository@sha256:`. | +| `gateway` | `https://gandalf.tionis.dev` | The Gandalf gateway. | +| `timeout` | `900` | Seconds to wait for the node's result. | + +The design is in Gandalf's wiki, `admin/Reference/app-platform.md`. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..869a106 --- /dev/null +++ b/action.yml @@ -0,0 +1,40 @@ +--- +name: Deploy an app through Gandalf +description: >- + Ask the Gandalf gateway to run a pushed image digest on the node an app is + placed on, and wait until the node reports it live or rolled back. + +inputs: + service: + description: The app's service id in Gandalf (a web_app with a deploy block). + required: true + image: + description: >- + The pushed image as repository@sha256:digest, e.g. from the push step's + digest output. Only digests of the app's declared repository deploy. + required: true + gateway: + description: The Gandalf gateway. + required: false + default: https://gandalf.tionis.dev + timeout: + description: Seconds to wait for the node's result. + required: false + default: "900" + +runs: + using: composite + steps: + - name: Deploy through Gandalf + shell: bash + env: + APP_DEPLOY_SERVICE: ${{ inputs.service }} + APP_DEPLOY_IMAGE: ${{ inputs.image }} + APP_DEPLOY_GATEWAY: ${{ inputs.gateway }} + APP_DEPLOY_TIMEOUT: ${{ inputs.timeout }} + APP_DEPLOY_REVISION: ${{ github.sha }} + APP_DEPLOY_RUN: ${{ github.repository }}:${{ github.run_id }}:${{ github.run_attempt }} + run: | + set -euo pipefail + # Forgejo and GitHub name the action's checkout differently. + python3 "${FORGEJO_ACTION_PATH:-$GITHUB_ACTION_PATH}/app_deploy.py" diff --git a/app_deploy.py b/app_deploy.py new file mode 100644 index 0000000..da58b86 --- /dev/null +++ b/app_deploy.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Deploy a pushed image digest through the Gandalf gateway and wait for it. + +The job's OIDC identity token proves the repository and branch; the gateway +accepts only digests of the app's declared image repository, records the +deployment and notifies the node, which applies it with a health check and +rolls back on failure. This waits for that result and prints the node's +progress. Dependency-free; Python 3.11 or newer. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Callable + +DIGEST = re.compile(r"^[a-z0-9.-]+(?::[0-9]+)?(?:/[a-z0-9._-]+)+@sha256:[0-9a-f]{64}$") +POLL_SECONDS = 3 +FINAL = {"live", "rolled_back", "failed", "superseded"} + + +class DeployError(Exception): + pass + + +def request_identity_token(audience: str, opener=urllib.request.urlopen) -> str: + """The running workflow's OIDC identity token, from Forgejo or GitHub.""" + url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL", "") + token = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + if not url or not token: + raise DeployError( + "no workflow identity: let the job request an identity token " + "(Forgejo: enable-openid-connect: true; GitHub: permissions: id-token: write)" + ) + separator = "&" if "?" in url else "?" + request = urllib.request.Request( + f"{url}{separator}audience={urllib.parse.quote(audience, safe='')}", + headers={"Authorization": f"bearer {token}", "Accept": "application/json"}, + ) + try: + with opener(request, timeout=30) as response: + value = json.loads(response.read()).get("value") + except urllib.error.HTTPError as exc: + exc.read() + raise DeployError(f"the workflow identity token could not be requested: HTTP {exc.code}") from exc + except (urllib.error.URLError, TimeoutError, ValueError, AttributeError) as exc: + raise DeployError(f"the workflow identity token could not be requested: {exc}") from exc + if not isinstance(value, str) or not value: + raise DeployError("the CI provider returned no identity token") + return value + + +def gateway_request( + gateway: str, + path: str, + token: str, + body: dict[str, Any] | None = None, + opener=urllib.request.urlopen, +) -> dict[str, Any]: + request = urllib.request.Request( + f"{gateway.rstrip('/')}{path}", + data=None if body is None else json.dumps(body).encode(), + method="GET" if body is None else "POST", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + **({} if body is None else {"Content-Type": "application/json"}), + }, + ) + try: + with opener(request, timeout=60) as response: + return json.loads(response.read() or b"{}") + except urllib.error.HTTPError as exc: + detail = exc.read() + try: + message = str(json.loads(detail).get("error") or "") + except (UnicodeDecodeError, json.JSONDecodeError, AttributeError): + message = "" + raise DeployError(f"Gandalf {request.method} {path} failed: HTTP {exc.code}{': ' + message if message else ''}") from exc + except urllib.error.URLError as exc: + raise DeployError(f"Gandalf could not be reached: {exc.reason}") from exc + + +def deploy( + service: str, + image: str, + gateway: str, + revision: str | None, + run: str, + timeout: float, + identity: Callable[[str], str], + call: Callable[..., dict[str, Any]], + log: Callable[[str], None] = print, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, +) -> int: + """0 when live (or superseded by a newer deployment), 1 otherwise.""" + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", service): + raise DeployError(f"{service!r} is not a service id") + if not DIGEST.match(image): + raise DeployError(f"{image!r} is not repository@sha256:; deploy the pushed digest, not a tag") + audience = urllib.parse.urlsplit(gateway)._replace(path="", query="", fragment="").geturl() + if revision is not None and not re.fullmatch(r"[A-Za-z0-9._-]{1,80}", revision): + revision = None + started = call( + gateway, + f"/v1/apps/{service}/deployments", + identity(audience), + {"image": image, "revision": revision, "idempotency_key": run[:200]}, + )["deployment"] + deployment_id = started["deployment_id"] + log( + f"deployment {deployment_id}: {image} for {service} on {started['node']}" + + ("" if started.get("notified") else " (the node finds it on its next poll)") + ) + deadline = clock() + timeout + seen = None + current = started + while True: + status, detail = current["status"], current.get("detail") + if (status, detail) != seen: + seen = (status, detail) + log(f"{status}{': ' + detail if detail else ''}") + if status in FINAL: + break + if clock() >= deadline: + log(f"::error::no result from {started['node']} after {int(timeout)} s (status {status})") + return 1 + sleep(POLL_SECONDS) + # A fresh identity token for each request: they expire within minutes. + current = call( + gateway, f"/v1/apps/{service}/deployments/{deployment_id}", identity(audience) + )["deployment"] + if status == "live": + return 0 + if status == "superseded": + log("::notice::a newer deployment of this app superseded this one") + return 0 + log(f"::error::{service} was {status.replace('_', ' ')} on {started['node']}") + return 1 + + +def main() -> int: + try: + return deploy( + os.environ.get("APP_DEPLOY_SERVICE", "").strip(), + os.environ.get("APP_DEPLOY_IMAGE", "").strip(), + os.environ.get("APP_DEPLOY_GATEWAY", "https://gandalf.tionis.dev").strip(), + os.environ.get("APP_DEPLOY_REVISION", "").strip()[:40] or None, + os.environ.get("APP_DEPLOY_RUN", "manual").strip(), + float(os.environ.get("APP_DEPLOY_TIMEOUT", "900")), + request_identity_token, + gateway_request, + ) + except (DeployError, KeyError, ValueError) as exc: + print(f"app-deploy: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_app_deploy.py b/tests/test_app_deploy.py new file mode 100644 index 0000000..1a32788 --- /dev/null +++ b/tests/test_app_deploy.py @@ -0,0 +1,119 @@ +import importlib.util +from pathlib import Path +import sys + +import pytest + +MODULE_PATH = Path(__file__).resolve().parents[1] / "app_deploy.py" +SPEC = importlib.util.spec_from_file_location("app_deploy_action", MODULE_PATH) +assert SPEC and SPEC.loader +app_deploy = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = app_deploy +SPEC.loader.exec_module(app_deploy) + +IMAGE = f"forge.example/eric/notes@sha256:{'a' * 64}" + + +class FakeGateway: + """Answers the start and then the given statuses, one per poll.""" + + def __init__(self, statuses, notified=True): + self.statuses = list(statuses) + self.requests = [] + self.notified = notified + + def __call__(self, gateway, path, token, body=None): + self.requests.append((path, token, body)) + status = self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0] + detail = {"applying": "pulling", "rolled_back": "not healthy after 120 s"}.get(status) + return { + "deployment": { + "deployment_id": "d-1", + "node": "citadel", + "status": status, + "detail": detail, + "notified": self.notified, + } + } + + +class Clock: + def __init__(self): + self.now = 0.0 + + def sleep(self, seconds): + self.now += seconds + + +def run(statuses, timeout=60, **kwargs): + gateway, clock, lines, tokens = FakeGateway(statuses, **kwargs), Clock(), [], iter(range(1000)) + code = app_deploy.deploy( + "notes", + IMAGE, + "https://gandalf.example/", + "abc1234", + "eric/notes:42:1", + timeout, + lambda audience: f"token-{next(tokens)}-{audience}", + gateway, + lines.append, + clock.sleep, + lambda: clock.now, + ) + return code, gateway, lines + + +def test_a_live_deployment_succeeds_and_prints_the_progress(): + code, gateway, lines = run(["pending", "applying", "applying", "live"]) + assert code == 0 + path, token, body = gateway.requests[0] + assert path == "/v1/apps/notes/deployments" + assert body == {"image": IMAGE, "revision": "abc1234", "idempotency_key": "eric/notes:42:1"} + assert token.endswith("-https://gandalf.example") + # Each request has its own identity token; every status is printed once. + assert len({token for _path, token, _body in gateway.requests}) == len(gateway.requests) + assert gateway.requests[1][0] == "/v1/apps/notes/deployments/d-1" + assert lines[1:] == ["pending", "applying: pulling", "live"] + + +def test_a_rollback_fails_the_job_with_the_reason(): + code, _gateway, lines = run(["pending", "rolled_back"]) + assert code == 1 + assert "rolled_back: not healthy after 120 s" in lines + assert lines[-1] == "::error::notes was rolled back on citadel" + + +def test_a_superseded_deployment_ends_with_a_notice(): + code, _gateway, lines = run(["pending", "superseded"]) + assert code == 0 + assert lines[-1].startswith("::notice::") + + +def test_waiting_ends_with_the_timeout(): + code, _gateway, lines = run(["pending"], timeout=10) + assert code == 1 + assert lines[-1].startswith("::error::no result from citadel after 10 s") + + +def test_an_unnotified_node_is_mentioned(): + _code, _gateway, lines = run(["live"], notified=False) + assert "next poll" in lines[0] + + +@pytest.mark.parametrize( + "image", + [ + "forge.example/eric/notes:latest", + "forge.example/eric/notes@sha256:" + "a" * 63, + "notes@sha256:" + "a" * 64, + ], +) +def test_only_pushed_digests_deploy(image): + with pytest.raises(app_deploy.DeployError, match="digest"): + app_deploy.deploy("notes", image, "https://g", None, "r", 1, str, None) + + +def test_the_identity_token_needs_the_job_permission(monkeypatch): + monkeypatch.delenv("ACTIONS_ID_TOKEN_REQUEST_URL", raising=False) + with pytest.raises(app_deploy.DeployError, match="id-token: write"): + app_deploy.request_identity_token("https://g")