server-up/server-up/core/selfupdate.py
Ramon f8274ebeff
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 17m46s
v0.10.20-beta - self-update, tags, tokens, --base-dir en --doctor
- bijwerken vanuit de interface kon nooit werken: selfupdate schreef SU_TAG naar
  <werkmap>/.env, maar die map is nergens in de container gemount. De
  helper-container mount hem wel en zet de tag nu
- een vastgepinde versie kon niet bijgewerkt worden: `git reset --hard
  origin/<tag>` bestaat niet. Resetten gaat nu naar FETCH_HEAD, wat voor takken
  en tags allebei klopt
- de token stond in .git/config en in ps; hij gaat nu via een bestand met modus
  0600 dat na afloop weer weg is (git via een credential-helper, curl via
  --config). Schema en host komen uit bron_url, want git zoekt op exact die
  combinatie
- nieuwe optie --base-dir PAD: de hoofdmap bij de installatie zetten in plaats
  van achteraf in .env, waarna je de data alsnog moet verhuizen. Relatieve
  paden en systeemmappen worden geweigerd
- nieuwe actie --doctor: een installatie doorlichten zonder iets te wijzigen —
  container, bereikbaarheid op het ingestelde adres, het account versus SU_UID
  in .env, of BASE_DIR echt gekoppeld is, de rechten van .env en de vrije
  ruimte. Exitcode 1 bij fouten
- de CI draait --doctor na elke deploy tegen de echte installatie

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-03 00:26:52 +02:00

289 lines
11 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": "", "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)."""
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 _tag_script(tag: str) -> str:
"""Sh-regels die SU_TAG in het .env van de werkmap zetten.
Dit gebeurt in de helper-container en niet hier, en dat is geen detail: de
installatiemap (`/opt/server-up`) is nérgens in deze container gemount. Er
zijn maar drie mounts — de docker-socket, het `su-data`-volume en `BASE_DIR`
— en de installatiemap zit daar niet bij. Zelf schrijven belandde dus in de
overlay van de container of, waarschijnlijker, mislukte omdat de map hier
niet bestaat. Bijwerken vanuit de interface kon daardoor nooit werken.
De helper mount `working_dir` wél, op hetzelfde pad, en draait als root.
"""
q = shlex.quote(f"SU_TAG={tag}")
return (
"touch .env; "
"sed -i '/^SU_TAG=/d' .env; "
f"printf '%s\\n' {q} >> .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.
"""
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}})
if log_fn:
log_fn(f"Vorige versie: {vorige_tag} (terugrollen kan vanuit Instellingen)")
log_fn(f"SU_TAG={tag} wordt door de helper in {werkmap}/.env gezet")
log_fn("Helper starten die de container hercreëert…")
ok, melding = _spawn_helper(image, werkmap, service, tag, 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, tag: 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.
De helper zet ook SU_TAG: hij mount de werkmap, deze container niet.
"""
script = (f"sleep 5; "
f"{_tag_script(tag)}"
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)