fix(deploy): build prod image with host clusev uid/gid so sentinels are writable

The prod image baked the app user at uid 1002, but install.sh chowns the
bind-mounted ./run to the clusev host user. On a host where clusev != 1002 the
app process could not write the restart/update sentinel — the dashboard button
showed 'läuft' while nothing happened on the server. Pass APP_UID/APP_GID =
HOST_UID/HOST_GID into the prod build so the in-image app user matches clusev
and ./run is writable. Also surface a failed sentinel write as an error toast
instead of a stuck 'running' state (requestUpdate now returns bool).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.9.15
boban 2026-06-19 19:45:20 +02:00
parent 07020a9ea0
commit 703207c03c
8 changed files with 49 additions and 4 deletions

View File

@ -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

View File

@ -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.

View File

@ -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). */

View File

@ -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

View File

@ -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:

View File

@ -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.',

View File

@ -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.',

View File

@ -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');