server-up/server-up/core/selfupdate.py
Ramon 14515143fc
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m51s
perf(build): image bouwt niet meer alles opnieuw bij elke uitgave
ARG SU_VERSION stond bovenaan met een LABEL erachter. Die waarde verandert
per release, dus alle lagen erna vervielen: apt-get, de Docker-CLI van 70 MB
en de front-end-bestanden. Versie en label staan nu onderaan, en de assets
vóór de broncode zodat ze niet bij elke commit opnieuw van het CDN komen.

Ook: een mislukte pull noemt nu de waarschijnlijke oorzaak (geen gepusht
image) in plaats van alleen naar toegangsrechten te wijzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 16:05:46 +02:00

342 lines
14 KiB
Python

"""Zelf-update: Server Up vervangt zijn eigen container door een nieuwer image.
Het lastige aan jezelf bijwerken is dat het proces dat `docker compose up -d`
uitvoert, halverwege door datzelfde commando wordt gestopt. Daarom doet Server
Up alleen het voorwerk (image ophalen, tag wegschrijven) en laat het de
daadwerkelijke hercreatie over aan een korte **helper-container**:
1. `docker pull <registry>/<repo>:<tag>` ← nieuw image binnenhalen
2. `SU_TAG=<tag>` en `SU_VERSION=<tag>` in de deploy-.env schrijven
3. helper starten met exact hetzelfde Compose-project en -configuratie
4. helper stopt zichzelf (`--rm`)
De helper draait op het zojuist gepulde image: dat bevat de docker- en
compose-CLI al (zie Dockerfile) en is per definitie aanwezig, dus er is geen
extra image nodig.
De deploy-map wordt niet geraden maar uitgelezen uit de compose-labels die
Docker op de eigen container heeft gezet.
"""
from __future__ import annotations
import json
import os
import re
import shlex
import time
from pathlib import Path
import core as cfg
from core import docker, updater
# Labels die `docker compose` op elke container zet.
_LBL_PROJECT = "com.docker.compose.project"
_LBL_WORKDIR = "com.docker.compose.project.working_dir"
_LBL_CONFIGS = "com.docker.compose.project.config_files"
_LBL_SERVICE = "com.docker.compose.service"
_TAG_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$")
_IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*(:[0-9]{1,5})?(/[A-Za-z0-9._/-]+)*$")
class UpdateError(Exception):
pass
# ── Zelfinspectie ────────────────────────────────────────────────────────────
def container_name() -> str:
return os.environ.get("HOSTNAME") or os.environ.get("SU_CONTAINER", "server-up")
def inspect_self() -> dict:
"""Gegevens over de eigen container: image, compose-project en deploy-map."""
r = docker._run([docker._bin(), "inspect", container_name()], timeout=15)
if r.returncode != 0:
return {}
try:
data = json.loads(r.stdout)
except Exception:
return {}
if not data:
return {}
c = data[0]
labels = (c.get("Config") or {}).get("Labels") or {}
return {
"name": (c.get("Name") or "").lstrip("/"),
"image": (c.get("Config") or {}).get("Image", ""),
"image_id": c.get("Image", ""),
"project": labels.get(_LBL_PROJECT, ""),
"working_dir": labels.get(_LBL_WORKDIR, ""),
"config_files": labels.get(_LBL_CONFIGS, ""),
"service": labels.get(_LBL_SERVICE, "") or "server-up",
"version": labels.get("org.opencontainers.image.version", ""),
}
def image_repo(info: dict | None = None) -> str:
"""Het image zonder tag, bv. `git.example.com/bes-r/server-up`.
Voorkeur: expliciet ingesteld via UPDATE_IMAGE. Anders afgeleid uit het
image waarmee deze container draait.
"""
expliciet = (cfg.load().get("UPDATE_IMAGE") or "").strip()
if expliciet:
# Een image-adres heeft nooit een schema. Wie het uit zijn adresbalk
# plakt neemt `https://` mee, en dan faalt `docker pull` op iets wat er
# in het veld goed uitziet. Weghalen is hier eenduidig: er bestaat geen
# geldig image dat zo begint.
for schema in ("https://", "http://"):
if expliciet.lower().startswith(schema):
expliciet = expliciet[len(schema):]
break
expliciet = expliciet.strip("/")
return expliciet.rsplit(":", 1)[0] if _has_tag(expliciet) else expliciet
img = (info or inspect_self()).get("image", "")
if not img:
return ""
return img.rsplit(":", 1)[0] if _has_tag(img) else img
def _has_tag(image: str) -> bool:
"""`host:5000/repo` heeft geen tag, `repo:1.2` wel — kijk ná de laatste `/`."""
laatste = image.rsplit("/", 1)[-1]
return ":" in laatste
def status() -> dict:
"""Kan deze installatie zichzelf bijwerken, en zo niet: waarom niet?"""
info = inspect_self()
repo = image_repo(info)
res = {"can_apply": False, "reason": "", "mode": "", "image": repo,
"current_image": info.get("image", ""),
"working_dir": info.get("working_dir", ""),
"service": info.get("service", ""),
"previous_tag": (cfg.load().get("UPDATE_PREVIOUS_TAG") or "")}
if not info:
res["mode"] = "no-container"
res["reason"] = ("De eigen container is niet te vinden via de "
"Docker-socket. Draait Server Up wel in Docker?")
return res
if not info.get("working_dir"):
res["mode"] = "no-compose"
res["reason"] = ("Deze container is niet met docker compose gestart "
"(geen compose-labels), dus bijwerken vanuit de "
"interface kan niet.")
return res
if not repo:
res["mode"] = "unknown"
res["reason"] = "Geen image bekend om bij te werken."
return res
if "/" not in repo:
# Lokaal gebouwd image. Meestal is dat geen probleem maar juist de
# opzet: de server bouwt zelf na een push naar Git. De knop is bedoeld
# voor installaties die een kant-en-klaar image uit een registry halen.
res["mode"] = "local-build"
res["reason"] = (
f"Deze installatie draait op een zelfgebouwd image ('{repo}') en "
"werkt dus bij via je eigen build- of deploy-proces — bij een "
"Forgejo-opzet: pushen naar de branch, of de deploy-workflow "
"handmatig starten. Wil je hier tóch de knop gebruiken, laat de "
"server dan vanaf een registry-image draaien (SU_IMAGE in .env, "
"zie docs/updates.md).")
return res
res["mode"] = "registry"
res["can_apply"] = True
return res
# ── Registry ─────────────────────────────────────────────────────────────────
def _login(log_fn=None) -> tuple[bool, str]:
"""Optionele registry-login voor een privé Forgejo-registry."""
c = cfg.load()
user = (c.get("UPDATE_REGISTRY_USER") or "").strip()
token = (c.get("UPDATE_REGISTRY_TOKEN") or "").strip()
if not user or not token:
return True, "" # publieke registry of al ingelogd
registry = image_repo().split("/")[0]
if log_fn:
log_fn(f"Inloggen op {registry} als {user}")
r = docker._run([docker._bin(), "login", registry, "-u", user,
"--password-stdin"], input=token, timeout=30)
if r.returncode != 0:
# Het token mag nooit in een logregel belanden.
melding = (r.stderr or r.stdout or "").replace(token, "***").strip()
return False, f"Registry-login mislukt: {melding[:200]}"
return True, ""
def pull(tag: str, log_fn=None) -> tuple[bool, str]:
"""Haal `<repo>:<tag>` binnen. Retourneert (ok, image of foutmelding)."""
# Forgejo noemt de Git-tag v0.8.13, maar de workflows pushen :0.8.13.
# Normaliseer hier óók, zodat oude clients die nog latest_tag versturen
# nooit een niet-bestaande :v0.8.13 proberen op te halen.
tag = updater.image_tag(tag)
if not _TAG_RE.match(tag or ""):
return False, f"Ongeldige tag: {tag!r}"
repo = image_repo()
if not repo or not _IMAGE_RE.match(repo):
return False, f"Ongeldig image: {repo!r}"
ok, err = _login(log_fn)
if not ok:
return False, err
image = f"{repo}:{tag}"
if log_fn:
log_fn(f"Ophalen {image}")
rc = docker._stream([docker._bin(), "pull", image], log_fn=log_fn)
if rc != 0:
# "Not found" is bijna nooit een toegangsprobleem: er staat gewoon geen
# image met die tag. De release bestaat wel — die maakt Forgejo bij de
# git-tag — maar de build pusht alleen als de deploy-omgeving weet naar
# welke registry hij moet. Draait deze installatie bovendien op een
# zelfgebouwd image, dan valt er sowieso niets op te halen.
lokaal = inspect_self().get("image", "")
extra = ""
if lokaal and "/" not in lokaal.rsplit(":", 1)[0]:
extra = (f" Deze installatie draait '{lokaal}', een lokaal gebouwd "
"image; bijwerken gaat dan via install.sh en niet via deze "
"knop.")
return False, (
f"Ophalen van {image} mislukt. Er is een release met deze versie, "
"maar geen image met deze tag in de registry — controleer of de "
"build hem gepusht heeft (SU_IMAGE in de .env van je deploy) en of "
"deze server bij de registry mag." + extra)
return True, image
# ── Toepassen ────────────────────────────────────────────────────────────────
def _tag_script(tag: str) -> str:
"""Sh-regels die de image-tag én versie in de deploy-.env vastleggen.
Dit gebeurt in de helper-container: de installatiemap is niet in de
draaiende Server Up-container gemount, maar wordt wel expliciet aan de
helper gekoppeld. SU_VERSION is nodig voor oudere compose-bestanden die de
ingebakken imageversie met een runtimewaarde overschrijven.
"""
tag_regel = shlex.quote(f"SU_TAG={tag}")
versie_regel = shlex.quote(f"SU_VERSION={tag}")
return (
"touch .env; "
"sed -i '/^SU_TAG=/d; /^SU_VERSION=/d' .env; "
f"printf '%s\\n' {tag_regel} {versie_regel} >> .env; "
)
def _tag_uit_env(working_dir: str) -> str:
"""Wat staat er nú als SU_TAG in de werkmap? Leeg als we er niet bij kunnen."""
try:
for regel in (Path(working_dir) / ".env").read_text("utf-8").splitlines():
if regel.strip().startswith("SU_TAG="):
return regel.split("=", 1)[1].strip()
except OSError:
pass
return ""
def apply(tag: str, log_fn=None) -> tuple[bool, str]:
"""Werk Server Up bij naar `tag`.
Bij succes keert deze functie terug terwijl de helper al gestart is; enkele
seconden later verdwijnt dit proces omdat de container hercreëerd wordt.
"""
tag = updater.image_tag(tag)
st = status()
if not st["can_apply"]:
return False, st["reason"]
info = inspect_self()
werkmap = info["working_dir"]
service = info["service"]
ok, image_of_fout = pull(tag, log_fn)
if not ok:
return False, image_of_fout
image = image_of_fout
# Een alias als latest of beta verschuift bij iedere release en is dus geen
# bruikbaar terugrolpunt. Het OCI-versielabel verwijst naar de echte,
# onveranderlijke versietag; val terug op de gebruikte imagetag voor oude
# images zonder label.
versie = updater.image_tag(info.get("version", ""))
huidig = info.get("image", "")
if _TAG_RE.match(versie or "") and versie != "dev":
vorige_tag = versie
else:
vorige_tag = huidig.rsplit(":", 1)[-1] if _has_tag(huidig) else "latest"
cfg.patch({"UPDATE_PREVIOUS_TAG": vorige_tag,
"UPDATE_LAST_APPLIED": {"tag": tag, "ts": time.time(),
"from": vorige_tag}})
if log_fn:
log_fn(f"Vorige versie: {vorige_tag} (terugrollen kan vanuit Instellingen)")
log_fn(f"SU_TAG en SU_VERSION worden in {werkmap}/.env bijgewerkt")
log_fn("Helper starten die de container hercreëert…")
ok, melding = _spawn_helper(
image, werkmap, service, tag,
project=info.get("project", ""),
config_files=info.get("config_files", ""),
log_fn=log_fn,
)
if not ok:
return False, melding
return True, (f"Bijwerken naar {tag} gestart. Server Up herstart nu; "
"de interface komt over enkele seconden terug.")
def _compose_up(working_dir: str, project: str, config_files: str,
service: str) -> str:
"""Bouw het commando voor exact het huidige Compose-project."""
cmd = ["docker", "compose", "--project-directory", working_dir]
if project:
cmd.extend(["--project-name", project])
for config in (p.strip() for p in (config_files or "").split(",")):
if config:
cmd.extend(["--file", config])
# Het doelimage is vlak hiervoor expliciet gepulld. Bouw nooit stilletjes
# oude broncode uit de deploy-map en haal tijdens de herstart niets anders.
cmd.extend(["up", "-d", "--remove-orphans", "--no-build", "--pull", "never",
service])
return shlex.join(cmd)
def _spawn_helper(image: str, working_dir: str, service: str, tag: str,
project: str = "", config_files: str = "",
log_fn=None) -> tuple[bool, str]:
"""Start een helper die het bestaande Compose-project exact hercreëert.
Projectnaam en configuratiebestanden komen uit de labels van de huidige
container. Daardoor werkt dit ook bij `docker compose -p ... -f ...`, en
wordt niet per ongeluk een tweede project gestart. `--no-build` garandeert
dat Compose het zojuist gepullde release-image gebruikt.
"""
compose_up = _compose_up(working_dir, project, config_files, service)
script = f"sleep 5; {_tag_script(tag)}{compose_up}"
cmd = [docker._bin(), "run", "--rm", "--detach",
"--name", "server-up-updater",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-v", f"{working_dir}:{working_dir}",
"-w", working_dir,
"--entrypoint", "sh",
image, "-c", script]
# Een blijven hangen helper van een vorige poging zou de naam bezet houden.
docker._run([docker._bin(), "rm", "-f", "server-up-updater"], timeout=15)
r = docker._run(cmd, timeout=30)
if r.returncode != 0:
return False, f"Helper starten mislukt: {(r.stderr or r.stdout).strip()[:300]}"
if log_fn:
log_fn("Helper gestart — tot zo.")
return True, "ok"
def rollback(log_fn=None) -> tuple[bool, str]:
"""Ga terug naar de tag die vóór de laatste update draaide."""
vorige = (cfg.load().get("UPDATE_PREVIOUS_TAG") or "").strip()
if not vorige:
return False, "Geen vorige versie bekend om naar terug te rollen."
if log_fn:
log_fn(f"Terugrollen naar {vorige}")
return apply(vorige, log_fn)