Update on a schedule if the owner wants one, and say where incidents go
tests / pest (push) Failing after 8m22s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

── 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 <noreply@anthropic.com>
feature/betriebsmodus v1.3.12
nexxo 2026-07-29 15:01:54 +02:00
parent ea643b5e73
commit 0e3c50d9a1
12 changed files with 525 additions and 1 deletions

View File

@ -1 +1 @@
1.3.11
1.3.12

View File

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use Illuminate\Console\Command;
/**
* Takes an available update, inside the window, if the owner asked for that.
*
* It leaves exactly the same request the button leaves same file, same agent,
* same run. There is no second path into a deployment, because a second path is
* a second set of rules to keep in step with the first.
*
* Nothing here checks whether an update already ran today. It does not need to:
* an installation that is current has nothing available, so an operator who
* pressed the button at four finds the five o'clock window with nothing to do.
*/
class AutoUpdate extends Command
{
protected $signature = 'clupilot:auto-update {--force : ignore the window, for testing the wiring}';
protected $description = 'Install an available release during the configured update window';
public function handle(UpdateWindow $window, UpdateChannel $channel): int
{
if (! $window->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;
}
}

View File

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

View File

@ -0,0 +1,128 @@
<?php
namespace App\Services\Deployment;
use App\Support\Settings;
use Illuminate\Support\Carbon;
/**
* When the installation is allowed to update itself.
*
* The button stays: manual is always possible, at any hour. This is the other
* option tick it, name a window, and the installation takes the update in
* that window on its own. An update is one to three minutes of maintenance
* mode, which is short enough not to announce and long enough that nobody
* wants it at eleven in the morning.
*
* There is nothing to guard against having already updated by hand: an
* installation that is current has nothing available, and the window finds
* nothing to do. The condition is "is there something newer", not "has today's
* update run".
*/
class UpdateWindow
{
public const KEY_ENABLED = 'deploy.auto_update';
public const KEY_DAYS = 'deploy.auto_days';
public const KEY_FROM = 'deploy.auto_from';
public const KEY_TO = 'deploy.auto_to';
/**
* Tuesday to Thursday by default.
*
* Not Monday the week starts and every fault costs double. Not Friday
* nobody wants to repair a deployment on a Friday afternoon. ISO weekdays,
* so 1 is Monday.
*/
public const DEFAULT_DAYS = [2, 3, 4];
/**
* Half past five by default.
*
* Not three in the morning: if it goes wrong, the person who has to fix it
* is asleep, and an outage nobody is awake for is an outage that lasts
* until somebody wakes up.
*/
public const DEFAULT_FROM = '05:00';
public const DEFAULT_TO = '06:00';
public function enabled(): bool
{
return Settings::bool(self::KEY_ENABLED, false);
}
/** @return array<int, int> 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();
}
}

View File

@ -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',
];

View File

@ -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',
],
];

View File

@ -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',
];

View File

@ -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',
],
];

View File

@ -2,6 +2,20 @@
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin_incidents.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin_incidents.subtitle') }}</p>
{{-- 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. --}}
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
<x-ui.icon name="activity" class="size-4 shrink-0 text-accent-text" />
<span class="text-body">{{ __('admin_incidents.appears_on') }}</span>
<a href="{{ route('status') }}" target="_blank" rel="noopener"
class="inline-flex items-center gap-1.5 font-medium text-accent-text underline-offset-4 hover:underline">
{{ __('admin_incidents.open_status') }}<x-ui.icon name="external-link" class="size-3.5" />
</a>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px] lg:items-start">

View File

@ -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. --}}
<div class="mt-5 border-t border-line pt-5">
<label class="flex items-start gap-3">
<input type="checkbox" wire:model.live="autoUpdate" class="mt-0.5 size-4 rounded border-line text-accent-active">
<span>
<span class="block text-sm font-semibold text-ink">{{ __('admin_settings.auto_title') }}</span>
<span class="mt-1 block max-w-[64ch] text-xs leading-relaxed text-muted">{{ __('admin_settings.auto_hint') }}</span>
</span>
</label>
<div class="mt-4 space-y-4 @unless ($autoUpdate) opacity-50 @endunless">
<div>
<span class="mb-2 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_days') }}</span>
<div class="flex flex-wrap gap-1.5">
@foreach ([1, 2, 3, 4, 5, 6, 7] as $day)
<label class="cursor-pointer">
<input type="checkbox" wire:model="autoDays" value="{{ $day }}" class="peer sr-only" @disabled(! $autoUpdate)>
<span class="inline-flex min-h-9 items-center rounded border border-line bg-surface px-3 text-sm text-muted transition peer-checked:border-accent-border peer-checked:bg-accent-subtle peer-checked:font-semibold peer-checked:text-accent-text">
{{ __('admin_settings.weekday.'.$day) }}
</span>
</label>
@endforeach
</div>
@error('autoDays') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap items-end gap-3">
<div>
<label for="auto-from" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_from') }}</label>
<input id="auto-from" type="time" wire:model="autoFrom" @disabled(! $autoUpdate)
class="min-h-10 rounded border border-line bg-surface px-3 font-mono text-sm text-ink">
@error('autoFrom') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<div>
<label for="auto-to" class="mb-1.5 block text-xs font-semibold text-muted">{{ __('admin_settings.auto_to') }}</label>
<input id="auto-to" type="time" wire:model="autoTo" @disabled(! $autoUpdate)
class="min-h-10 rounded border border-line bg-surface px-3 font-mono text-sm text-ink">
@error('autoTo') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<x-ui.button wire:click="saveUpdateWindow" variant="secondary" size="sm">{{ __('admin_settings.save') }}</x-ui.button>
</div>
<p class="text-xs text-muted">
@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
</p>
</div>
</div>
@if ($update['last_state'] !== null && ! $update['running'])
<p class="mt-3 text-xs text-muted">
{{ __('admin_settings.update_last_run', [

View File

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

View File

@ -0,0 +1,176 @@
<?php
use App\Livewire\Admin\Settings as AdminSettings;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* Updating on a schedule instead of on a click.
*
* The button does not go away manual stays possible at any hour. This is the
* option beside it, and the tests are about the two things it must never do:
* run outside the window it was given, and do anything at all while it is
* switched off.
*/
beforeEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
function windowStatus(): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => '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:0002: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();
});