CluPilotCloud/app/Services/Deployment/UpdateWindow.php

129 lines
3.9 KiB
PHP

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