From 0e3c50d9a14802fbedaefc08d0aef7cdd5d8df8a Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 15:01:54 +0200 Subject: [PATCH] Update on a schedule if the owner wants one, and say where incidents go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ── Where the status page gets its data ────────────────────────────────────── Asked in as many words, and fairly: the page for maintaining incidents exists (console → Betrieb → Störungen) and nothing anywhere said so. The page now carries the answer at the top — what is written there appears publicly at once, the four components measure themselves, and there is a link straight to the live status page. ── Automatic updates, inside a window, switchable ─────────────────────────── Tick it and name the days and hours; leave it alone and nothing changes. The button stays either way: manual is always possible, at any hour. It leaves exactly the same request the button leaves — same file, same agent, same run. A second path into a deployment would be a second set of rules to keep in step with the first, and the automation must not be able to do what an operator is prevented from doing by hand: it checks the same three things the button is disabled by (agent alive, something released, nothing running). Nothing guards against having already updated by hand, and nothing needs to: an installation that is current has nothing available, so pressing the button at four leaves the five o'clock window with nothing to do. That was the owner's own question and it answers itself. Defaults are Tuesday to Thursday, 05:00–06:00. Not Monday — the week starts and every fault costs double. Not Friday — nobody wants to repair a deployment on a Friday afternoon. Not three in the morning: if it goes wrong, the person who has to fix it is asleep. A window whose end is before its start wraps past midnight, because 23:00 to 02:00 is a reasonable thing to ask for and reading it as an empty range would silently mean "never". Switched on with no weekday selected is refused rather than corrected — it is a configuration that reads as working and does nothing. The clock is the wall clock (R19): an operator who writes 05:00 means five in the morning where they live, and a window that drifts by an hour twice a year is a window nobody trusts. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Console/Commands/AutoUpdate.php | 52 ++++++ app/Livewire/Admin/Settings.php | 52 ++++++ app/Services/Deployment/UpdateWindow.php | 128 +++++++++++++ lang/de/admin_incidents.php | 3 + lang/de/admin_settings.php | 15 ++ lang/en/admin_incidents.php | 3 + lang/en/admin_settings.php | 15 ++ .../views/livewire/admin/incidents.blade.php | 14 ++ .../views/livewire/admin/settings.blade.php | 58 ++++++ routes/console.php | 8 + tests/Feature/Admin/UpdateWindowTest.php | 176 ++++++++++++++++++ 12 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/AutoUpdate.php create mode 100644 app/Services/Deployment/UpdateWindow.php create mode 100644 tests/Feature/Admin/UpdateWindowTest.php diff --git a/VERSION b/VERSION index 17e63e7..90a7f60 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.11 +1.3.12 diff --git a/app/Console/Commands/AutoUpdate.php b/app/Console/Commands/AutoUpdate.php new file mode 100644 index 0000000..cf2448f --- /dev/null +++ b/app/Console/Commands/AutoUpdate.php @@ -0,0 +1,52 @@ +enabled()) { + return self::SUCCESS; + } + + if (! $this->option('force') && ! $window->isOpen()) { + return self::SUCCESS; + } + + $state = $channel->state(); + + // The agent has to be alive, there has to be something to install, and + // nothing may be running. All three come from the same state the button + // is disabled by, so the automation cannot do what an operator is + // prevented from doing by hand. + if (! $state['agent_seen'] || ! $state['available'] || $state['running']) { + return self::SUCCESS; + } + + if ($channel->request(__('admin_settings.update_by_schedule'))) { + $this->info('Requested an update inside the window.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 158df31..e10d9c1 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -10,6 +10,7 @@ use App\Models\Operator; use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; use App\Services\Deployment\UpdateChannel; +use App\Services\Deployment\UpdateWindow; use App\Support\Settings as AppSettings; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -74,6 +75,12 @@ class Settings extends Component $this->name = $operator->name; $this->email = $operator->email; $this->requireTwoFactor = AppSettings::bool('console.require_2fa', false); + + $window = app(UpdateWindow::class); + $this->autoUpdate = $window->enabled(); + $this->autoDays = $window->days(); + $this->autoFrom = $window->from(); + $this->autoTo = $window->to(); } /** @@ -395,6 +402,51 @@ class Settings extends Component * but only a released version above the one installed. There is no longer * such a thing as "update to whatever is on the branch". */ + /** The update window — see App\Services\Deployment\UpdateWindow. */ + public bool $autoUpdate = false; + + /** @var array ISO weekdays */ + public array $autoDays = []; + + public string $autoFrom = ''; + + public string $autoTo = ''; + + /** + * Switch the automation on or off and set its window. + * + * One action for all four values: a half-saved window — enabled with no + * days, or a start without an end — is a configuration that reads as + * working and does nothing. + */ + public function saveUpdateWindow(): void + { + $this->authorize('site.manage'); + + $this->validate([ + 'autoDays' => 'array', + 'autoDays.*' => 'integer|min:1|max:7', + 'autoFrom' => ['required', 'regex:/^([01]\\d|2[0-3]):[0-5]\\d$/'], + 'autoTo' => ['required', 'regex:/^([01]\\d|2[0-3]):[0-5]\\d$/'], + ]); + + // Refused rather than silently corrected: an automation switched on + // with no day selected would never run, and an interface that accepts + // that has told the operator something untrue. + if ($this->autoUpdate && $this->autoDays === []) { + $this->addError('autoDays', __('admin_settings.auto_days')); + + return; + } + + AppSettings::set(UpdateWindow::KEY_ENABLED, $this->autoUpdate); + AppSettings::set(UpdateWindow::KEY_DAYS, array_values(array_map('intval', $this->autoDays))); + AppSettings::set(UpdateWindow::KEY_FROM, $this->autoFrom); + AppSettings::set(UpdateWindow::KEY_TO, $this->autoTo); + + $this->dispatch('notify', message: __('admin_settings.auto_saved')); + } + public function requestUpdate(): void { $this->authorize('site.manage'); diff --git a/app/Services/Deployment/UpdateWindow.php b/app/Services/Deployment/UpdateWindow.php new file mode 100644 index 0000000..3ab5441 --- /dev/null +++ b/app/Services/Deployment/UpdateWindow.php @@ -0,0 +1,128 @@ + ISO weekdays */ + public function days(): array + { + $days = Settings::get(self::KEY_DAYS, self::DEFAULT_DAYS); + + if (! is_array($days)) { + return self::DEFAULT_DAYS; + } + + // Filtered rather than trusted: this is read on a schedule, and a + // malformed setting must not be able to mean "every day" by accident. + $days = array_values(array_unique(array_filter( + array_map('intval', $days), + fn (int $day) => $day >= 1 && $day <= 7, + ))); + + return $days === [] ? self::DEFAULT_DAYS : $days; + } + + public function from(): string + { + return $this->time(self::KEY_FROM, self::DEFAULT_FROM); + } + + public function to(): string + { + return $this->time(self::KEY_TO, self::DEFAULT_TO); + } + + private function time(string $key, string $default): string + { + $value = (string) Settings::get($key, $default); + + return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value) === 1 ? $value : $default; + } + + /** + * Is now inside the window? + * + * On the wall clock, not UTC (R19): an operator who writes 05:00 means five + * in the morning where they live, and a window that drifts by an hour twice + * a year is a window nobody trusts. + * + * A window that ends before it starts wraps past midnight — 23:00 to 02:00 + * is a perfectly reasonable thing to ask for, and reading it as an empty + * range would silently mean "never". + */ + public function isOpen(?Carbon $at = null): bool + { + $now = ($at ?? Carbon::now())->local(); + + if (! in_array((int) $now->isoWeekday(), $this->days(), true)) { + // …unless the window wraps and we are in the tail of yesterday's. + return $this->wraps() + && in_array((int) $now->copy()->subDay()->isoWeekday(), $this->days(), true) + && $now->format('H:i') < $this->to(); + } + + $clock = $now->format('H:i'); + $from = $this->from(); + $to = $this->to(); + + return $this->wraps() + ? ($clock >= $from || $clock < $to) + : ($clock >= $from && $clock < $to); + } + + private function wraps(): bool + { + return $this->to() <= $this->from(); + } +} diff --git a/lang/de/admin_incidents.php b/lang/de/admin_incidents.php index a731af2..24b3072 100644 --- a/lang/de/admin_incidents.php +++ b/lang/de/admin_incidents.php @@ -26,4 +26,7 @@ return [ 'created' => 'Störung veröffentlicht.', 'update_posted' => 'Eintrag hinzugefügt.', + + 'appears_on' => 'Was Sie hier eintragen, erscheint sofort öffentlich auf der Statusseite. Die vier Dienste darüber messen sich selbst — hier steht das, was keine Messung wissen kann.', + 'open_status' => 'Statusseite öffnen', ]; diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index efb3c83..4bc7c17 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -175,4 +175,19 @@ return [ 'password_save' => 'Passwort ändern', 'password_wrong' => 'Das aktuelle Passwort stimmt nicht.', 'password_changed' => 'Passwort geändert.', + + // ── Automatik ──────────────────────────────────────────────────────── + 'auto_title' => 'Automatisch aktualisieren', + 'auto_hint' => 'Holt eine verfügbare Version im gewählten Zeitfenster von selbst. Von Hand geht es weiterhin jederzeit — und wer vorher schon aktualisiert hat, für den findet das Fenster nichts mehr zu tun.', + 'auto_enabled' => 'Automatik einschalten', + 'auto_days' => 'Wochentage', + 'auto_from' => 'ab', + 'auto_to' => 'bis', + 'auto_saved' => 'Zeitfenster gespeichert.', + 'auto_off' => 'Automatik ist aus — Updates nur von Hand.', + 'auto_next' => 'Nächstes Fenster: :days, :from–:to Uhr.', + 'update_by_schedule' => 'Automatik', + 'weekday' => [ + 1 => 'Mo', 2 => 'Di', 3 => 'Mi', 4 => 'Do', 5 => 'Fr', 6 => 'Sa', 7 => 'So', + ], ]; diff --git a/lang/en/admin_incidents.php b/lang/en/admin_incidents.php index 1b3138f..327d149 100644 --- a/lang/en/admin_incidents.php +++ b/lang/en/admin_incidents.php @@ -26,4 +26,7 @@ return [ 'created' => 'Incident published.', 'update_posted' => 'Entry added.', + + 'appears_on' => 'What you write here appears publicly on the status page at once. The four components above measure themselves — this is the part no probe can know.', + 'open_status' => 'Open the status page', ]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 440dfc1..96b2131 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -174,4 +174,19 @@ return [ 'password_save' => 'Change password', 'password_wrong' => 'The current password is not correct.', 'password_changed' => 'Password changed.', + + // ── Automation ─────────────────────────────────────────────────────── + 'auto_title' => 'Update automatically', + 'auto_hint' => 'Takes an available release on its own inside the chosen window. By hand still works at any time — and if you updated earlier, the window finds nothing to do.', + 'auto_enabled' => 'Enable automation', + 'auto_days' => 'Weekdays', + 'auto_from' => 'from', + 'auto_to' => 'to', + 'auto_saved' => 'Window saved.', + 'auto_off' => 'Automation is off — updates by hand only.', + 'auto_next' => 'Next window: :days, :from–:to.', + 'update_by_schedule' => 'Automation', + 'weekday' => [ + 1 => 'Mon', 2 => 'Tue', 3 => 'Wed', 4 => 'Thu', 5 => 'Fri', 6 => 'Sat', 7 => 'Sun', + ], ]; diff --git a/resources/views/livewire/admin/incidents.blade.php b/resources/views/livewire/admin/incidents.blade.php index 6fb1f27..6b00566 100644 --- a/resources/views/livewire/admin/incidents.blade.php +++ b/resources/views/livewire/admin/incidents.blade.php @@ -2,6 +2,20 @@

