Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 4s
- 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
268 lines
10 KiB
Python
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)
|