Project-Template/docs/architecture/scripts/deploy.py

582 lines
25 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
Deploy automation: update a running stack to a newly published image.
Builds a Docker image from the project directory, tags it with an incremented
version and `latest`, pushes it to the Forgejo Docker registry, then updates the
configured Portainer stack to that image and verifies the container is running
and healthy.
## This is the only copy
It used to exist twice here and in the `privacyllc-deploy` skill and the two
drifted apart until neither was a subset of the other (#209). The skill copy had
the credential resolution and the two safety refusals below; this one had the
environment-driven configuration. A defect fixed in one stayed live in the
other, behind a closed issue, because nothing said which was authoritative.
The skill's copy is now a symlink to this file. Anything project-specific must
therefore arrive through the environment, never as a literal: a script copied
from another project must not silently point a deploy at that project's stack —
or, just as quietly, bake that project's canonical origin into somebody else's
image.
Credentials:
- Forgejo registry token: ~/.openclaw/docker-registry.env
- Portainer API key: PORTAINER_API_KEY, or the credential file the operator
keeps Portainer keys in which is the file named after Portainer, not the
one named after whichever project you are deploying. Looking in the wrong
one is what made every deploy on the source project manual for weeks (#137):
the key was on the machine the whole time, and the error said it was
missing.
Usage:
export PORTAINER_API_URL="https://192.168.1.11:9443/api"
python3 deploy.py [--project-dir PATH] [--portainer-stack-id ID]
[--dry-run] [--build-only] [--skip-push] [--skip-portainer]
"""
import argparse
import json
import os
import re
import subprocess
import sys
import traceback
from pathlib import Path
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ---------------------------------------------------------------------------
# Everything about a particular deployment lives here, and nowhere else.
#
# Set these before the first run. They are read from the environment so a
# checkout carries no deployment's identity — the same reason `release.sh` takes
# its image from RELEASE_IMAGE rather than hard-coding one: a script copied from
# another project must not silently point a deploy at that project's stack.
#
# DEPLOY_PROJECT_DIR the repository to build from
# DEPLOY_IMAGE fully-qualified image name, no tag
# DEPLOY_STACK_ID the Portainer stack that runs it
# DEPLOY_CONTAINER the container name, for the post-deploy check
# DEPLOY_SITE_URL public origin, baked into the image at build time
# DEPLOY_LABEL what to call this thing in output (cosmetic)
# PORTAINER_API_URL e.g. https://portainer.example.com:9443/api
# ---------------------------------------------------------------------------
PROJECT_DIR_DEFAULT = os.environ.get("DEPLOY_PROJECT_DIR", os.getcwd())
FORGEJO_IMAGE = os.environ.get("DEPLOY_IMAGE", "")
FORGEJO_REGISTRY = FORGEJO_IMAGE.split("/")[0] if FORGEJO_IMAGE else ""
PORTAINER_STACK_ID_DEFAULT = int(os.environ.get("DEPLOY_STACK_ID", "0") or 0)
CONTAINER_NAME = os.environ.get("DEPLOY_CONTAINER", "")
# The canonical origin is frozen into the image at build time — every canonical
# URL, the sitemap and robots.txt — and cannot be corrected without another
# build. Both copies of this script hard-coded one project's origin, so
# deploying any other project from it would have baked the wrong one in and
# nothing would have failed. Required, rather than defaulted, for that reason.
SITE_URL = os.environ.get("DEPLOY_SITE_URL", "")
# Cosmetic only: what the [DONE] line calls this. Nothing branches on it.
DEPLOY_LABEL = os.environ.get("DEPLOY_LABEL", "site")
def load_env_file(path: Path) -> dict:
"""Read KEY=VALUE lines, ignoring blank lines and comments."""
values = {}
if not path.exists():
return values
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def load_privacyllc_credentials() -> dict:
"""Load the privacyllc credential file (KEY=VALUE format)."""
path = Path.home() / ".openclaw" / "credentials" / "privacyllc.md"
return load_env_file(path)
def load_registry_credentials() -> dict:
"""Load the docker registry credential file."""
path = Path.home() / ".openclaw" / "docker-registry.env"
return load_env_file(path)
def get_git_version(project_dir: Path) -> str:
"""Derive the next release version from the latest release commit message.
Release commits are of the form `chore(release): vX.Y.Z`. Returns the next
patch increment (vX.Y.Z+1).
"""
result = subprocess.run(
["git", "log", "--oneline", "--grep=chore(release): v", "-1"],
cwd=project_dir,
capture_output=True,
text=True,
check=True,
)
line = result.stdout.strip()
if not line:
raise RuntimeError("No release commit found; cannot auto-increment version")
match = re.search(r"v(\d+)\.(\d+)\.(\d+)", line)
if not match:
raise RuntimeError(f"Could not parse version from commit: {line}")
major, minor, patch = map(int, match.groups())
return f"v{major}.{minor}.{patch + 1}"
def run(cmd: list[str], cwd: Path | None = None, env: dict | None = None) -> subprocess.CompletedProcess:
"""Run a shell command and return the result."""
merged_env = os.environ.copy()
if env:
merged_env.update(env)
print(f"[RUN] {' '.join(cmd)}")
return subprocess.run(cmd, cwd=cwd, env=merged_env, text=True, capture_output=True)
def build_image(project_dir: Path, version: str, dry_run: bool) -> None:
"""Build the Docker image with the incremented version tag."""
full_tag = f"{FORGEJO_IMAGE}:{version}"
latest_tag = f"{FORGEJO_IMAGE}:latest"
if dry_run:
# Checked here too, so --dry-run reports a missing origin instead of
# printing a confident "would build" for an image that would be wrong.
_require_site_url()
print(f"[DRY-RUN] would build image {full_tag} and {latest_tag}")
return
_require_site_url()
# Clean build to avoid stale Next.js output layers.
print("[BUILD] Cleaning previous build artifacts...")
run(["rm", "-rf", ".next"], cwd=project_dir)
print(f"[BUILD] Building {full_tag}...")
build_result = run(
[
"docker",
"build",
"--no-cache",
"--build-arg",
f"NEXT_PUBLIC_SITE_URL={SITE_URL}",
# The Dockerfile declares ARG APP_VERSION and bakes it into
# ENV APP_VERSION and org.opencontainers.image.version. Omitting it
# here built images with an EMPTY version label, which is not
# cosmetic: buildVersion() then returns null so the admin screen
# reports no running build, and release.sh's deployed_version()
# reads that same label to decide which published tag a prune may
# not delete — an empty label makes prune fail-safe but blind.
#
# A published tag is immutable here (release.sh refuses to move
# one), so an image pushed without the label could never be
# corrected, only abandoned at the cost of a burned version number.
#
# release.sh has always passed this and additionally verifies the
# baked value with printenv before it will push. This is the same
# build arg; the verification is not duplicated because this script
# deploys an existing version rather than cutting one.
"--build-arg",
f"APP_VERSION={version}",
"-t",
full_tag,
"-t",
latest_tag,
".",
],
cwd=project_dir,
)
if build_result.returncode != 0:
print(build_result.stderr)
raise RuntimeError("Docker build failed")
print(build_result.stdout)
def push_image(version: str, dry_run: bool, skip: bool) -> None:
"""Push the version and latest tags to the Forgejo registry."""
if skip:
print("[SKIP] image push disabled by --skip-push")
return
full_tag = f"{FORGEJO_IMAGE}:{version}"
latest_tag = f"{FORGEJO_IMAGE}:latest"
if dry_run:
print(f"[DRY-RUN] would push {full_tag} and {latest_tag}")
return
creds = load_registry_credentials()
registry = creds.get("FORGEJO_REGISTRY", FORGEJO_REGISTRY)
user = creds.get("FORGEJO_REGISTRY_USER", "null")
token = creds.get("FORGEJO_REGISTRY_TOKEN")
if not token:
raise RuntimeError("FORGEJO_REGISTRY_TOKEN not found in ~/.openclaw/docker-registry.env")
print("[PUSH] Logging in to Forgejo registry...")
login = run(
["docker", "login", registry, "-u", user, "--password-stdin"],
env={"DOCKER_CONFIG": os.environ.get("DOCKER_CONFIG", "")},
)
if login.returncode != 0:
# docker login --password-stdin reads from stdin; pass token via input
login = subprocess.run(
["docker", "login", registry, "-u", user, "--password-stdin"],
input=token,
text=True,
capture_output=True,
)
if login.returncode != 0:
print(login.stderr)
raise RuntimeError("Docker login failed")
for tag in (full_tag, latest_tag):
print(f"[PUSH] Pushing {tag}...")
push = run(["docker", "push", tag])
if push.returncode != 0:
print(push.stderr)
raise RuntimeError(f"Docker push failed for {tag}")
print(push.stdout)
print("[PUSH] Logging out from Forgejo registry...")
run(["docker", "logout", registry])
def load_portainer_key(host: str = "nebula") -> str | None:
"""Read one host's Portainer API key out of the credential file.
The key lives here, not in privacyllc.md. Looking only in the latter is
what made every deploy manual for weeks: the credential was on the machine
the whole time, in the file named after the service it belongs to, while
the error said it was "not available".
Parsed rather than run through `load_env_file`, for two reasons. The file
is prose with markdown headings, not KEY=VALUE. And it documents the
variable by example a literal `PORTAINER_API_KEY=<the-key>` line which
a KEY=VALUE reader would happily return as the string "<the-key>", giving a
401 that looks like a revoked key rather than a parse mistake.
It also holds more than one host. Sections are `### Portainer (nebula)` and
`### Portainer (exodus)`; this takes the key under the one asked for, so a
deploy cannot authenticate against the wrong machine.
"""
path = Path.home() / ".openclaw" / "credentials" / "portainer.md"
if not path.exists():
return None
section = None
for line in path.read_text(encoding="utf-8").splitlines():
heading = re.match(r"^#{1,6}\s*Portainer\s*\(([^)]+)\)", line.strip())
if heading:
section = heading.group(1).strip().lower()
continue
if section != host.lower():
continue
found = re.search(r"`(ptr_[A-Za-z0-9+/=_-]+)`", line)
if found:
return found.group(1)
return None
def portainer_headers() -> dict:
"""Return request headers with the Portainer API key.
Four places, in the order somebody would expect them to win: an explicit
environment variable, then either credential file. Both env spellings are
honoured because the error message below names both, and promising a
variable the code never reads is how this failed silently exporting
PORTAINER_API_KEY, the name portainer.md itself documents, did nothing.
"""
token = (
os.environ.get("PORTAINER_API_KEY")
or os.environ.get("PORTAINER_API_TOKEN")
or load_portainer_key()
or load_privacyllc_credentials().get("PORTAINER_API_KEY")
)
if not token:
raise RuntimeError(
"PORTAINER_API_KEY/PORTAINER_API_TOKEN not available. Looked in the "
"environment, ~/.openclaw/credentials/portainer.md and "
"~/.openclaw/credentials/privacyllc.md."
)
return {"X-API-Key": token, "Content-Type": "application/json"}
def portainer_api_url() -> str:
"""Return the Portainer API base URL from environment or default."""
url = os.environ.get("PORTAINER_API_URL", "https://192.168.1.11:9443/api")
return url.rstrip("/")
def update_portainer_stack(version: str, stack_id: int, dry_run: bool, skip: bool) -> None:
"""Update the Portainer stack image line and trigger a redeploy."""
if skip:
print("[SKIP] Portainer update disabled by --skip-portainer")
return
base_url = portainer_api_url()
headers = portainer_headers()
# Fetch current stack file.
file_url = f"{base_url}/stacks/{stack_id}/file"
import requests
file_resp = requests.get(file_url, headers=headers, verify=False, timeout=30)
file_resp.raise_for_status()
compose_text = file_resp.json().get("StackFileContent", "")
if not compose_text:
raise RuntimeError("Portainer stack file content is empty")
# Replace the image line's tag, keeping the line's indentation.
#
# The previous pattern matched `^\s*image:` — indentation included — and
# replaced the whole match with an unindented line. The image line landed at
# column 0, the YAML stopped parsing ("mapping values are not allowed in
# this context"), and Portainer answered 500 before its compose engine ever
# ran — which is why its log carried no entry for the failed update while a
# hand-edit through the UI, indentation intact, deployed fine. Reproduced
# locally against the real stack file and confirmed with
# `docker compose config` before this was written.
#
# `[ \t]*` rather than `\s*`, because `\s` matches newlines and could crawl
# up through blank lines above the target.
image_line = re.compile(
r"^([ \t]*)image:[ \t]*" + re.escape(FORGEJO_IMAGE) + r":(\S+)[ \t]*$",
flags=re.MULTILINE,
)
found = image_line.search(compose_text)
if not found:
raise RuntimeError(
f"Could not find image line for {FORGEJO_IMAGE} in Portainer stack file"
)
if found.group(2) == version:
# Distinguished from "not found": redeploying the version already pinned
# is a no-op somebody may have meant, not a malformed stack file.
print(f"[PORTAINER] stack already pins {version}; sending it unchanged")
updated_compose = image_line.sub(
lambda m: f"{m.group(1)}image: {FORGEJO_IMAGE}:{version}",
compose_text,
)
# The whole point of the last failure: prove the result still parses as
# YAML before it goes anywhere near the stack. `docker compose config`
# validates against the real schema; if the binary is missing the check is
# skipped with a warning rather than silently passed.
import shutil, subprocess, tempfile
if shutil.which("docker"):
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
handle.write(updated_compose)
probe_path = handle.name
# --no-interpolate: the stack's variables live in Portainer's Env, not
# in this shell, and `${TURNSTILE_SECRET:?}`-style references would fail
# interpolation here while being perfectly valid there. Structure is
# what the last failure corrupted and structure is checked either way —
# the de-indented file still fails this exact command.
probe = subprocess.run(
["docker", "compose", "-f", probe_path, "config", "--no-interpolate", "-q"],
capture_output=True, text=True, timeout=60,
)
Path(probe_path).unlink(missing_ok=True)
if probe.returncode != 0:
raise RuntimeError(
"The transformed compose does not validate; refusing to send it to "
f"Portainer. docker compose says: {probe.stderr.strip()[:300]}"
)
else:
print("[WARN] docker not on PATH here; skipping compose validation of the transformed file")
# Fetch stack metadata to get endpoint ID.
stack_url = f"{base_url}/stacks/{stack_id}"
stack_resp = requests.get(stack_url, headers=headers, verify=False, timeout=30)
stack_resp.raise_for_status()
stack_data = stack_resp.json()
endpoint_id = stack_data.get("EndpointId")
if not endpoint_id:
raise RuntimeError("Portainer stack response missing EndpointId")
if dry_run:
print(f"[DRY-RUN] would update Portainer stack {stack_id} on endpoint {endpoint_id}")
print(updated_compose)
return
# Update the stack via Portainer API. Preserve existing env vars to avoid
# wiping secrets (POSTGRES_PASSWORD, OIDC_CLIENT_SECRET, etc.).
#
# A stack update REPLACES the definition, so whatever is not sent back is
# gone. This is a round trip of what the GET returned, never a rebuild.
env_vars = stack_data.get("Env") or []
# An empty list is not a stack without variables; it is far more likely a
# read that did not return them — a Portainer version that moved the field,
# a partial response, a token whose scope quietly narrowed. Sending it would
# blank every secret the stack holds, and one of them cannot be recovered by
# re-entering it: without CONTACT_ENCRYPTION_KEY every contact message,
# brainstorming note, document and business detail is permanently
# unreadable. `preflightRestore` refuses a restore for this same reason.
#
# So "I could not read them" and "there are none" are kept apart, which is
# the distinction this deployment refuses to collapse anywhere else. The
# escape hatch exists for a stack that genuinely has none, and it has to be
# asked for by name rather than being what happens by default.
if not env_vars and not os.environ.get("DEPLOY_ALLOW_EMPTY_ENV"):
raise RuntimeError(
f"Portainer stack {stack_id} returned no environment variables. Refusing to "
"update, because sending an empty list would erase every variable the stack "
"holds — including CONTACT_ENCRYPTION_KEY, which cannot be recovered by "
"typing it again. Check the stack in Portainer. If it genuinely has none, "
"re-run with DEPLOY_ALLOW_EMPTY_ENV=1."
)
payload_env = [{"name": e.get("name"), "value": e.get("value")} for e in env_vars]
# Names only. The values are secrets and this prints to a terminal and a log.
print(f"[PORTAINER] carrying {len(payload_env)} env vars: "
f"{' '.join(sorted(e['name'] for e in payload_env if e.get('name')))}")
update_url = f"{base_url}/stacks/{stack_id}?endpointId={endpoint_id}"
payload = {"stackFileContent": updated_compose, "env": payload_env, "prune": False}
update_resp = requests.put(update_url, headers=headers, json=payload, verify=False, timeout=120)
if update_resp.status_code >= 400:
# The body is where Portainer puts the reason, and discarding it is what
# turned the indentation bug into a day of guesswork. Truncated, and it
# carries Portainer's own message rather than anything from the stack's
# environment.
raise RuntimeError(
f"Portainer answered {update_resp.status_code} updating stack {stack_id}: "
f"{update_resp.text[:500]}"
)
print(f"[PORTAINER] Stack {stack_id} updated to {FORGEJO_IMAGE}:{version}")
def verify_container(endpoint_id: int, dry_run: bool) -> dict:
"""Check that the container is running and healthy via Portainer."""
if dry_run:
print(f"[DRY-RUN] would verify container {CONTAINER_NAME} on endpoint {endpoint_id}")
return {"State": "DryRun"}
base_url = portainer_api_url()
headers = portainer_headers()
import requests
url = f"{base_url}/endpoints/{endpoint_id}/docker/containers/json"
resp = requests.get(url, headers=headers, verify=False, timeout=30)
resp.raise_for_status()
containers = resp.json()
for container in containers:
names = container.get("Names", [])
if any(name.lstrip("/") == CONTAINER_NAME for name in names):
state = container.get("State", "")
status = container.get("Status", "")
print(f"[VERIFY] {CONTAINER_NAME}: state={state}, status={status}")
if state != "running":
raise RuntimeError(f"Container {CONTAINER_NAME} is not running (state={state})")
if "healthy" not in status.lower() and "(unhealthy)" in status.lower():
raise RuntimeError(f"Container {CONTAINER_NAME} is unhealthy: {status}")
return container
raise RuntimeError(f"Container {CONTAINER_NAME} not found on endpoint {endpoint_id}")
def _require_config() -> None:
"""Refuse a half-configured run.
The failure this prevents is quiet and expensive: an unset image name makes
every tag `":v1.2.3"`, and an unset stack id makes the update target stack
0. Neither raises anything obviously about configuration, and one of them
could update somebody else's stack.
DEPLOY_SITE_URL is required for the same class of reason but a worse
outcome: it is frozen into the image at build time, so an empty or wrong
value cannot be corrected without another build, and nothing fails at
deploy time to tell you. Only checked when a build will actually happen
--skip-* runs that never build have no origin to bake.
"""
missing = [
name
for name, value in (
("DEPLOY_IMAGE", FORGEJO_IMAGE),
("DEPLOY_STACK_ID", PORTAINER_STACK_ID_DEFAULT),
("DEPLOY_CONTAINER", CONTAINER_NAME),
)
if not value
]
if missing:
raise SystemExit(
"[ERROR] not configured: " + ", ".join(missing) + "\n"
" See the header of this file; a deploy script that guesses "
"its target is one that deploys to the wrong place."
)
def _require_site_url() -> None:
"""Refuse to build without the origin that will be frozen into the image."""
if not SITE_URL:
raise SystemExit(
"[ERROR] not configured: DEPLOY_SITE_URL\n"
" It is baked into every canonical URL, the sitemap and "
"robots.txt at build time and cannot be corrected afterwards "
"without another build."
)
def main() -> int:
parser = argparse.ArgumentParser(description="Deploy a stack to a newly built image")
parser.add_argument("--project-dir", default=PROJECT_DIR_DEFAULT, help="Path to the project to build")
parser.add_argument("--portainer-stack-id", type=int, default=PORTAINER_STACK_ID_DEFAULT, help="Portainer stack ID")
parser.add_argument("--dry-run", action="store_true", help="Print actions without executing")
parser.add_argument("--build-only", action="store_true", help="Build only; do not push or update Portainer")
parser.add_argument("--skip-push", action="store_true", help="Skip pushing image to registry")
parser.add_argument("--skip-portainer", action="store_true", help="Skip Portainer stack update")
parser.add_argument("--version", help="Override the auto-incremented version tag")
args = parser.parse_args()
_require_config()
project_dir = Path(args.project_dir).expanduser().resolve()
try:
version = args.version or get_git_version(project_dir)
print(f"[DEPLOY] Target version: {version}")
build_image(project_dir, version, args.dry_run)
if args.build_only:
print("[DONE] Build-only requested; stopping after build.")
return 0
push_image(version, args.dry_run, args.skip_push)
update_portainer_stack(version, args.portainer_stack_id, args.dry_run, args.skip_portainer)
if not args.skip_portainer:
# Re-fetch endpoint ID for verification if we did not update Portainer.
import requests
base_url = portainer_api_url()
headers = portainer_headers()
stack_resp = requests.get(
f"{base_url}/stacks/{args.portainer_stack_id}", headers=headers, verify=False, timeout=30
)
stack_resp.raise_for_status()
endpoint_id = stack_resp.json().get("EndpointId")
verify_container(endpoint_id, args.dry_run)
print(f"[DONE] {DEPLOY_LABEL} deployed as {FORGEJO_IMAGE}:{version}")
return 0
except Exception as exc:
print(f"[ERROR] {exc}")
if os.environ.get("DEPLOY_DEBUG"):
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())