All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 17m46s
- 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
289 lines
11 KiB
Python
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)
|