582 lines
25 KiB
Python
Executable File
582 lines
25 KiB
Python
Executable File
#!/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())
|