Initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
+20
@@ -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"]
|
||||||
@@ -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.
|
||||||
+15
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pyyaml>=6.0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""FromChat auto-updater: watches GitHub tags + packages and rolls forward releases."""
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from updater.main import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user