dev #229
@@ -151,7 +151,7 @@ Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem
|
|||||||
1. Exklusive Host-Sperre erwerben; offene Wiederherstellungsfälle blockieren neue Updates.
|
1. Exklusive Host-Sperre erwerben; offene Wiederherstellungsfälle blockieren neue Updates.
|
||||||
2. Release samt Prüfsummen laden, Manifest/Commit prüfen und die FEDEO-Images auf die exakten Digests setzen.
|
2. Release samt Prüfsummen laden, Manifest/Commit prüfen und die FEDEO-Images auf die exakten Digests setzen.
|
||||||
3. Aktuelle und neue Compose-Konfiguration mit Docker Compose validieren. Projektname, Container-Identitäten, Mounts, Netzwerke und Zugangsdaten erhalten. Die Shell-Umgebung überschreibt dabei keine Werte aus der Instanz-`.env`.
|
3. Aktuelle und neue Compose-Konfiguration mit Docker Compose validieren. Projektname, Container-Identitäten, Mounts, Netzwerke und Zugangsdaten erhalten. Die Shell-Umgebung überschreibt dabei keine Werte aus der Instanz-`.env`.
|
||||||
4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images laden, Backup-Platz erneut prüfen.
|
4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images mit exakt gepinntem SHA-256-Digest nur laden, wenn sie lokal fehlen (`pull --policy missing`); vorhandene Tags ohne passenden Digest reichen dafür nicht aus. Bewegliche Infrastruktur-Tags weiterhin aktualisieren (`pull --policy always`). Ein fehlendes, nicht herunterladbares Image beendet den Auftrag vor der Wartung. Backup-Platz erneut prüfen. Während der anschließenden Infrastrukturstarts und Migrationen erfolgt kein weiterer Pull (`up`/`run --pull never`), auch nicht bei einer abweichenden Pull-Policy in einer Override-Datei.
|
||||||
5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv.
|
5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv.
|
||||||
6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt.
|
6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt.
|
||||||
Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr.
|
Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr.
|
||||||
@@ -163,6 +163,8 @@ Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `st
|
|||||||
|
|
||||||
## Fehler und Wiederherstellung
|
## Fehler und Wiederherstellung
|
||||||
|
|
||||||
|
Bei einer nicht erreichbaren Registry können bereits lokal vorhandene, exakt passende Digest-Images weiterverwendet werden. Das hilft beispielsweise bei einem `401 Unauthorized` der MinIO-Registry, wenn sowohl MinIO als auch `mc` mit den Ziel-Digests bereits auf diesem Host liegen. Der Host-Updater muss diese Pull-Logik bereits enthalten; bestehende Dienstinstallationen nach Austausch des Hostskripts mit dem Dienstinstaller aktualisieren. Veröffentlichte Release-Assets werden nicht nachträglich verändert. Für Instanzen ohne diese Images bleibt eine geprüfte Bezugsquelle notwendig. Der lokale Cache ersetzt weder Sicherheitsupdates noch eine dauerhafte Versorgung mit gepflegten Images.
|
||||||
|
|
||||||
- **Download, Konfiguration oder Pull fehlgeschlagen:** Der laufende Stack bleibt bestehen.
|
- **Download, Konfiguration oder Pull fehlgeschlagen:** Der laufende Stack bleibt bestehen.
|
||||||
- **Backup oder Stop fehlgeschlagen:** Der Updater versucht, die zuvor laufenden Container wieder zu starten. Gelingt das nicht, bleibt der Wiederherstellungsstatus gesetzt.
|
- **Backup oder Stop fehlgeschlagen:** Der Updater versucht, die zuvor laufenden Container wieder zu starten. Gelingt das nicht, bleibt der Wiederherstellungsstatus gesetzt.
|
||||||
- **Ab Austausch der Dateien/Infrastruktur:** Keine automatische Rückkehr zu älteren Images. Bei einem Fehler werden Backend, Frontend und Proxy nach Möglichkeit angehalten. Das Datenbankschema oder das Speicherformat könnten bereits verändert sein.
|
- **Ab Austausch der Dateien/Infrastruktur:** Keine automatische Rückkehr zu älteren Images. Bei einem Fehler werden Backend, Frontend und Proxy nach Möglichkeit angehalten. Das Datenbankschema oder das Speicherformat könnten bereits verändert sein.
|
||||||
|
|||||||
@@ -566,7 +566,13 @@ class Updater:
|
|||||||
if not self.managed and not accept_compose:
|
if not self.managed and not accept_compose:
|
||||||
raise UpdateError("Compose-Datei ist noch nicht verwaltet oder wurde bearbeitet. compose.diff prüfen, Anpassungen in die Override-Datei übertragen und --accept-compose-replacement verwenden.")
|
raise UpdateError("Compose-Datei ist noch nicht verwaltet oder wurde bearbeitet. compose.diff prüfen, Anpassungen in die Override-Datei übertragen und --accept-compose-replacement verwenden.")
|
||||||
self.save("pulling")
|
self.save("pulling")
|
||||||
self.compose(True, "pull", timeout=3600)
|
# Exact digests are immutable: reuse local copies even when their registry is offline.
|
||||||
|
# Mutable infrastructure tags must still be refreshed before entering maintenance.
|
||||||
|
for policy in ("missing", "always"):
|
||||||
|
services = [name for name, service in self.candidate["services"].items()
|
||||||
|
if bool(re.search(r"@sha256:[a-f0-9]{64}$", service["image"])) == (policy == "missing")]
|
||||||
|
if services:
|
||||||
|
self.compose(True, "pull", "--policy", policy, *services, timeout=3600)
|
||||||
self.check_space(self.backup_size, self.home)
|
self.check_space(self.backup_size, self.home)
|
||||||
if any(sha(Path(path).read_bytes()) != value for path, value in self.before.items()):
|
if any(sha(Path(path).read_bytes()) != value for path, value in self.before.items()):
|
||||||
raise UpdateError("Konfiguration wurde während der Vorbereitung verändert; erneut prüfen.")
|
raise UpdateError("Konfiguration wurde während der Vorbereitung verändert; erneut prüfen.")
|
||||||
@@ -590,13 +596,13 @@ class Updater:
|
|||||||
atomic(self.root / ".env.example", (self.job / ".env.example").read_bytes())
|
atomic(self.root / ".env.example", (self.job / ".env.example").read_bytes())
|
||||||
atomic(self.root / "scripts/selfhost-setup.sh", (self.job / "selfhost-setup.sh").read_bytes())
|
atomic(self.root / "scripts/selfhost-setup.sh", (self.job / "selfhost-setup.sh").read_bytes())
|
||||||
os.chmod(self.root / "scripts/selfhost-setup.sh", 0o700)
|
os.chmod(self.root / "scripts/selfhost-setup.sh", 0o700)
|
||||||
self.compose(True, "up", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", "minio", "node-exporter")
|
self.compose(True, "up", "--pull", "never", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", "minio", "node-exporter")
|
||||||
self.compose(True, "run", "--rm", "--no-deps", "createbuckets")
|
self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "createbuckets")
|
||||||
self.save("migrating", migrationContainer="fedeo-update-" + self.job.name)
|
self.save("migrating", migrationContainer="fedeo-update-" + self.job.name)
|
||||||
self.compose(True, "run", "--rm", "--no-deps", "--name", self.state["migrationContainer"],
|
self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "--name", self.state["migrationContainer"],
|
||||||
"-e", "FEDEO_RUN_MIGRATIONS=false", "--entrypoint", "npm", "backend", "run", "migrate")
|
"-e", "FEDEO_RUN_MIGRATIONS=false", "--entrypoint", "npm", "backend", "run", "migrate")
|
||||||
self.save("starting")
|
self.save("starting")
|
||||||
self.compose(True, "up", "-d", "--wait", "--wait-timeout", "600", timeout=900)
|
self.compose(True, "up", "--pull", "never", "-d", "--wait", "--wait-timeout", "600", timeout=900)
|
||||||
self.save("verifying")
|
self.save("verifying")
|
||||||
self.verify_matrix_stopped(retired=True)
|
self.verify_matrix_stopped(retired=True)
|
||||||
for name, image in self.images.items():
|
for name, image in self.images.items():
|
||||||
|
|||||||
@@ -79,6 +79,32 @@ class Runner:
|
|||||||
self.matrix_containers = {}
|
self.matrix_containers = {}
|
||||||
self.on_stop = None
|
self.on_stop = None
|
||||||
self.real_tar = False
|
self.real_tar = False
|
||||||
|
self.compose_configs = {}
|
||||||
|
self.local_images = set()
|
||||||
|
self.unavailable_images = set()
|
||||||
|
self.downloads = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def image_reference(image):
|
||||||
|
# Docker resolves tag@digest using the repository and digest, not the tag.
|
||||||
|
if '@' not in image:
|
||||||
|
return image
|
||||||
|
name, digest = image.split('@', 1)
|
||||||
|
repository, slash, basename = name.rpartition('/')
|
||||||
|
return repository + slash + basename.split(':', 1)[0] + '@' + digest
|
||||||
|
|
||||||
|
def pull_image(self, image, policy):
|
||||||
|
reference = self.image_reference(image)
|
||||||
|
if policy == 'never':
|
||||||
|
if reference not in self.local_images:
|
||||||
|
raise u.UpdateError('Image missing locally')
|
||||||
|
return
|
||||||
|
if policy == 'missing' and reference in self.local_images:
|
||||||
|
return
|
||||||
|
self.downloads.append(reference)
|
||||||
|
if reference in self.unavailable_images:
|
||||||
|
raise u.UpdateError('Registry unavailable for ' + reference)
|
||||||
|
self.local_images.add(reference)
|
||||||
|
|
||||||
def run(self, args, output=None, input_file=None, **kwargs):
|
def run(self, args, output=None, input_file=None, **kwargs):
|
||||||
self.calls.append(args)
|
self.calls.append(args)
|
||||||
@@ -108,6 +134,7 @@ class Runner:
|
|||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
if result.returncode:
|
if result.returncode:
|
||||||
raise u.UpdateError('Compose invalid: ' + result.stderr)
|
raise u.UpdateError('Compose invalid: ' + result.stderr)
|
||||||
|
self.compose_configs[args[args.index('-f') + 1]] = json.loads(result.stdout)
|
||||||
return result.stdout
|
return result.stdout
|
||||||
if 'ps' in args:
|
if 'ps' in args:
|
||||||
if args[:2] == ['docker', 'ps']:
|
if args[:2] == ['docker', 'ps']:
|
||||||
@@ -121,8 +148,17 @@ class Runner:
|
|||||||
container['State']['Running'] = 'start' in args
|
container['State']['Running'] = 'start' in args
|
||||||
if 'stop' in args and self.on_stop:
|
if 'stop' in args and self.on_stop:
|
||||||
self.on_stop()
|
self.on_stop()
|
||||||
if 'pull' in args and self.on_pull:
|
if args[:2] == ['docker', 'compose'] and any(command in args for command in ('pull', 'up', 'run')):
|
||||||
self.on_pull()
|
services = self.compose_configs[args[args.index('-f') + 1]]['services']
|
||||||
|
selected = [service for name, service in services.items() if name in args]
|
||||||
|
if not selected:
|
||||||
|
selected = list(services.values())
|
||||||
|
for service in selected:
|
||||||
|
policy = (args[args.index('--policy') + 1] if '--policy' in args else 'always') if 'pull' in args else (
|
||||||
|
args[args.index('--pull') + 1] if '--pull' in args else service.get('pull_policy', 'missing'))
|
||||||
|
self.pull_image(service['image'], policy)
|
||||||
|
if 'pull' in args and self.on_pull:
|
||||||
|
self.on_pull()
|
||||||
if any('pg_database_size' in part for part in args):
|
if any('pg_database_size' in part for part in args):
|
||||||
return '1000'
|
return '1000'
|
||||||
if args[0] == 'du':
|
if args[0] == 'du':
|
||||||
@@ -493,6 +529,49 @@ class UpdateTests(unittest.TestCase):
|
|||||||
self.run_update()
|
self.run_update()
|
||||||
self.assert_untouched()
|
self.assert_untouched()
|
||||||
|
|
||||||
|
def test_cached_exact_images_work_with_unavailable_registries_and_forced_pull_overrides(self):
|
||||||
|
self.updater.execute('check', '3.1.0')
|
||||||
|
services = self.updater.candidate['services']
|
||||||
|
refs = {name: self.runner.image_reference(service['image']) for name, service in services.items()}
|
||||||
|
self.runner.local_images.update(refs.values())
|
||||||
|
self.runner.unavailable_images.update(ref for ref in refs.values() if '@sha256:' in ref)
|
||||||
|
(self.root / 'docker-compose.override.yml').write_text(
|
||||||
|
'services:\n' + ''.join(f' {name}:\n pull_policy: always\n' for name in services))
|
||||||
|
result = self.run_update()
|
||||||
|
self.assertEqual(result['status'], 'succeeded')
|
||||||
|
self.assertEqual(set(self.runner.downloads), {refs[name] for name in ('db', 'traefik', 'node-exporter')})
|
||||||
|
self.assertEqual(len(self.runner.downloads), 3)
|
||||||
|
|
||||||
|
def test_missing_digest_is_downloaded_even_if_tag_or_another_digest_is_cached(self):
|
||||||
|
self.updater.execute('check', '3.1.0')
|
||||||
|
services = self.updater.candidate['services']
|
||||||
|
for service in services.values():
|
||||||
|
image = service['image']
|
||||||
|
if '@' in image:
|
||||||
|
self.runner.local_images.add(image.split('@')[0])
|
||||||
|
self.runner.local_images.add(self.runner.image_reference(image).split('@')[0] + '@sha256:' + '0' * 64)
|
||||||
|
self.assertEqual(self.run_update()['status'], 'succeeded')
|
||||||
|
self.assertEqual(set(self.runner.downloads), {self.runner.image_reference(s['image']) for s in services.values()})
|
||||||
|
|
||||||
|
def test_missing_unavailable_digest_stops_before_maintenance_despite_cached_tag(self):
|
||||||
|
self.updater.execute('check', '3.1.0')
|
||||||
|
image = self.updater.candidate['services']['minio']['image']
|
||||||
|
self.runner.local_images.add(image.split('@')[0])
|
||||||
|
self.runner.unavailable_images.add(self.runner.image_reference(image))
|
||||||
|
with self.assertRaisesRegex(u.UpdateError, 'Registry unavailable'):
|
||||||
|
self.run_update()
|
||||||
|
self.assert_untouched()
|
||||||
|
self.assertFalse(self.updater.status()['recoveryRequired'])
|
||||||
|
|
||||||
|
def test_cached_mutable_tag_still_requires_a_successful_registry_refresh(self):
|
||||||
|
self.updater.execute('check', '3.1.0')
|
||||||
|
services = self.updater.candidate['services']
|
||||||
|
self.runner.local_images.update(self.runner.image_reference(s['image']) for s in services.values())
|
||||||
|
self.runner.unavailable_images.add(services['db']['image'])
|
||||||
|
with self.assertRaisesRegex(u.UpdateError, 'Registry unavailable'):
|
||||||
|
self.run_update()
|
||||||
|
self.assert_untouched()
|
||||||
|
|
||||||
def test_configuration_changed_during_pull_is_detected(self):
|
def test_configuration_changed_during_pull_is_detected(self):
|
||||||
self.runner.on_pull = lambda: (self.root / '.env').write_bytes(self.original_env + b'\nNEW=value\n')
|
self.runner.on_pull = lambda: (self.root / '.env').write_bytes(self.original_env + b'\nNEW=value\n')
|
||||||
with self.assertRaisesRegex(u.UpdateError, 'während'):
|
with self.assertRaisesRegex(u.UpdateError, 'während'):
|
||||||
|
|||||||
Reference in New Issue
Block a user