{{ __('admin_incidents.title') }}

{{ __('admin_incidents.subtitle') }}

+ + {{-- Where this ends up. Asked in as many words — "where do I enter + something, and where does the status page get its data from" — and + nothing on either page said so. The four components measure + themselves; what an operator adds here is the part no probe can + know. --}} +
+ + {{ __('admin_incidents.appears_on') }} + + {{ __('admin_incidents.open_status') }} + +
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 12ee76d..8172fb2 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -162,6 +162,64 @@ running time and the log tail — it is the one that survives the restart, so it is the one that keeps them. --}} + {{-- The window. Deliberately inside the update card rather than a + section of its own: it is the same decision as the button next + to it, only taken in advance. --}} +
+ + +
+
+ {{ __('admin_settings.auto_days') }} +
+ @foreach ([1, 2, 3, 4, 5, 6, 7] as $day) + + @endforeach +
+ @error('autoDays')

{{ $message }}

@enderror +
+ +
+
+ + + @error('autoFrom')

{{ $message }}

@enderror +
+
+ + + @error('autoTo')

{{ $message }}

@enderror +
+ {{ __('admin_settings.save') }} +
+ +

+ @if ($autoUpdate) + {{ __('admin_settings.auto_next', [ + 'days' => collect($autoDays)->sort()->map(fn ($d) => __('admin_settings.weekday.'.$d))->join(', '), + 'from' => $autoFrom, + 'to' => $autoTo, + ]) }} + @else + {{ __('admin_settings.auto_off') }} + @endif +

