262 lines
8.9 KiB
PHP
262 lines
8.9 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';
|
|
|
|
/**
|
|
* 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,
|
|
|
|
'last_state' => $lastRun['state'] ?? 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),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
}
|