#!/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=` line — which a KEY=VALUE reader would happily return as the string "", 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())