All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m51s
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
342 lines
14 KiB
Python
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)
|