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
parent
07020a9ea0
commit
b02266ca8e
13
CHANGELOG.md
13
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue