diff --git a/README.md b/README.md index f6f2283..c25511a 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,10 @@ Vier Auslöser, in aufsteigender Verlässlichkeit: ## PHP-Webhosting +**Archiv-Updates prüfen:** `node scripts/archive-update-check.mjs ` testet im Produktionsmodus den vollständigen HTTP-Updateablauf mit isolierter Kopie von `var/kantit.db`. PHP mit SQLite3 über `PHP_BINARY` wählen. Downloadtransport lokal, Originaldatenbank unverändert; Protokolle unter `var/archive-check-*`. Ein drittes Argument `500` prüft ausdrücklich den bekannten Altfehler samt Wiederaufnahme. Um den nächsten Updateaufruf des korrigierten 1.10.2-Pakets ohne Folgerelease zu testen, dasselbe Paket zweimal angeben und ausschließlich für diesen Test `UPDATE_SOURCE_VERSION=1.10.1` setzen; geändert wird nur die Versionsdatei der Wegwerfinstallation. + +**500 direkt nach einem Archiv-Update aus 1.10.1 oder älter:** `/admin/system` direkt öffnen und angebotene Migrationen abschließen. Der alte Updater kann nach erfolgreichem Dateitausch seinen eigenen Cache löschen und beim Antworten abbrechen. Ab 1.10.2 behalten laufende Anfragen ihren Cache; neue Releases erhalten ein eigenes Cacheverzeichnis. Details und Grenzen stehen in ADR 090. Nicht erneut installieren oder die Datenbank ersetzen. + Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen: - Gepflegte PHP-Version ab 8.2, PDO MySQL und die Composer-Plattformanforderungen. diff --git a/VERSION b/VERSION index 4dae298..5ad2491 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.10.1 +1.10.2 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7de10e3..0e45726 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,16 @@ Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 090 – Releasecache bleibt für laufende Updateanfragen erhalten (14.09.2026) + +Der echte HTTP-Updatepfad von 1.10.0 nach 1.10.1 reproduziert Umuts 500 auf `/admin/system/archiv`: Die Dateien sind bereits erfolgreich ersetzt, dann entfernt `ArchiveUpdate` den gesamten `var/cache`. Symfony lädt beim Abschluss weitere Dienste aus dem kompilierten Container nach und findet deren PHP-Dateien nicht mehr. Eine frische Installation und isolierte Updater-Rückgabewerte decken diesen Fehler nicht ab. + +Der Kernel verwendet deshalb `var/cache//release-`. Er merkt sich diesen Pfad pro Kernelinstanz, damit das Überschreiben von VERSION während der Anfrage ihren Cachepfad nicht ändert. Der Archiv-Updater löscht keinen Cache mehr; die folgende Anfrage startet mit dem eigenen Container- und Twigcache der neuen Version. Alte Cacheverzeichnisse bleiben absichtlich erhalten. Sie können bei gestoppter Anwendung entfernt werden, nicht während eines laufenden Updates. Keine Datenmigration; ein Downgrade verwendet wieder den zu seiner Fassung gehörenden Cache und kann ihn bei Bedarf über die Konsole erneuern. + +**Übergang:** Ein bereits installierter Updater bis einschließlich 1.10.1 führt noch seinen alten Löschcode aus, auch wenn er 1.10.2 installiert. Dieser Updateaufruf kann einmalig weiterhin mit 500 enden. `/admin/system` direkt öffnen und die angebotenen Migrationen abschließen; die Marke in `var/update/offen.json` erhält den Fortschritt. Die Reparatur verhindert den Abbruch bei nachfolgenden Updates aus dem korrigierten Stand. Kein Erfolg der Produktionsmigrationen wird ohne Prüfung behauptet. + +**Regression:** `scripts/archive-update-check.mjs` installiert zwei vollständige Releasearchive in einem eigenen Verzeichnis und prüft den echten HTTP-POST, Wartungszustand, Weiterleitung, erhaltene Sitzung, Systemseite, Migrationsabschluss, Sicherung und öffentliche Ausgabe. Nur der Downloadtransport wird auf das lokale Zielarchiv umgestellt. `UPDATE_SOURCE_VERSION` erlaubt eine ausdrücklich synthetische vorherige Versionsnummer auf dem korrigierten Paket, um dessen nächsten Updateaufruf vor einem weiteren Release zu prüfen. Die Original-Pilotdatenbank bleibt unverändert. + ## ADR 089 – Formularaktionen nutzen den Website-Standardbutton (14.09.2026) Der öffentliche Absenden-Button erhält dieselbe Klasse `cms-button` wie normale Buttonblöcke. Die bisherigen formularspezifischen Farb- und Hover-Regeln entfallen; die bereits vorhandenen Theme-Variablen bestimmen Form, Darstellung, Größe, Schrift und Farben. `justify-self:start` verhindert das implizite Strecken durch das Formular-Grid. Es werden keine Designwerte in Formulardefinitionen kopiert: spätere Änderungen am Website-Standard wirken auch auf vorhandene Formulare. Nativer Submit-Typ, Beschriftung, Fokus, Validierung und Versand bleiben erhalten. Keine Daten-/Schemamigration; ein Code-Rückweg stellt das frühere Sonderdesign wieder her. diff --git a/docs/STATUS.md b/docs/STATUS.md index cce1feb..9ea48bb 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,13 @@ # Projektstatus +## 1.10.2 – Fehler 500 beim Abschluss des Archiv-Updateaufrufs (14.09.2026) + +**Reproduziert und korrigiert:** Echter Produktionsmodus mit den vollständigen Paketen 1.10.0 → 1.10.1: Update-POST HTTP 500 nach erfolgreichem Dateitausch, fehlende PHP-Dienste im gerade gelöschten Containercache. Systemseite anschließend HTTP 200, Migrationsabschluss HTTP 302, öffentliche Seite danach HTTP 200. Ursache und Übergang: ADR 090. Der Kernel hält nun seinen Cachepfad pro Anfrage fest und verwendet für jede Releasefassung ein eigenes Verzeichnis. Der Archiv-Updater entfernt keine Dateien, die die laufende Anfrage noch benötigt. + +**Bisher geprüft:** Gegenprobe mit korrigiertem Updater: POST 302, System 200, Abschluss 302, öffentlich 200. 16 gezielte PHP-Tests / 105 Assertions für Archive, Cachepfad und Systemverwaltung erfolgreich. Neuer wiederverwendbarer Paket-HTTP-Test reproduziert den alten Fehler sowie den erfolgreichen Wiederaufnahmeweg. Die Originaldatenbank bleibt unberührt. + +**Release / nächster Schritt:** Paket 1.10.2 bauen und den Übergang aus 1.10.1 sowie einen weiteren Updateaufruf aus dem korrigierten Stand mit diesem Paket prüfen. Noch nicht veröffentlicht. Der bereits installierte alte Updater kann beim Einspielen dieser Reparatur einmalig weiter 500 ausgeben; anschließend `/admin/system` direkt öffnen und die angebotenen Migrationen abschließen. Das ist im Pakettest ausdrücklich zu prüfen. Kein Zugriff auf Produktionsprotokolle und keine Produktionsänderung durch diese Diagnose; Startseite und Anmeldung aktuell HTTP 200. + ## 1.10.1 – Standardbutton im Formular und zuverlässiges Wiederaufnehmen von Einblendungen (14.09.2026) **Umgesetzt:** „Absenden“ verwendet den konfigurierten Website-Standardbutton, einschließlich Form, Darstellung, Größe, Schrift sowie normalen und Hover-Farben. Harte Formularfarben entfernt und natürliche Buttonbreite hergestellt. Bestehende Formulare übernehmen Theme-Änderungen ohne Inhaltsmigration (ADR 089). Enthalten sind außerdem die unten dokumentierte Observer-Korrektur und die dynamische Bewegungsmeldung im Editor. diff --git a/scripts/archive-update-check.mjs b/scripts/archive-update-check.mjs new file mode 100644 index 0000000..bf74e4f --- /dev/null +++ b/scripts/archive-update-check.mjs @@ -0,0 +1,97 @@ +// Exercise the real HTTP update across two complete release archives in an isolated installation. +// Usage: PHP_BINARY=php node scripts/archive-update-check.mjs old.zip new.zip [expected-status] +// UPDATE_SOURCE_VERSION changes only the disposable source VERSION to test a future upgrade +// from the fixed updater before a subsequent release exists. +import assert from 'node:assert/strict'; +import { chromium } from 'playwright'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdir, readFile, writeFile, readdir, access } from 'node:fs/promises'; +import { createHash, randomBytes } from 'node:crypto'; +import path from 'node:path'; +import net from 'node:net'; + +const php = process.env.PHP_BINARY || 'php'; +const [source, target, expected = '302'] = process.argv.slice(2); +assert.ok(source && target, 'Supply the installed and target release ZIP paths.'); +const root = process.cwd().replaceAll('\\', '/'); +const run = `${root}/var/archive-check-${randomBytes(5).toString('hex')}`; +await mkdir(run, { recursive: true }); +const originalHash = createHash('sha256').update(await readFile('var/kantit.db')).digest('hex'); +execFileSync(php, ['-r', '$z=new ZipArchive(); if(true!==$z->open($argv[1])||!$z->extractTo($argv[2]))exit(1); $z->close();', path.resolve(source), run], { windowsHide: true }); +const [directory] = (await readdir(run)).filter(name => /^kantsites-/.test(name)); +assert.ok(directory); +const installation = `${run}/${directory}`; +const targetVersion = execFileSync(php, ['-r', '$z=new ZipArchive(); $z->open($argv[1]); for($i=0;$i<$z->numFiles;$i++){if(preg_match("#^kantsites-[^/]+/VERSION$#",$z->getNameIndex($i)))echo trim($z->getFromIndex($i));}', path.resolve(target)], { encoding: 'utf8', windowsHide: true }).trim(); +assert.match(targetVersion, /^\d+\.\d+\.\d+$/); +if (process.env.UPDATE_SOURCE_VERSION) await writeFile(`${installation}/VERSION`, `${process.env.UPDATE_SOURCE_VERSION}\n`); +const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); +await mkdir(`${installation}/var/media`, { recursive: true }); +// Only the download transport is replaced; the real archive, checksum, backup, file swap, +// production kernel, session, redirects, migration and subsequent requests remain in use. +await writeFile(`${run}/download.php`, `'v'.getenv('QA_TARGET_VERSION')]); + if(str_ends_with($url, '.zip.sha256')) return hash_file('sha256', getenv('QA_TARGET_ZIP')); + if(str_ends_with($url, '.zip')) return file_get_contents(getenv('QA_TARGET_ZIP')); + return null; + } +} +`); +await writeFile(`${installation}/config/services.yaml`, (await readFile(`${installation}/config/services.yaml`, 'utf8')) + '\n App\\Ops\\Download:\n class: ArchiveCheckDownload\n'); +// Keep the test router outside the replaced bin/ directory (Windows locks the active script). +await writeFile(`${run}/router.php`, ` probe.listen(0, '127.0.0.1', resolve)); +const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: `${installation}/var/media`, SITE_ORIGIN: origin, QA_TARGET_ZIP: path.resolve(target), QA_TARGET_VERSION: targetVersion }; +const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', `${run}/router.php`], { cwd: installation, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); +let log = '', browser; +server.stdout.on('data', data => log += data); server.stderr.on('data', data => log += data); +const result = { source: path.resolve(source), target: path.resolve(target), simulatedSourceVersion: process.env.UPDATE_SOURCE_VERSION ?? null }; +try { + let ready = false; + for (let attempt = 0; attempt < 60; attempt++) { + try { if ((await fetch(origin + '/admin/login')).ok) { ready = true; break; } } catch {} + await new Promise(resolve => setTimeout(resolve, 200)); + } + assert.ok(ready, 'Isolated release must boot before testing an update.'); + browser = await chromium.launch(); const page = await browser.newPage(); + await page.goto(origin + '/admin/login'); + await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password); + await page.locator('button[type=submit]').click(); + assert.equal((await page.goto(origin + '/admin/system')).status(), 200); + const token = await page.locator('form[action="/admin/system/archiv"] [name="_csrf_token"]').first().inputValue(); + const response = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: token, target: targetVersion }, maxRedirects: 0 }); + result.updateStatus = response.status(); assert.equal(response.status(), Number(expected)); + if (response.status() === 302) assert.equal(response.headers().location, '/admin/system'); + assert.equal((await readFile(`${installation}/VERSION`, 'utf8')).trim(), targetVersion); + const updateLog = await readFile(`${installation}/var/updates.log`, 'utf8'); + assert.ok(updateLog.includes('Dateien sind ersetzt')); assert.ok(!updateLog.includes('FEHLER')); + assert.equal((await page.request.get(origin)).status(), 503, 'Maintenance stays active until migrations finish.'); + result.systemStatus = (await page.goto(origin + '/admin/system')).status(); assert.equal(result.systemStatus, 200); + const finish = page.locator('form').filter({ has: page.locator('input[name=finish]') }); + assert.equal(await finish.count(), 1, 'The migration step remains accessible with the same session.'); + const csrf = await finish.locator('[name="_csrf_token"]').inputValue(); + const finished = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: csrf, finish: '1' }, maxRedirects: 0 }); + result.finishStatus = finished.status(); assert.equal(finished.status(), 302); + result.publicStatus = (await page.request.get(origin)).status(); assert.equal(result.publicStatus, 200); + assert.equal((await page.goto(origin + '/admin/system')).status(), 200); + assert.equal(await page.locator('input[name=finish]').count(), 0); + assert.equal((await readdir(`${installation}/var/backups`)).filter(name => name.startsWith('sicherung-')).length, 1); + await access(`${installation}/.env`); + assert.equal(createHash('sha256').update(await readFile('var/kantit.db')).digest('hex'), originalHash); + if (Number(expected) === 302) assert.doesNotMatch(log, /\[critical\]|Failed to open stream|PHP Fatal error/); + result.ok = true; +} finally { + await browser?.close(); server.kill(); + await writeFile(`${run}/server.log`, log); await writeFile(`${run}/result.json`, JSON.stringify(result, null, 2)); + console.log(JSON.stringify({ ...result, report: run }, null, 2)); +} diff --git a/src/Kernel.php b/src/Kernel.php index 779cd1f..0e6c82e 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -8,4 +8,17 @@ use Symfony\Component\HttpKernel\Kernel as BaseKernel; class Kernel extends BaseKernel { use MicroKernelTrait; + + private ?string $releaseCacheDir = null; + + public function getCacheDir(): string + { + // A web update still needs its compiled services until the response has finished. + // The next release builds its own cache without deleting the running request's files. + return $this->releaseCacheDir ??= parent::getCacheDir().'/release-'.substr( + hash_file('sha256', $this->getProjectDir().'/VERSION'), + 0, + 16, + ); + } } diff --git a/src/Ops/ArchiveUpdate.php b/src/Ops/ArchiveUpdate.php index 484b154..bf9594c 100644 --- a/src/Ops/ArchiveUpdate.php +++ b/src/Ops/ArchiveUpdate.php @@ -218,9 +218,9 @@ final class ArchiveUpdate } $melden($schritte, 'Dateien ersetzen', true, sprintf('%d Einträge ersetzt, var/ und .env unberührt.', $ersetzt)); - // Der Zwischenspeicher gehört zum alten Stand. - $this->removeDirectory($this->root.'/var/cache'); - $melden($schritte, 'Zwischenspeicher', true, 'Geleert - sonst liefe die neue Fassung mit den Wegweisern der alten.'); + // Never delete compiled services underneath the running kernel. The new kernel uses + // a release-specific cache directory; old requests can finish with their own files. + $melden($schritte, 'Zwischenspeicher', true, 'Die neue Fassung verwendet einen eigenen Cache; laufende Anfragen behalten ihren bisherigen.'); // Hier endet dieser Durchgang mit Absicht. Der laufende Prozess hat den alten Code im // Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen @@ -341,19 +341,4 @@ final class ArchiveUpdate return $treffer[1]; } - - private function removeDirectory(string $pfad): void - { - if (!is_dir($pfad)) { - return; - } - foreach (scandir($pfad) ?: [] as $eintrag) { - if ('.' === $eintrag || '..' === $eintrag) { - continue; - } - $voll = $pfad.'/'.$eintrag; - is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll); - } - @rmdir($pfad); - } } diff --git a/tests/ArchiveUpdateTest.php b/tests/ArchiveUpdateTest.php index f1b1afe..47b4aca 100644 --- a/tests/ArchiveUpdateTest.php +++ b/tests/ArchiveUpdateTest.php @@ -70,10 +70,12 @@ final class ArchiveUpdateTest extends TestCase public function testAnUpdateReplacesTheApplicationAndLeavesTheInstallationUntouched(): void { $this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0')); + $this->write('var/cache/prod/running-service.php', 'updater()->run(); self::assertTrue($ergebnis['ok'], $this->explain($ergebnis)); + self::assertFileExists($this->root.'/var/cache/prod/running-service.php', 'The update request still loads compiled services after replacing files.'); self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.'); self::assertSame( ['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'], diff --git a/tests/KernelCacheTest.php b/tests/KernelCacheTest.php new file mode 100644 index 0000000..48c1ecf --- /dev/null +++ b/tests/KernelCacheTest.php @@ -0,0 +1,38 @@ + new class($root) extends Kernel { + public function __construct(private string $root) + { + parent::__construct('prod', false); + } + + public function getProjectDir(): string + { + return $this->root; + } + }; + try { + file_put_contents($root.'/VERSION', "1.10.1\n"); + $running = $kernel(); + $old = $running->getCacheDir(); + self::assertSame($old, $kernel()->getCacheDir()); + file_put_contents($root.'/VERSION', "1.10.2\n"); + self::assertSame($old, $running->getCacheDir(), 'The active request retains its cache even after VERSION changes.'); + self::assertNotSame($old, $kernel()->getCacheDir(), 'A new kernel must not reuse the previous compiled container or Twig templates.'); + } finally { + unlink($root.'/VERSION'); + rmdir($root); + } + } +}