+
+
+ @if ($update['last_state'] !== null && ! $update['running'])

{{ __('admin_settings.update_last_run', [ diff --git a/routes/console.php b/routes/console.php index 5cb6b99..aa2f26d 100644 --- a/routes/console.php +++ b/routes/console.php @@ -84,3 +84,11 @@ Schedule::command('clupilot:sample-status') Schedule::command('clupilot:verify-domains') ->dailyAt('03:40') ->withoutOverlapping(); + +// Take an available release inside the configured window, if the owner has +// switched that on. Every five minutes rather than once at the start of the +// window: a scheduler that was down for those sixty seconds would otherwise +// skip the whole night. +Schedule::command('clupilot:auto-update') + ->everyFiveMinutes() + ->withoutOverlapping(); diff --git a/tests/Feature/Admin/UpdateWindowTest.php b/tests/Feature/Admin/UpdateWindowTest.php new file mode 100644 index 0000000..9deba3f --- /dev/null +++ b/tests/Feature/Admin/UpdateWindowTest.php @@ -0,0 +1,176 @@ + 'idle', + 'checked_at' => now()->toIso8601String(), + 'behind' => 1, + ])); +} + +function openWindow(array $days, string $from = '05:00', string $to = '06:00'): void +{ + AppSettings::set(UpdateWindow::KEY_ENABLED, true); + AppSettings::set(UpdateWindow::KEY_DAYS, $days); + AppSettings::set(UpdateWindow::KEY_FROM, $from); + AppSettings::set(UpdateWindow::KEY_TO, $to); +} + +it('does nothing at all while the automation is off', function () { + // The switch has to mean it. An installation whose owner deliberately + // updates by hand must never find itself in maintenance mode at dawn. + windowStatus(); + AppSettings::set(UpdateWindow::KEY_ENABLED, false); + + $this->artisan('clupilot:auto-update')->assertSuccessful(); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); + +it('takes an available release inside the window', function () { + // Wednesday, 05:30 local. + Carbon::setTestNow(Carbon::parse('2026-07-29 03:30:00', 'UTC')); + windowStatus(); + openWindow([3]); + + expect(app(UpdateWindow::class)->isOpen())->toBeTrue(); + + $this->artisan('clupilot:auto-update')->assertSuccessful(); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue(); +}); + +it('leaves it alone outside the hours', function () { + // Wednesday, 11:30 local — the right day, the wrong time. + Carbon::setTestNow(Carbon::parse('2026-07-29 09:30:00', 'UTC')); + windowStatus(); + openWindow([3]); + + expect(app(UpdateWindow::class)->isOpen())->toBeFalse(); + + $this->artisan('clupilot:auto-update'); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); + +it('leaves it alone on a day that was not chosen', function () { + Carbon::setTestNow(Carbon::parse('2026-07-29 03:30:00', 'UTC')); // Wednesday + windowStatus(); + openWindow([1, 2]); // Mon, Tue + + expect(app(UpdateWindow::class)->isOpen())->toBeFalse(); +}); + +it('understands a window that runs past midnight', function () { + // 23:00–02:00 is a perfectly reasonable thing to ask for, and reading it + // as an empty range would silently mean "never". + $window = app(UpdateWindow::class); + + // Wednesday 23:30 local — inside, on the chosen day. + openWindow([3], '23:00', '02:00'); + Carbon::setTestNow(Carbon::parse('2026-07-29 21:30:00', 'UTC')); + expect($window->isOpen())->toBeTrue(); + + // Thursday 01:30 local — still the tail of Wednesday's window. + Carbon::setTestNow(Carbon::parse('2026-07-29 23:30:00', 'UTC')); + expect($window->isOpen())->toBeTrue(); + + // Thursday 03:30 local — over. + Carbon::setTestNow(Carbon::parse('2026-07-30 01:30:00', 'UTC')); + expect($window->isOpen())->toBeFalse(); +}); + +it('finds nothing to do when the operator already updated by hand', function () { + // Named by the owner as the case that must not surprise them: pressed at + // four, window at five. Nothing guards against it explicitly — an + // installation that is current simply has nothing available. + Carbon::setTestNow(Carbon::parse('2026-07-29 03:30:00', 'UTC')); + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'checked_at' => now()->toIso8601String(), + 'behind' => 0, + ])); + openWindow([3]); + + expect(app(UpdateChannel::class)->state()['available'])->toBeFalse(); + + $this->artisan('clupilot:auto-update'); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); + +it('does not queue a second run over one already in flight', function () { + Carbon::setTestNow(Carbon::parse('2026-07-29 03:30:00', 'UTC')); + windowStatus(); + openWindow([3]); + + app(UpdateChannel::class)->request('owner@example.com'); + $before = File::get(storage_path('app/deploy/update-request.json')); + + $this->artisan('clupilot:auto-update'); + + expect(File::get(storage_path('app/deploy/update-request.json')))->toBe($before); +}); + +it('will not save an automation that could never run', function () { + // Switched on with no day selected is a configuration that reads as working + // and does nothing. Refused rather than silently corrected. + Livewire::actingAs(operator('Owner'), 'operator') + ->test(AdminSettings::class) + ->set('autoUpdate', true) + ->set('autoDays', []) + ->set('autoFrom', '05:00') + ->set('autoTo', '06:00') + ->call('saveUpdateWindow') + ->assertHasErrors('autoDays'); + + expect(app(UpdateWindow::class)->enabled())->toBeFalse(); +}); + +it('saves a window the owner can see again afterwards', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(AdminSettings::class) + ->set('autoUpdate', true) + ->set('autoDays', [2, 4]) + ->set('autoFrom', '04:30') + ->set('autoTo', '05:30') + ->call('saveUpdateWindow') + ->assertHasNoErrors(); + + $window = app(UpdateWindow::class); + + expect($window->enabled())->toBeTrue() + ->and($window->days())->toBe([2, 4]) + ->and($window->from())->toBe('04:30') + ->and($window->to())->toBe('05:30'); +}); + +it('needs the capability to change when the installation updates itself', function () { + Livewire::actingAs(operator('Support'), 'operator') + ->test(AdminSettings::class) + ->call('saveUpdateWindow') + ->assertForbidden(); +});