diff --git a/CHANGELOG.md b/CHANGELOG.md index 30e3c1c..fa5fae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.9.15] - 2026-06-19 + +### Behoben +- **Dashboard-Update/-Neustart hingen auf Hosts, deren `clusev`-Nutzer nicht uid 1002 hat.** Das + Prod-Image baute den `app`-Nutzer fest mit uid 1002, während `install.sh` das bind-gemountete + `./run` dem `clusev`-Nutzer (z. B. uid 1000) übergibt. Dadurch konnte der App-Prozess die + Sentinel-Datei nicht schreiben — der Knopf zeigte „läuft", aber auf dem Server passierte nichts. + Das Image wird jetzt mit `APP_UID/APP_GID = HOST_UID/HOST_GID` (dem `clusev`-Nutzer) gebaut, sodass + App-Prozess und `./run` übereinstimmen. **Einmalig per `sudo ./update.sh` neu bauen**, danach + funktioniert der Knopf (der konnte sich nicht selbst reparieren, da genau das Schreiben kaputt war). +- **Fehlgeschlagenes Sentinel-Schreiben wird jetzt gemeldet** statt eines ewigen „läuft": geht das + Schreiben schief, erscheint ein Fehler-Toast mit dem Hinweis auf `sudo ./update.sh`. + ## [0.9.14] - 2026-06-19 ### Geändert diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 88a4b10..e08ca26 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -197,7 +197,13 @@ class Index extends Component } RateLimiter::hit($key, 600); - $deployment->requestUpdate(); + // Write the sentinel; if it can't be written (e.g. the bind-mounted dir isn't writable by + // the app user), surface that instead of a "running" state that would never resolve. + if (! $deployment->requestUpdate()) { + $this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error'); + + return; + } // Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice // survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version // moves past this base. 15 min TTL > the poll timeout, so a reload can still resume. diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index 2227a6d..35f099c 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -213,7 +213,7 @@ class DeploymentService * (rebuild, migrate, restart) — then removes the sentinel. The container never runs git or * Docker itself; it only writes this non-sensitive marker. Never throws. */ - public function requestUpdate(): void + public function requestUpdate(): bool { $path = $this->updateSignalPath(); $dir = dirname($path); @@ -222,7 +222,9 @@ class DeploymentService @mkdir($dir, 0775, true); } - @file_put_contents($path, now()->toIso8601String().PHP_EOL); + // Returns false when the (bind-mounted) sentinel dir is not writable by the app user — + // the caller surfaces that instead of showing a "running" state that never resolves. + return @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false; } /** True while an update request is pending (the host watcher clears it once handled). */ diff --git a/config/clusev.php b/config/clusev.php index f35d46c..a85a1f2 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.14', + 'version' => '0.9.15', // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // image ships no .git); the Versions page prefers them and falls back to a live .git read in diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b7c4a4a..7efa38b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -31,6 +31,13 @@ services: context: . dockerfile: Dockerfile target: prod + args: + # Build the in-image `app` user with the HOST's clusev uid/gid so php-fpm can write the + # bind-mounted ./run sentinel (install.sh chowns ./run to clusev). Without this the image + # defaults to 1002 and on a host where clusev != 1002 the dashboard restart/update buttons + # silently fail to write their sentinel. install.sh sets HOST_UID/HOST_GID from clusev. + APP_UID: "${HOST_UID:-1002}" + APP_GID: "${HOST_GID:-1002}" expose: - "80" volumes: diff --git a/lang/de/versions.php b/lang/de/versions.php index 424fb1b..7020fd7 100644 --- a/lang/de/versions.php +++ b/lang/de/versions.php @@ -52,6 +52,7 @@ return [ 'update_running' => 'Update läuft — Stack wird neu gebaut. Bitte gleich neu laden.', 'update_not_available' => 'Kein Update verfügbar — die installierte Version ist bereits aktuell.', 'update_throttled' => 'Zu viele Update-Anfragen — bitte in :seconds Sekunden erneut versuchen.', + 'update_write_failed' => 'Update konnte nicht angestoßen werden (Sentinel nicht schreibbar). Bitte einmalig per SSH „sudo ./update.sh" aktualisieren.', 'update_done' => 'Update abgeschlossen — jetzt auf v:version.', 'update_stalled' => 'Update dauert ungewöhnlich lange — Logs prüfen (journalctl -u clusev-update.service) oder Seite neu laden.', diff --git a/lang/en/versions.php b/lang/en/versions.php index 79e2208..ae36ba3 100644 --- a/lang/en/versions.php +++ b/lang/en/versions.php @@ -52,6 +52,7 @@ return [ 'update_running' => 'Update in progress — the stack is rebuilding. Reload shortly.', 'update_not_available' => 'No update available — the installed version is already current.', 'update_throttled' => 'Too many update requests — try again in :seconds seconds.', + 'update_write_failed' => 'Could not start the update (sentinel not writable). Please update once via SSH with "sudo ./update.sh".', 'update_done' => 'Update complete — now on v:version.', 'update_stalled' => 'Update is taking unusually long — check the logs (journalctl -u clusev-update.service) or reload.', diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php index 649cc2d..ce51a43 100644 --- a/tests/Feature/VersionUpdateCheckTest.php +++ b/tests/Feature/VersionUpdateCheckTest.php @@ -224,6 +224,21 @@ class VersionUpdateCheckTest extends TestCase ->assertSee('feat/v1-foundation'); } + public function test_update_surfaces_an_error_when_the_sentinel_cannot_be_written(): void + { + config()->set('clusev.version', '0.9.10'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.14']])]); + $mock = \Mockery::mock(DeploymentService::class)->makePartial(); + $mock->shouldReceive('requestUpdate')->andReturn(false); // unwritable sentinel dir + $this->app->instance(DeploymentService::class, $mock); + + Livewire::test(Index::class) + ->call('requestUpdate') + ->assertSet('updateRequested', false); + + $this->assertNull(Cache::get('clusev:update-in-progress'), 'no in-progress marker when the sentinel write failed'); + } + public function test_request_update_is_throttled_per_user(): void { config()->set('clusev.version', '0.9.4');