server-up/server-up/core/selfupdate.py
Ramon 8ff5f4b1d3
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 4s
v0.5.10-beta - update-systeem met kanalen en een-klik bijwerken
- Update-kanalen stable/beta in plaats van het vinkje "pre-releases meenemen".
  Stable ziet alleen releases, beta ook pre-releases; een release telt hoger
  dan zijn eigen beta (0.5.10-beta1 < 0.5.10). UPDATE_INCLUDE_PRERELEASE
  migreert automatisch naar het beta-kanaal.
- Nieuw core/selfupdate.py: image uit de registry ophalen, SU_TAG wegschrijven
  en de eigen container laten hercreeren door een korte helper-container die op
  het nieuwe image draait (bevat de docker- en compose-CLI al). Een container
  kan zichzelf niet hercreeren, vandaar de helper.
- Deploy-map wordt uitgelezen uit de compose-labels van de eigen container.
  Ontbreken die, draait de container niet vanaf een registry-image of is de
  docker-socket er niet, dan meldt de UI waarom bijwerken niet kan.
- Vorige tag wordt onthouden; terugrolknop in de instellingen.
- Update-check een uur gecached, met geforceerde check via de knop; eerder deed
  elke paginalading een netwerkverzoek. Laatst-gecontroleerd zichtbaar.
- Registry-inloggegevens instelbaar; het token komt net als de git-tokens nooit
  terug via de API en leeg laten betekent ongewijzigd.
- docker-compose.yml gebruikt ${SU_IMAGE:-server-up}:${SU_TAG:-latest}; zonder
  SU_IMAGE blijft lokaal bouwen werken zoals voorheen.
- deploy-prod.yml bouwt en pusht het image in dezelfde job wanneer SU_IMAGE
  ingesteld is; build.yml is nu alleen handmatig. Bewust geen aparte
  build-workflow op dezelfde tag: bij een runner met een job tegelijk zou de
  deploy wachten op een build die zelf nog in de wachtrij staat.
- docs/updates.md: werking, complete Forgejo-instelling (registry, tokens,
  variables, secrets, runners), release-procedure voor stable en beta,
  terugrollen en probleemoplossing.
- Tests uitgebreid naar 148 (kanaallogica, cache, image-afleiding incl.
  registry-poortnummers, tag-validatie, .env-schrijven, tokenlek).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-26 15:13:18 +02:00

268 lines
10 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>` in het .env van de deploy-map schrijven
3. helper starten: sleep 5 && docker compose up -d server-up
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
# 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",
}
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:
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": "", "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["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["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["reason"] = "Geen image bekend om bij te werken."
return res
if "/" not in repo:
res["reason"] = (f"Het image '{repo}' komt niet uit een registry. Stel "
"UPDATE_IMAGE in op bv. git.example.com/bes-r/server-up "
"en draai vanaf een registry-image.")
return res
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)."""
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:
return False, (f"Ophalen van {image} mislukt. Bestaat die tag in de "
"registry, en heeft deze server er toegang toe?")
return True, image
# ── Toepassen ────────────────────────────────────────────────────────────────
def _write_tag(working_dir: str, tag: str, log_fn=None):
"""Zet SU_TAG in het .env van de deploy-map, zodat compose het nieuwe image
gebruikt en de wijziging een herstart overleeft."""
env_file = Path(working_dir) / ".env"
regels: list[str] = []
if env_file.exists():
regels = env_file.read_text(encoding="utf-8").splitlines()
nieuw, gezet = [], False
for r in regels:
if r.strip().startswith("SU_TAG="):
nieuw.append(f"SU_TAG={tag}")
gezet = True
else:
nieuw.append(r)
if not gezet:
nieuw.append(f"SU_TAG={tag}")
env_file.write_text("\n".join(nieuw) + "\n", encoding="utf-8")
if log_fn:
log_fn(f"SU_TAG={tag} vastgelegd in {env_file}")
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.
"""
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
# Onthoud waar we vandaan komen, zodat terugrollen mogelijk blijft als het
# nieuwe image niet start.
huidig = info.get("image", "")
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}})
try:
_write_tag(werkmap, tag, log_fn)
except Exception as e:
return False, f"Kan {werkmap}/.env niet schrijven: {e}"
if log_fn:
log_fn(f"Vorige versie: {vorige_tag} (terugrollen kan vanuit Instellingen)")
log_fn("Helper starten die de container hercreëert…")
ok, melding = _spawn_helper(image, werkmap, service, 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 _spawn_helper(image: str, working_dir: str, service: str,
log_fn=None) -> tuple[bool, str]:
"""Start een korte container die `docker compose up -d <service>` uitvoert.
Draait op hetzelfde (nieuwe) image, dat de docker- en compose-CLI al bevat.
`sleep` geeft dit proces de tijd om zijn HTTP-antwoord af te maken voordat
het onder zijn eigen voeten wordt weggehaald.
"""
script = (f"sleep 5; "
f"docker compose up -d --remove-orphans {shlex.quote(service)}")
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)