CluPilotCloud/app/Services/Deployment/UpdateChannel.php

399 lines
14 KiB
PHP

<?php
namespace App\Services\Deployment;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Throwable;
/**
* The panel's side of "update this installation".
*
* The panel cannot update anything itself, and it should not be able to. It
* runs as www-data inside a container; the update runs on the host, as the
* service account, and restarts the very container the request came from. A
* button that shelled out would be a button that kills its own web server
* mid-response — and it would need the app to hold host-level credentials,
* which is a far worse thing to own than an out-of-date checkout.
*
* So this is a mailbox, not a command:
*
* panel → writes a request into storage/app/deploy/
* agent → (deploy/update-agent.sh, on the host, on a timer) consumes it,
* runs deploy/update.sh, writes back what happened
* panel → reads the result
*
* The directory is inside the bind-mounted checkout, so both sides see it
* without anything being exposed over the network.
*
* Everything here degrades to "unknown" rather than throwing. A broken or
* absent status file must leave the settings page readable — it is the page an
* operator opens when something is already wrong.
*/
final class UpdateChannel
{
/** Written by the panel, consumed by the agent. */
private const REQUEST = 'deploy/update-request.json';
/** Written by the agent after every check and every run. */
private const STATUS = 'deploy/update-status.json';
/**
* The outcome of the last actual run, kept apart from the periodic check.
*
* In one file the next idle check would overwrite a failure five minutes
* later, and an operator would usually never learn that the update failed.
*/
private const LAST_RUN = 'deploy/update-last-run.json';
/** Tail of the last run, for the operator to read without shell access. */
private const LOG = 'deploy/update-last-run.log';
/**
* The step deploy/update.sh is on, written by update.sh itself.
*
* Read from here rather than from the status file, and that is the whole
* point: the agent writes the status once, then blocks for the length of the
* run. Anything taken from the status file is therefore frozen at "the run
* started" and the step would never advance while it mattered. update.sh
* rewrites this file at every step, so it is the only live source.
*
* Two tab-separated fields: the step key, and when it started.
*/
private const PHASE = 'deploy/update-phase';
/**
* A request older than this is treated as abandoned.
*
* Without it a request written while the agent was stopped would fire the
* moment the agent came back, which could be days later and no longer what
* anyone wanted.
*/
private const REQUEST_EXPIRES_MINUTES = 30;
/**
* How stale the agent's last check may be before it counts as gone.
*
* The timer runs every five minutes; four missed ticks is a stopped agent,
* not a slow one. Without this, a single status file written once — by an
* agent since disabled, broken or uninstalled — leaves the button enabled
* forever, and pressing it writes a request nobody will ever collect while
* the page cheerfully reports an update in progress.
*/
private const AGENT_STALE_AFTER_MINUTES = 20;
/**
* What the panel needs to show, in one read.
*
* @return array<string, mixed>
*/
public function state(): array
{
$release = Release::current();
$status = $this->readJson(self::STATUS);
$lastRun = $this->readJson(self::LAST_RUN);
$request = $this->pendingRequest();
$checkedAt = $this->timestamp($status['checked_at'] ?? null);
$agentAlive = $this->agentIsAlive($status);
// A figure from an agent that has since stopped is not current, and
// "three updates behind" from last week reads exactly like now.
$behind = $agentAlive && isset($status['behind']) ? (int) $status['behind'] : null;
return [
'version' => $release->version,
'commit' => $release->commit,
'source' => $release->source,
'deployed_at' => $release->deployedAt,
// Null means "we do not know", which is not the same as "up to
// date" and must not be shown as it.
'behind' => $behind,
'available' => $behind !== null && $behind > 0,
'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null,
'checked_at' => $checkedAt,
'agent_seen' => $agentAlive,
'running' => $agentAlive && ($request !== null || ($status['state'] ?? null) === 'running'),
'requested_at' => $request !== null ? $this->timestamp($request['requested_at'] ?? null) : null,
'requested_by' => $request['requested_by'] ?? null,
// When the agent will next look. A queued update sits until the
// timer fires, and "in a few minutes" is exactly the answer that
// leaves an operator refreshing the page wondering whether the
// button did anything at all.
'next_check_at' => $agentAlive ? $this->nextCheckAt($checkedAt, $status) : null,
// The step the deployment script is on — a key, translated here.
// Read live from the phase file; older agents write none, so its
// absence is normal rather than a fault.
'phase' => $this->phase($this->currentPhase($status)),
'started_at' => $this->timestamp($status['started_at'] ?? null),
'last_state' => $lastRun['state'] ?? null,
'last_phase' => $this->phase($lastRun['phase'] ?? null),
'last_started_at' => $this->timestamp($lastRun['started_at'] ?? null),
'last_finished_at' => $this->timestamp($lastRun['finished_at'] ?? null),
// The run's own failure first; a check-level problem (the
// repository being unreachable) only when the last run was fine.
'last_error' => $this->errorMessage($lastRun) ?? $this->errorMessage($status),
];
}
/**
* The deployment step, as something a German interface can print.
*
* The script writes a key precisely so this stays translatable. An unknown
* key — a newer script against older translations — yields null rather than
* a raw identifier in the middle of a sentence.
*/
private function phase(mixed $key): ?string
{
if (! is_string($key) || $key === '') {
return null;
}
$line = __('admin_settings.update_phase.'.$key);
return is_string($line) && $line !== 'admin_settings.update_phase.'.$key ? $line : null;
}
/**
* The step key update.sh last announced, or null.
*
* Its own tiny file rather than a field in the status document: update.sh
* runs as the service account on the host and rewrites this at every step,
* while the agent that owns the status document is blocked waiting for it.
*
* Only while a run is genuinely in flight, and only if the step is newer
* than that run. A failed run deliberately LEAVES its file behind so the
* failing step can be named; without these two checks, queueing the next
* update would show it as already at "Migrating the database" — a step
* nothing has started — and hide the one thing the operator needs, which is
* when it will actually begin.
*
* @param array<string, mixed> $status
*/
private function currentPhase(array $status): ?string
{
if (($status['state'] ?? null) !== 'running') {
return null;
}
$path = storage_path('app/'.self::PHASE);
if (! is_file($path)) {
return null;
}
// Deliberately forgiving: this is a progress hint, and a half-written
// line during the moment update.sh replaces it must not throw on a page
// an operator is watching a deployment from.
$parts = explode("\t", (string) @file_get_contents($path), 2);
$key = trim($parts[0] ?? '');
if ($key === '') {
return null;
}
// Left over from a manual run of update.sh on the shell: newer than the
// last agent status, older than this run.
$startedAt = $this->timestamp($status['started_at'] ?? null);
$phaseAt = $this->timestamp(trim($parts[1] ?? ''));
if ($startedAt !== null && $phaseAt !== null && $phaseAt->lt($startedAt)) {
return null;
}
return $key;
}
/**
* The latest moment a queued update can still be picked up.
*
* Normally one interval after the agent's last check. But a systemd timer
* that ran late leaves a last check already older than its interval, and
* `checked_at + interval` is then a time in the PAST — the page would
* promise a start that has demonstrably not happened. In that case the only
* honest upper bound is one interval from now.
*
* @param array<string, mixed> $status
*/
private function nextCheckAt(?Carbon $checkedAt, array $status): ?Carbon
{
if ($checkedAt === null) {
return null;
}
$interval = $this->checkInterval($status);
$due = $checkedAt->copy()->addMinutes($interval);
return $due->isFuture() ? $due : Carbon::now()->addMinutes($interval);
}
/**
* How often the agent looks, in minutes.
*
* Reported by the agent rather than assumed here: the interval lives in the
* systemd timer, and a server whose timer was tuned would otherwise be
* given a next-check time that quietly never arrives.
*
* @param array<string, mixed> $status
*/
private function checkInterval(array $status): int
{
$minutes = (int) ($status['check_interval_minutes'] ?? 0);
// Clamped, not trusted: this value ends up in a promise to the operator,
// and a corrupt file must not produce "next check in 1970" or a time so
// far out that the page looks broken.
return $minutes >= 1 && $minutes <= 60 ? $minutes : 5;
}
/**
* Has the agent checked in recently enough to be considered alive?
*
* @param array<string, mixed> $status
*/
private function agentIsAlive(array $status): bool
{
$checkedAt = $this->timestamp($status['checked_at'] ?? null);
return $checkedAt !== null
&& $checkedAt->gt(Carbon::now()->subMinutes(self::AGENT_STALE_AFTER_MINUTES));
}
/** Is the agent in the middle of a run right now? */
public function isRunning(): bool
{
$status = $this->readJson(self::STATUS);
return ($status['state'] ?? null) === 'running' && $this->agentIsAlive($status);
}
/** Is an update run already asked for, and still current? */
public function pendingRequest(): ?array
{
$request = $this->readJson(self::REQUEST);
if ($request === []) {
return null;
}
$at = $this->timestamp($request['requested_at'] ?? null);
if ($at === null || $at->lt(Carbon::now()->subMinutes(self::REQUEST_EXPIRES_MINUTES))) {
return null;
}
return $request;
}
/**
* Ask the agent to update.
*
* Returns false when a request is already outstanding — clicking twice must
* not queue two runs, and the second click is much more likely to be
* impatience than intent.
*/
public function request(string $by): bool
{
// Both conditions, not just the request file. The agent DELETES the
// request before it starts — so between that moment and the end of the
// run there is nothing pending, and a second click from a stale tab
// would queue a whole redundant deployment for the next tick.
if ($this->pendingRequest() !== null || $this->isRunning()) {
return false;
}
$this->write(self::REQUEST, json_encode([
'requested_at' => Carbon::now()->toIso8601String(),
'requested_by' => $by,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return true;
}
/**
* The agent's failure, in the operator's language.
*
* The agent is a shell script and is not translated; it reports a code and
* the translation happens here. Anything unrecognised is passed through
* rather than swallowed — a message nobody has worded yet still beats
* silence when an update is failing.
*/
private function errorMessage(array $status): ?string
{
$code = isset($status['error']) ? trim((string) $status['error']) : '';
if ($code === '') {
return null;
}
$key = 'admin_settings.update_error.'.$code;
if (! \Illuminate\Support\Facades\Lang::has($key)) {
return $code;
}
return __($key, ['code' => (string) ($status['exit_code'] ?? '?')]);
}
/** The tail of the last run, for showing what went wrong. */
public function lastLog(int $lines = 40): ?string
{
$path = storage_path('app/'.self::LOG);
try {
if (! File::exists($path)) {
return null;
}
$all = preg_split('/\R/', trim((string) File::get($path))) ?: [];
return implode("\n", array_slice($all, -$lines));
} catch (Throwable) {
return null;
}
}
/** @return array<string, mixed> */
private function readJson(string $relative): array
{
try {
$path = storage_path('app/'.$relative);
if (! File::exists($path)) {
return [];
}
$decoded = json_decode((string) File::get($path), true);
return is_array($decoded) ? $decoded : [];
} catch (Throwable) {
return [];
}
}
private function write(string $relative, string $contents): void
{
$path = storage_path('app/'.$relative);
File::ensureDirectoryExists(dirname($path));
File::put($path, $contents);
}
private function timestamp(mixed $value): ?Carbon
{
if (! is_string($value) || $value === '') {
return null;
}
try {
return Carbon::parse($value);
} catch (Throwable) {
return null;
}
}
}