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(); +});