feat(app_deploy): add a CI action that deploys a pushed digest through Gandalf
This commit is contained in:
commit
512afe26ca
5 changed files with 436 additions and 0 deletions
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
89
README.md
Normal file
89
README.md
Normal file
|
|
@ -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@<commit-sha>
|
||||
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@<commit-sha>
|
||||
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:<digest>`. |
|
||||
| `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`.
|
||||
40
action.yml
Normal file
40
action.yml
Normal file
|
|
@ -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"
|
||||
167
app_deploy.py
Normal file
167
app_deploy.py
Normal file
|
|
@ -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:<digest>; 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())
|
||||
119
tests/test_app_deploy.py
Normal file
119
tests/test_app_deploy.py
Normal file
|
|
@ -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")
|
||||
Loading…
Reference in a new issue