From d100a2102047d1efa69aec4cb569724fda64e123 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 14 Jul 2026 12:10:44 +0300 Subject: [PATCH] Initial commit --- .env.example | 13 ++ .gitignore | 4 + Dockerfile | 20 +++ README.md | 31 ++++ compose.yml | 15 ++ requirements.txt | 1 + updater/__init__.py | 2 + updater/__main__.py | 3 + updater/git_remote.py | 378 +++++++++++++++++++++++++++++++++++++++ updater/github_client.py | 252 ++++++++++++++++++++++++++ updater/main.py | 32 ++++ 11 files changed, 751 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yml create mode 100644 requirements.txt create mode 100644 updater/__init__.py create mode 100644 updater/__main__.py create mode 100644 updater/git_remote.py create mode 100644 updater/github_client.py create mode 100644 updater/main.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bdd4b85 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +GITHUB_TOKEN= +GIT_TOKEN= +BACKEND_REPO=https://github.com/fromchat-messenger/backend.git +WEB_REPO=https://github.com/fromchat-messenger/web.git +DEPLOYMENT_REPO=https://github.com/fromchat-messenger/app.git +COMPOSE_PROJECT_DIR=/home/user/fromchat-server +FROMCHAT_COMPONENTS=backend,frontend,caddy +CHECK_INTERVAL_SECONDS=60 + +# Gitea-only example (no GitHub fallback needed): +# BACKEND_REPO=https://git.fromchat.ru/FromChat/backend.git +# WEB_REPO=https://git.fromchat.ru/FromChat/web.git +# DEPLOYMENT_REPO=https://git.fromchat.ru/FromChat/app.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc36f7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +*.pyc +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1adb157 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +RUN apt-get update -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + python3-yaml ca-certificates docker.io \ + && rm -rf /var/lib/apt/lists/* + +# Uses host Docker Engine via mounted /var/run/docker.sock (see compose.yml). + +WORKDIR /app + +COPY requirements.txt . +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements.txt + +COPY updater/ ./updater/ + +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "-m", "updater"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..59c6f56 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# FromChat Updater + +Watches GitHub **tags** and **container packages** for backend + frontend repos. +When a newer semver tag exists in both and all required `fromchat/*` images are published, +regenerates `compose.yml` (via the deployment merge script) and restarts the stack. + +## Environment (`.env`) + +| Variable | Description | +|----------|-------------| +| `GITHUB_TOKEN` | PAT with `read:packages` + `repo` | +| `BACKEND_REPO` | e.g. `https://github.com/fromchat-messenger/backend.git` | +| `WEB_REPO` | e.g. `https://github.com/fromchat-messenger/web.git` | +| `DEPLOYMENT_REPO` | `https://github.com/fromchat-messenger/app.git` (deployment scripts) | +| `DEPLOYMENT_REPO` | Deployment repo for `generate-compose.py` | +| `COMPOSE_PROJECT_DIR` | Host path to the stack (`compose.yml` parent directory) | +| `FROMCHAT_COMPONENTS` | Comma list: `backend,frontend,caddy,updater` | +| `CHECK_INTERVAL_SECONDS` | Poll interval (default `60`) | + +Only `compose.yml` from the stack directory is mounted into the updater container (read-write at `/fromchat/compose.yml`). The current release is read from image tags inside that file. + +Create token: [GitHub PAT (read:packages + repo)](https://github.com/settings/tokens/new?description=FromChat%20Updater&scopes=read:packages,repo) + +## Run standalone + +```bash +cd ~/fromchat-server/updater +docker compose --env-file .env up -d +``` + +The installer sets this up automatically when **updater** is selected. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..08da996 --- /dev/null +++ b/compose.yml @@ -0,0 +1,15 @@ +services: + updater: + image: fromchat/updater:latest + restart: unless-stopped + env_file: + - .env + volumes: + - ${COMPOSE_PROJECT_DIR}/compose.yml:/fromchat/compose.yml:rw + - /var/run/docker.sock:/var/run/docker.sock + networks: + - updater + +networks: + updater: + driver: bridge diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3aecde9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyyaml>=6.0 diff --git a/updater/__init__.py b/updater/__init__.py new file mode 100644 index 0000000..57c4cfb --- /dev/null +++ b/updater/__init__.py @@ -0,0 +1,2 @@ +"""FromChat auto-updater: watches GitHub tags + packages and rolls forward releases.""" +__version__ = "0.1.0" diff --git a/updater/__main__.py b/updater/__main__.py new file mode 100644 index 0000000..8f074c5 --- /dev/null +++ b/updater/__main__.py @@ -0,0 +1,3 @@ +from updater.main import main + +main() diff --git a/updater/git_remote.py b/updater/git_remote.py new file mode 100644 index 0000000..26cd0c9 --- /dev/null +++ b/updater/git_remote.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +""" +GitHub + Gitea (git.fromchat.ru) helpers for FromChat deployment/updater. + +Official GitHub: github.com/fromchat-messenger/{backend,web,app} +Gitea: git.fromchat.ru/FromChat/{same repo name} + +Official GitHub URLs fall back to Gitea when GitHub fails. +URLs already on git.fromchat.ru use Gitea APIs directly. +""" +from __future__ import annotations + +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request + +SEMVER = re.compile(r"^v(\d+)\.(\d+)(?:\.(\d+))?$") + +GITHUB_ORG = "fromchat-messenger" +GITEA_ORG = "FromChat" +GITHUB_OFFICIAL_PREFIX = f"https://github.com/{GITHUB_ORG}/" +GITEA_BASE = "https://git.fromchat.ru" + +OFFICIAL_GITHUB_REPOS = frozenset( + { + f"https://github.com/{GITHUB_ORG}/backend.git", + f"https://github.com/{GITHUB_ORG}/web.git", + f"https://github.com/{GITHUB_ORG}/app.git", + } +) + + +def normalize_repo_url(url: str) -> str: + u = url.rstrip("/") + if u.endswith(".git"): + return u + return u + ".git" + + +def is_gitea_repo(url: str) -> bool: + return "git.fromchat.ru" in normalize_repo_url(url) + + +def is_official_github_repo(url: str) -> bool: + normalized = normalize_repo_url(url) + return normalized in OFFICIAL_GITHUB_REPOS or GITHUB_OFFICIAL_PREFIX in normalized + + +def gitea_owner_repo(github_owner: str, repo: str) -> tuple[str, str]: + """Map github.com/fromchat-messenger/{repo} -> git.fromchat.ru/FromChat/{repo}.""" + if github_owner == GITEA_ORG: + return github_owner, repo + if github_owner == GITHUB_ORG or is_official_github_repo( + f"https://github.com/{github_owner}/{repo}.git" + ): + return GITEA_ORG, repo + return github_owner, repo + + +def parse_slug(url: str) -> tuple[str, str]: + u = url.rstrip("/").removesuffix(".git") + for marker in ("github.com/", "git.fromchat.ru/"): + if marker in u: + slug = u.split(marker, 1)[1] + owner, repo = slug.split("/", 1) + return owner, repo + if "/" in u and "://" not in u: + owner, repo = u.split("/", 1) + return owner, repo + raise ValueError(f"Cannot parse repository slug from {url!r}") + + +def _auth_headers(token: str | None, *, gitea: bool = False) -> dict[str, str]: + headers = {"User-Agent": "fromchat-git-remote"} + if not token: + return headers + if gitea: + # Gitea accepts both schemes; token is the documented form. + headers["Authorization"] = f"token {token}" + else: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _http_get(url: str, token: str | None, *, gitea: bool = False) -> bytes: + req = urllib.request.Request( + url, + headers={ + **_auth_headers(token, gitea=gitea), + **({"Accept": "application/vnd.github+json"} if not gitea else {}), + }, + ) + with urllib.request.urlopen(req, timeout=45) as resp: + return resp.read() + + +def github_raw_url(owner: str, repo: str, ref: str, path: str) -> str: + return f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path.lstrip('/')}" + + +def gitea_api_raw_url(gitea_owner: str, gitea_repo: str, ref: str, path: str) -> str: + """Gitea API raw file (supports branch/tag/commit via ?ref=).""" + filepath = urllib.parse.quote(path.lstrip("/"), safe="/") + ref_q = urllib.parse.quote(ref, safe="") + return ( + f"{GITEA_BASE}/api/v1/repos/{gitea_owner}/{gitea_repo}/raw/{filepath}?ref={ref_q}" + ) + + +def gitea_web_raw_urls(gitea_owner: str, gitea_repo: str, ref: str, path: str) -> list[str]: + """Legacy/browser raw paths (tried if API raw fails).""" + p = path.lstrip("/") + ref_q = urllib.parse.quote(ref, safe="") + return [ + f"{GITEA_BASE}/{gitea_owner}/{gitea_repo}/raw/{ref_q}/{p}", + f"{GITEA_BASE}/{gitea_owner}/{gitea_repo}/raw/tag/{ref_q}/{p}", + ] + + +def fetch_gitea_raw( + gitea_owner: str, + gitea_repo: str, + ref: str, + path: str, + token: str | None, +) -> bytes: + urls = [gitea_api_raw_url(gitea_owner, gitea_repo, ref, path)] + urls.extend(gitea_web_raw_urls(gitea_owner, gitea_repo, ref, path)) + + last_error: Exception | None = None + for url in urls: + try: + return _http_get(url, token, gitea=True) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc: + last_error = exc + raise RuntimeError( + f"Failed to fetch {path} from git.fromchat.ru/{gitea_owner}/{gitea_repo}@{ref}" + ) from last_error + + +def fetch_raw_file(repo_url: str, ref: str, path: str, token: str | None = None) -> bytes: + owner, repo = parse_slug(repo_url) + + if is_gitea_repo(repo_url): + gitea_owner, gitea_repo = gitea_owner_repo(owner, repo) + return fetch_gitea_raw(gitea_owner, gitea_repo, ref, path, token) + + try: + return _http_get(github_raw_url(owner, repo, ref, path), token, gitea=False) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as github_err: + if not is_official_github_repo(repo_url): + raise RuntimeError( + f"Failed to fetch {path} from {repo_url}@{ref}" + ) from github_err + gitea_owner, gitea_repo = gitea_owner_repo(owner, repo) + print( + f"GitHub unavailable for {owner}/{repo}, " + f"trying git.fromchat.ru/{gitea_owner}/{gitea_repo}…", + file=sys.stderr, + ) + return fetch_gitea_raw(gitea_owner, gitea_repo, ref, path, token) + + +def _tags_from_github(owner: str, repo: str, token: str | None) -> set[str]: + data = json.loads( + _http_get( + f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", + token, + gitea=False, + ).decode() + ) + return {item["name"] for item in data if SEMVER.fullmatch(item.get("name", ""))} + + +def _gitea_paginated_json(path: str, token: str | None) -> list[dict]: + items: list[dict] = [] + page = 1 + while True: + sep = "&" if "?" in path else "?" + url = f"{GITEA_BASE}{path}{sep}page={page}&limit=50" + chunk = json.loads(_http_get(url, token, gitea=True).decode()) + if not isinstance(chunk, list): + break + if not chunk: + break + items.extend(chunk) + if len(chunk) < 50: + break + page += 1 + return items + + +def _tags_from_gitea(gitea_owner: str, gitea_repo: str, token: str | None) -> set[str]: + out: set[str] = set() + + for item in _gitea_paginated_json( + f"/api/v1/repos/{gitea_owner}/{gitea_repo}/tags", + token, + ): + name = item.get("name", "") + if SEMVER.fullmatch(name): + out.add(name) + + if out: + return out + + # Fallback: release tags + for item in _gitea_paginated_json( + f"/api/v1/repos/{gitea_owner}/{gitea_repo}/releases", + token, + ): + name = item.get("tag_name") or item.get("tag") or "" + if SEMVER.fullmatch(name): + out.add(name) + + return out + + +def fetch_semver_tags(repo_url: str, token: str | None = None) -> set[str]: + owner, repo = parse_slug(repo_url) + + if is_gitea_repo(repo_url): + gitea_owner, gitea_repo = gitea_owner_repo(owner, repo) + return _tags_from_gitea(gitea_owner, gitea_repo, token) + + try: + return _tags_from_github(owner, repo, token) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError): + if not is_official_github_repo(repo_url): + raise + gitea_owner, gitea_repo = gitea_owner_repo(owner, repo) + print( + f"GitHub tags unavailable for {owner}/{repo}, " + f"trying git.fromchat.ru/{gitea_owner}/{gitea_repo}…", + file=sys.stderr, + ) + return _tags_from_gitea(gitea_owner, gitea_repo, token) + + +def semver_key(tag: str) -> tuple[int, ...]: + m = SEMVER.match(tag) + if not m: + raise ValueError(tag) + return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)) + + +def resolve_common_tag(repo_urls: list[str], token: str | None = None) -> str: + sets = [fetch_semver_tags(url, token) for url in repo_urls] + common = set.intersection(*sets) if sets else set() + if not common: + raise RuntimeError("No common semver tag (vX.Y or vX.Y.Z) found across repos.") + return max(common, key=semver_key) + + +def _image_tag_matches(needle: str, tag: str, candidates: list[str]) -> bool: + n = needle.lstrip("v") + t = tag.lstrip("v") + for c in candidates: + if not c: + continue + c = str(c) + if c in (tag, needle, t, n, f"v{n}"): + return True + return False + + +def package_exists_github(github_owner: str, package: str, tag: str, token: str) -> bool: + short = package.split("/", 1)[-1] + url = ( + f"https://api.github.com/users/{github_owner}/packages/container/{short}/versions" + "?per_page=20" + ) + try: + data = json.loads(_http_get(url, token, gitea=False).decode()) + except (urllib.error.HTTPError, urllib.error.URLError): + return False + if not isinstance(data, list): + return False + for version in data: + meta = version.get("metadata", {}).get("container", {}) + tags = meta.get("tags") or [] + names = version.get("name") or version.get("version") or "" + if _image_tag_matches(tag, tag, list(tags) + [names]): + return True + return False + + +def package_exists_gitea(gitea_owner: str, package: str, tag: str, token: str) -> bool: + short = package.split("/", 1)[-1] + path = f"/api/v1/packages/{gitea_owner}/container/{short}/versions" + try: + versions = _gitea_paginated_json(path, token) + except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError): + return False + + for version in versions: + names: list[str] = [] + for key in ("version", "name"): + val = version.get(key) + if isinstance(val, str): + names.append(val) + metadata = version.get("metadata") or {} + if isinstance(metadata, dict): + container = metadata.get("container") or {} + if isinstance(container, dict): + names.extend(str(t) for t in (container.get("tags") or [])) + tags_field = metadata.get("tags") + if isinstance(tags_field, list): + names.extend(str(t) for t in tags_field) + if _image_tag_matches(tag, tag, names): + return True + return False + + +def package_version_exists( + github_owner: str, + package: str, + tag: str, + token: str, + *, + official: bool = True, +) -> bool: + gitea_owner, _ = gitea_owner_repo(github_owner, package.split("/", 1)[-1]) + + if github_owner == GITEA_ORG: + return package_exists_gitea(gitea_owner, package, tag, token) + + if package_exists_github(github_owner, package, tag, token): + return True + if not official: + return False + if package_exists_gitea(gitea_owner, package, tag, token): + print( + f"GitHub packages unavailable for {package}, confirmed on git.fromchat.ru", + file=sys.stderr, + ) + return True + return False + + +def resolve_git_token() -> str | None: + import os + + return os.environ.get("GIT_TOKEN") or os.environ.get("GITHUB_TOKEN") or None + + +def main() -> None: + if len(sys.argv) < 2: + sys.exit( + "usage: git_remote.py fetch-raw REPO REF PATH [TOKEN] | " + "resolve-tag [TOKEN] REPO..." + ) + + cmd = sys.argv[1] + args = sys.argv[2:] + token: str | None = None + if args and not args[0].startswith("http"): + token = args[0] + args = args[1:] + token = token or resolve_git_token() + + if cmd == "fetch-raw": + repo, ref, path = args[:3] + sys.stdout.buffer.write(fetch_raw_file(repo, ref, path, token)) + return + + if cmd == "resolve-tag": + print(resolve_common_tag(args, token)) + return + + sys.exit(f"Unknown command: {cmd}") + + +if __name__ == "__main__": + main() diff --git a/updater/github_client.py b/updater/github_client.py new file mode 100644 index 0000000..f18d4cf --- /dev/null +++ b/updater/github_client.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import logging +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +import yaml + +from updater.git_remote import ( + fetch_raw_file, + fetch_semver_tags, + package_version_exists, + parse_slug, +) + +logger = logging.getLogger("fromchat.updater") + +COMPOSE_FILE = Path("/fromchat/compose.yml") +DEPLOY_CACHE = Path("/tmp/fromchat-deploy-cache") + +SEMVER = re.compile(r"^v(\d+)\.(\d+)(?:\.(\d+))?$") + +# Images that must exist in GHCR for a release to be considered complete. +REQUIRED_PACKAGES = ( + "fromchat/main", + "fromchat/web", + "fromchat/messaging", + "fromchat/file_storage", + "fromchat/postgres", +) + + +@dataclass +class Settings: + github_token: str + backend_repo: str + web_repo: str + deployment_repo: str + compose_project_dir: Path + components: list[str] + current_version: str + check_interval_seconds: int + owner: str + + @classmethod + def from_env(cls) -> Settings: + project_dir = Path(os.environ["COMPOSE_PROJECT_DIR"]) + components = [ + c.strip().lower() + for c in os.environ.get("FROMCHAT_COMPONENTS", "backend,frontend").split(",") + if c.strip() + ] + token = os.environ.get("GIT_TOKEN") or os.environ.get("GITHUB_TOKEN") or "" + backend = os.environ["BACKEND_REPO"] + owner, _ = parse_slug(backend) + current = read_version_from_compose(COMPOSE_FILE) + return cls( + github_token=token, + backend_repo=backend, + web_repo=os.environ["WEB_REPO"], + deployment_repo=os.environ.get( + "DEPLOYMENT_REPO", "https://github.com/fromchat-messenger/app.git" + ), + compose_project_dir=project_dir, + components=components, + current_version=current, + check_interval_seconds=int(os.environ.get("CHECK_INTERVAL_SECONDS", "60")), + owner=owner, + ) + + +def read_version_from_compose(compose_file: Path) -> str: + if not compose_file.is_file(): + return "" + try: + doc = yaml.safe_load(compose_file.read_text(encoding="utf-8")) or {} + except OSError: + return "" + services = doc.get("services") or {} + for name in ("main", "web", "messaging"): + image = (services.get(name) or {}).get("image", "") + if isinstance(image, str) and image.startswith("fromchat/") and ":" in image: + return image.rsplit(":", 1)[-1] + for service in services.values(): + image = service.get("image", "") + if isinstance(image, str) and image.startswith("fromchat/") and ":" in image: + return image.rsplit(":", 1)[-1] + return "" + + +def docker_compose_base(settings: Settings) -> list[str]: + host_compose = settings.compose_project_dir / "compose.yml" + return [ + "docker", + "compose", + "-f", + str(host_compose), + "--project-directory", + str(settings.compose_project_dir), + ] + + +def semver_key(tag: str) -> tuple[int, ...]: + m = SEMVER.match(tag) + if not m: + raise ValueError(tag) + return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)) + + +class GitHubClient: + """Release metadata client (GitHub primary, Gitea fallback for official repos).""" + + def __init__(self, token: str) -> None: + self.token = token + + def list_semver_tags(self, repo_url: str) -> set[str]: + return fetch_semver_tags(repo_url, self.token) + + +def packages_ready(client: GitHubClient, owner: str, tag: str, components: list[str]) -> bool: + needed: list[str] = [] + if "backend" in components: + needed.extend( + ["fromchat/main", "fromchat/messaging", "fromchat/file_storage", "fromchat/postgres"] + ) + if "frontend" in components: + needed.append("fromchat/web") + if "caddy" in components: + needed.append("fromchat/caddy") + if not needed: + needed = list(REQUIRED_PACKAGES) + return all( + package_version_exists(owner, pkg, tag, client.token, official=True) for pkg in needed + ) + + +def resolve_latest_release( + client: GitHubClient, + settings: Settings, +) -> str | None: + tag_sets: list[set[str]] = [] + if "backend" in settings.components: + tag_sets.append(client.list_semver_tags(settings.backend_repo)) + if "frontend" in settings.components: + tag_sets.append(client.list_semver_tags(settings.web_repo)) + if not tag_sets: + tag_sets = [ + client.list_semver_tags(settings.backend_repo), + client.list_semver_tags(settings.web_repo), + ] + common = set.intersection(*tag_sets) if tag_sets else set() + if not common: + return None + candidates = sorted(common, key=semver_key, reverse=True) + for tag in candidates: + if packages_ready(client, settings.owner, tag, settings.components): + return tag + return None + + +def regenerate_compose(settings: Settings, tag: str, deployment_root: Path) -> None: + branch = "main" + for name, path in ( + ("generate-compose.py", deployment_root / "scripts/generate-compose.py"), + ("git_remote.py", deployment_root / "scripts/git_remote.py"), + ("caddy.compose.yml", deployment_root / "compose/caddy.compose.yml"), + ): + rel = { + "generate-compose.py": "deployment/scripts/generate-compose.py", + "git_remote.py": "deployment/scripts/git_remote.py", + "caddy.compose.yml": "deployment/compose/caddy.compose.yml", + }[name] + deployment_root.mkdir(parents=True, exist_ok=True) + if name == "caddy.compose.yml": + (deployment_root / "compose").mkdir(parents=True, exist_ok=True) + if name.endswith(".py"): + (deployment_root / "scripts").mkdir(parents=True, exist_ok=True) + data = fetch_raw_file(settings.deployment_repo, branch, rel, settings.github_token) + path.write_bytes(data) + + tmp = deployment_root / "tmp-compose" + tmp.mkdir(parents=True, exist_ok=True) + + components = [c for c in settings.components if c != "updater"] + components_csv = ",".join(components) + + if "backend" in components: + (tmp / "backend.compose.yml").write_bytes( + fetch_raw_file(settings.backend_repo, tag, "compose.yml", settings.github_token) + ) + if "frontend" in components: + (tmp / "frontend.compose.yml").write_bytes( + fetch_raw_file(settings.web_repo, tag, "compose.yml", settings.github_token) + ) + + with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp_out: + tmp_output = Path(tmp_out.name) + + cmd = [ + "python3", + str(deployment_root / "scripts/generate-compose.py"), + "--tag", + tag, + "--components", + components_csv, + "--output", + str(tmp_output), + ] + if "backend" in components: + cmd.extend(["--backend-compose", str(tmp / "backend.compose.yml")]) + if "frontend" in components: + cmd.extend(["--frontend-compose", str(tmp / "frontend.compose.yml")]) + if "caddy" in components: + cmd.extend(["--caddy-compose", str(deployment_root / "compose/caddy.compose.yml")]) + + subprocess.run(cmd, check=True) + COMPOSE_FILE.write_text(tmp_output.read_text(encoding="utf-8"), encoding="utf-8") + tmp_output.unlink(missing_ok=True) + + +def apply_update(settings: Settings, tag: str) -> None: + logger.info("Applying update to %s", tag) + regenerate_compose(settings, tag, DEPLOY_CACHE) + env_file = settings.compose_project_dir / ".env" + compose = docker_compose_base(settings) + subprocess.run([*compose, "--env-file", str(env_file), "pull"], check=True) + subprocess.run( + [*compose, "--env-file", str(env_file), "up", "-d", "--wait", "--timeout", "600"], + check=True, + ) + logger.info("Update to %s complete", tag) + + +def run_once(settings: Settings) -> None: + client = GitHubClient(settings.github_token) + latest = resolve_latest_release(client, settings) + if not latest: + logger.debug("No release candidate found") + return + current = settings.current_version or read_version_from_compose(COMPOSE_FILE) + if not current: + logger.info("No version in compose.yml yet; latest release is %s", latest) + return + if semver_key(latest) <= semver_key(current): + logger.debug("Already on %s (latest candidate %s)", current, latest) + return + apply_update(settings, latest) + settings.current_version = latest diff --git a/updater/main.py b/updater/main.py new file mode 100644 index 0000000..1f97c37 --- /dev/null +++ b/updater/main.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import logging +import time + +from updater.github_client import Settings, run_once + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger("fromchat.updater.main") + + +def main() -> None: + settings = Settings.from_env() + logger.info( + "FromChat updater started (project=%s, current=%s, interval=%ss)", + settings.compose_project_dir, + settings.current_version or "(none)", + settings.check_interval_seconds, + ) + while True: + try: + run_once(settings) + except Exception: + logger.exception("Update check failed") + time.sleep(settings.check_interval_seconds) + + +if __name__ == "__main__": + main()