Update on a schedule if the owner wants one, and say where incidents go
── 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
parent
ea643b5e73
commit
0e3c50d9a1
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: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();
|
||||
});
|
||||
Loading…
Reference in New Issue