Add the update button, and give it a host-side agent that can actually update
The panel had no way to update the installation, and could not have one on its own: it runs as www-data inside a container, and the update restarts that container. A button that shelled out would kill its own web server mid-response, and would need the application to hold host-level credentials — a far worse thing to own than an out-of-date checkout. So the button is a mailbox. The panel writes a request into the checkout; a systemd timer on the host, running as the service account, consumes it, runs deploy/update.sh and writes back what happened. The same timer answers the question the panel cannot answer alone — is there anything to update — which needs a git fetch, and therefore credentials the application does not have. The parts that were wrong before review, all of which would have shipped as a button that looks fine and does nothing: - The agent was only installed by install.sh, which the installed base never runs again. Every existing server would have shown the button permanently disabled. It has its own root entry point now, install.sh delegates to it, and update.sh says so when the unit is missing. - On a release-pinned server the checkout is detached, so it followed "origin/HEAD" and then ran an update that deliberately stays on the pinned tag: updates advertised, nothing applied. It now compares TAGS in release mode and passes the target release through. - On a branch other than main it advertised that branch's commits and then deployed main's, because update.sh defaults to main. - A request written while the agent was down was executed whenever the agent next started, days later. - A single status file meant the routine five-minute check overwrote a failed update with "idle" before anyone saw it. - An agent that had been stopped left the button enabled forever, because a status file written once counted as an agent. - The agent's error messages were German strings rendered into an English interface; it reports codes now and the panel translates them. Also: the add-address button in the console-access panel was as tall as the field plus its hint, because the hint was a sibling inside the flex row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/portal-design tested-20260727-0349-276f926
parent
b860ce2e9b
commit
276f926d57
|
|
@ -5,6 +5,7 @@ namespace App\Livewire\Admin;
|
|||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Models\VpnPeer;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use App\Support\Settings as AppSettings;
|
||||
use App\Provisioning\Jobs\ApplyVpnPeer;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -322,6 +323,25 @@ class Settings extends Component
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ask the host-side agent to update this installation.
|
||||
*
|
||||
* Deliberately a request rather than an action: see UpdateChannel. The
|
||||
* button cannot report success, because success means this container is
|
||||
* restarted out from under the response — so it reports that the update was
|
||||
* asked for, and the page shows the outcome once the agent has written it.
|
||||
*/
|
||||
public function requestUpdate(): void
|
||||
{
|
||||
$this->authorize('site.manage');
|
||||
|
||||
$accepted = app(UpdateChannel::class)->request(auth()->user()->email);
|
||||
|
||||
$this->dispatch('notify', message: __($accepted
|
||||
? 'admin_settings.update_requested'
|
||||
: 'admin_settings.update_already_requested'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$staff = User::query()
|
||||
|
|
@ -338,6 +358,8 @@ class Settings extends Component
|
|||
]);
|
||||
|
||||
return view('livewire.admin.settings', [
|
||||
'update' => app(UpdateChannel::class)->state(),
|
||||
'updateLog' => app(UpdateChannel::class)->lastLog(),
|
||||
'sitePublic' => AppSettings::bool('site.public', true),
|
||||
'canManageSite' => auth()->user()?->can('site.manage') ?? false,
|
||||
// Shown so nobody has to guess why they still see the real site.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Installs the update agent's systemd timer. Root, once per machine, idempotent.
|
||||
#
|
||||
# Separate from install.sh because the installed base never runs install.sh
|
||||
# again — existing servers upgrade through deploy/update.sh, which runs as the
|
||||
# service account and cannot write to /etc/systemd. Without this as its own
|
||||
# entry point, every server that already exists would show the update button
|
||||
# permanently disabled.
|
||||
#
|
||||
# sudo bash deploy/install-agent.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_USER="${APP_USER:-clupilot}"
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "Please run as root: sudo bash deploy/install-agent.sh" >&2; exit 1; }
|
||||
id -u "$APP_USER" >/dev/null 2>&1 || { echo "No such account: $APP_USER" >&2; exit 1; }
|
||||
|
||||
install -o "$APP_USER" -g "$APP_USER" -d "$ROOT/storage/app/deploy"
|
||||
chmod +x "$ROOT/deploy/update-agent.sh"
|
||||
|
||||
cat > /etc/systemd/system/clupilot-update-agent.service <<EOF
|
||||
[Unit]
|
||||
Description=CluPilot update agent
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=$APP_USER
|
||||
WorkingDirectory=$ROOT
|
||||
ExecStart=$ROOT/deploy/update-agent.sh
|
||||
EOF
|
||||
|
||||
cat > /etc/systemd/system/clupilot-update-agent.timer <<'EOF'
|
||||
[Unit]
|
||||
Description=Check for CluPilot updates and run requested ones
|
||||
|
||||
[Timer]
|
||||
# Every five minutes: fast enough that the button feels like it did something,
|
||||
# slow enough that fetching from the repository is not a nuisance.
|
||||
OnBootSec=3min
|
||||
OnUnitActiveSec=5min
|
||||
AccuracySec=30s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now clupilot-update-agent.timer >/dev/null
|
||||
|
||||
# Run it once now, so the panel has a status to show instead of "never reported
|
||||
# in" for the first five minutes.
|
||||
systemctl start clupilot-update-agent.service || true
|
||||
|
||||
echo "Update agent installed and running (clupilot-update-agent.timer)."
|
||||
|
|
@ -414,6 +414,15 @@ ufw allow 443/tcp >/dev/null
|
|||
ufw allow 51820/udp >/dev/null
|
||||
ufw --force enable >/dev/null
|
||||
|
||||
# ── 8b. Update agent ─────────────────────────────────────────────────────────
|
||||
# The panel's update button cannot run the update itself: it is www-data inside
|
||||
# a container, and the update restarts that container. It writes a request into
|
||||
# the checkout instead, and a host-side timer picks it up. Installed by its own
|
||||
# script, because the installed base upgrades through deploy/update.sh and would
|
||||
# otherwise never get it.
|
||||
log "Installing the update agent"
|
||||
APP_USER="$APP_USER" bash "$INSTALL_DIR/deploy/install-agent.sh"
|
||||
|
||||
# ── 9. Done ──────────────────────────────────────────────────────────────────
|
||||
PUBLIC_IP="$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo '<server ip>')"
|
||||
cat <<SUMMARY
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# The host side of the panel's update button.
|
||||
#
|
||||
# The panel cannot run an update: it is www-data inside a container, and the
|
||||
# update restarts that container. So it writes a request into the checkout and
|
||||
# this agent — running on the host as the service account, on a timer — picks it
|
||||
# up, runs deploy/update.sh, and writes back what happened.
|
||||
#
|
||||
# It also answers the question the panel cannot answer on its own: is there
|
||||
# anything to update? That needs a `git fetch`, which needs credentials the
|
||||
# application deliberately does not have.
|
||||
#
|
||||
# Installed by deploy/install-agent.sh (as root, once). Runs every few minutes.
|
||||
#
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# release_mode / release_source, so this agent and update.sh agree on what this
|
||||
# installation is following rather than each deciding for itself.
|
||||
# shellcheck source=lib/release.sh
|
||||
. "$ROOT/deploy/lib/release.sh"
|
||||
|
||||
STATE_DIR="$ROOT/storage/app/deploy"
|
||||
REQUEST="$STATE_DIR/update-request.json"
|
||||
STATUS="$STATE_DIR/update-status.json"
|
||||
RUNLOG="$STATE_DIR/update-last-run.log"
|
||||
# The outcome of the last RUN, kept apart from the periodic check. Written into
|
||||
# one file, the next idle tick five minutes later would overwrite a failure with
|
||||
# "idle" — and an operator would usually never see that the update failed.
|
||||
LASTRUN="$STATE_DIR/update-last-run.json"
|
||||
LOCK="$STATE_DIR/.agent.lock"
|
||||
|
||||
# A request older than this is abandoned. The panel already refuses to show one
|
||||
# as pending; the agent has to agree, or a request written while the agent was
|
||||
# stopped fires whenever the agent next starts — which could be days later.
|
||||
REQUEST_EXPIRES_MINUTES=30
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# One agent at a time. Two overlapping runs of update.sh fight over the
|
||||
# checkout, and the loser leaves it half-updated.
|
||||
exec 9>"$LOCK"
|
||||
flock -n 9 || exit 0
|
||||
|
||||
MODE="$(release_mode)"
|
||||
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)"
|
||||
TARGET_RELEASE=''
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "${1-}" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr -d '\n\r'
|
||||
}
|
||||
|
||||
write_status() {
|
||||
local state="$1" error="${2-}"
|
||||
cat > "$STATUS.tmp" <<EOF
|
||||
{
|
||||
"state": "$(json_escape "$state")",
|
||||
"mode": "$(json_escape "$MODE")",
|
||||
"checked_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"finished_at": "$(json_escape "${FINISHED_AT:-}")",
|
||||
"local_commit": "$(json_escape "${LOCAL_COMMIT:-}")",
|
||||
"remote_commit": "$(json_escape "${REMOTE_COMMIT:-}")",
|
||||
"target_release": "$(json_escape "${TARGET_RELEASE:-}")",
|
||||
"behind": ${BEHIND:-null},
|
||||
"branch": "$(json_escape "$BRANCH")",
|
||||
"error": "$(json_escape "$error")"
|
||||
}
|
||||
EOF
|
||||
# Replaced atomically: the panel reads this on every settings render, and
|
||||
# half a JSON document is exactly the kind of thing that breaks the page at
|
||||
# the moment someone needs it.
|
||||
mv -f "$STATUS.tmp" "$STATUS"
|
||||
}
|
||||
|
||||
# The outcome of an actual run. Survives every later idle check.
|
||||
write_run() {
|
||||
local state="$1" error="${2-}"
|
||||
cat > "$LASTRUN.tmp" <<EOF
|
||||
{
|
||||
"state": "$(json_escape "$state")",
|
||||
"finished_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"commit": "$(json_escape "$(git rev-parse HEAD 2>/dev/null || echo '')")",
|
||||
"error": "$(json_escape "$error")",
|
||||
"exit_code": ${EXIT_CODE:-null}
|
||||
}
|
||||
EOF
|
||||
mv -f "$LASTRUN.tmp" "$LASTRUN"
|
||||
}
|
||||
|
||||
LOCAL_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo '')"
|
||||
REMOTE_COMMIT=''
|
||||
BEHIND=null
|
||||
FETCH_ERROR=''
|
||||
|
||||
# ── Is there anything newer? ──────────────────────────────────────────────
|
||||
# Two different questions depending on what this installation follows. A pinned
|
||||
# server does not care what landed on main — it moves between TAGS, and asking
|
||||
# the branch would advertise updates that pressing the button cannot apply.
|
||||
if [[ "$MODE" == "release" ]]; then
|
||||
CURRENT_TAG="$(release_source)"; CURRENT_TAG="${CURRENT_TAG#refs/tags/}"
|
||||
|
||||
if git fetch --quiet --tags --force origin 2>/dev/null; then
|
||||
# Newest by version order, not by tag date: v1.10.0 is newer than v1.9.0
|
||||
# and sorts earlier alphabetically.
|
||||
NEWEST_TAG="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null | head -1)"
|
||||
|
||||
if [[ -n "$NEWEST_TAG" && "$NEWEST_TAG" != "$CURRENT_TAG" ]]; then
|
||||
# Count only tags actually ahead of the deployed one.
|
||||
BEHIND="$(git tag --list 'v*' --sort=-v:refname 2>/dev/null \
|
||||
| awk -v cur="$CURRENT_TAG" '$0 == cur {exit} {n++} END {print n+0}')"
|
||||
[[ "${BEHIND:-0}" -gt 0 ]] && TARGET_RELEASE="$NEWEST_TAG"
|
||||
REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')"
|
||||
else
|
||||
BEHIND=0
|
||||
fi
|
||||
else
|
||||
# A code, not a sentence: the panel is translated and this script is
|
||||
# not, so a German string here would surface in the English interface.
|
||||
FETCH_ERROR='repo_unreachable'
|
||||
fi
|
||||
else
|
||||
if [[ "$BRANCH" == "HEAD" ]]; then
|
||||
# Detached, but not pinned to a release either — nothing sane to follow.
|
||||
FETCH_ERROR='detached_no_release'
|
||||
elif git fetch --quiet origin "$BRANCH" 2>/dev/null; then
|
||||
REMOTE_COMMIT="$(git rev-parse "origin/$BRANCH" 2>/dev/null || echo '')"
|
||||
if [[ -n "$LOCAL_COMMIT" && -n "$REMOTE_COMMIT" ]]; then
|
||||
BEHIND="$(git rev-list --count "HEAD..origin/$BRANCH" 2>/dev/null || echo 0)"
|
||||
fi
|
||||
else
|
||||
# Reported, not swallowed: "cannot reach the repository" and "already up
|
||||
# to date" look identical from the panel otherwise, and only one is fine.
|
||||
# A code, not a sentence: the panel is translated and this script is
|
||||
# not, so a German string here would surface in the English interface.
|
||||
FETCH_ERROR='repo_unreachable'
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Is an update asked for, and is it still current? ──────────────────────
|
||||
if [[ ! -f "$REQUEST" ]]; then
|
||||
write_status idle "$FETCH_ERROR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REQUEST_AGE_MINUTES="$(( ( $(date +%s) - $(stat -c %Y "$REQUEST" 2>/dev/null || date +%s) ) / 60 ))"
|
||||
|
||||
if (( REQUEST_AGE_MINUTES > REQUEST_EXPIRES_MINUTES )); then
|
||||
# Written while this agent was stopped. Running it now is not what was
|
||||
# asked for — it was asked for then.
|
||||
rm -f "$REQUEST"
|
||||
write_status idle "$FETCH_ERROR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Consumed BEFORE the run, not after: update.sh restarts the container stack and
|
||||
# may well kill this shell with it. A request left in place would be picked up
|
||||
# again on the next tick and update in a loop.
|
||||
rm -f "$REQUEST"
|
||||
|
||||
write_status running "$FETCH_ERROR"
|
||||
|
||||
set +e
|
||||
if [[ "$MODE" == "release" && -n "$TARGET_RELEASE" ]]; then
|
||||
RELEASE="$TARGET_RELEASE" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1
|
||||
else
|
||||
# BRANCH passed explicitly: update.sh defaults to main, so an installation
|
||||
# following anything else would be told about ITS branch and then handed
|
||||
# main's commits.
|
||||
BRANCH="$BRANCH" "$ROOT/deploy/update.sh" > "$RUNLOG" 2>&1
|
||||
fi
|
||||
RESULT=$?
|
||||
set -e
|
||||
|
||||
FINISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
LOCAL_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo '')"
|
||||
|
||||
EXIT_CODE="$RESULT"
|
||||
|
||||
if [[ $RESULT -eq 0 ]]; then
|
||||
BEHIND=0
|
||||
TARGET_RELEASE=''
|
||||
write_run succeeded ''
|
||||
write_status idle ''
|
||||
else
|
||||
write_run failed 'update_failed'
|
||||
write_status idle "$FETCH_ERROR"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
|
@ -220,4 +220,13 @@ printf '%s' "$after" > "$STATE_FILE"
|
|||
release_remember "$mode" "$source_ref"
|
||||
release_write_manifest "$after" "$source_ref" "$mode"
|
||||
|
||||
# The panel's update button needs a host-side timer, and this script cannot
|
||||
# install it: it runs as the service account and /etc/systemd is root's. Say so
|
||||
# once per update rather than leaving the button disabled without explanation.
|
||||
if ! systemctl list-unit-files clupilot-update-agent.timer >/dev/null 2>&1 \
|
||||
|| ! systemctl is-enabled --quiet clupilot-update-agent.timer 2>/dev/null; then
|
||||
printf '\033[1;33m !\033[0m %s\n' "Update agent not installed — the panel's update button stays disabled."
|
||||
printf ' %s\n' "Install it once: sudo bash $(pwd)/deploy/install-agent.sh"
|
||||
fi
|
||||
|
||||
log "Done — $(release_version) on $(git rev-parse --short HEAD) (${source_ref})"
|
||||
|
|
|
|||
|
|
@ -75,4 +75,25 @@ return [
|
|||
'console_locked' => 'Konsole eingeschränkt — nur noch VPN und gelistete Adressen.',
|
||||
'console_unlocked' => 'Einschränkung aufgehoben.',
|
||||
'console_lock_refused' => 'Nicht eingeschränkt: :ip steht auf keiner Liste, Sie hätten sich damit ausgesperrt.',
|
||||
|
||||
'update_title' => 'Version & Aktualisierung',
|
||||
'update_version' => 'Läuft',
|
||||
'update_source' => 'Quelle',
|
||||
'update_deployed' => 'Ausgerollt',
|
||||
'update_checked' => 'Zuletzt geprüft',
|
||||
'update_now' => 'Jetzt aktualisieren',
|
||||
'update_running' => 'Läuft gerade',
|
||||
'update_available' => ':n Aktualisierung(en) verfügbar',
|
||||
'update_current' => 'Aktuell',
|
||||
'update_unknown' => 'Unbekannt',
|
||||
'update_requested' => 'Aktualisierung angefordert — sie startet innerhalb weniger Minuten.',
|
||||
'update_already_requested' => 'Eine Aktualisierung ist bereits angefordert.',
|
||||
'update_no_agent' => 'Der Update-Dienst auf dem Server läuft nicht. Einmalig auf dem Server einrichten: sudo bash /opt/clupilot/deploy/install-agent.sh',
|
||||
'update_log' => 'Protokoll des letzten Laufs',
|
||||
|
||||
'update_error' => [
|
||||
'repo_unreachable' => 'Das Repository ist vom Server aus nicht erreichbar.',
|
||||
'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.',
|
||||
'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -75,4 +75,25 @@ return [
|
|||
'console_locked' => 'Console restricted — VPN and listed addresses only.',
|
||||
'console_unlocked' => 'Restriction lifted.',
|
||||
'console_lock_refused' => 'Not restricted: :ip is on no list, so this would have locked you out.',
|
||||
|
||||
'update_title' => 'Version & update',
|
||||
'update_version' => 'Running',
|
||||
'update_source' => 'Source',
|
||||
'update_deployed' => 'Deployed',
|
||||
'update_checked' => 'Last checked',
|
||||
'update_now' => 'Update now',
|
||||
'update_running' => 'Running',
|
||||
'update_available' => ':n update(s) available',
|
||||
'update_current' => 'Up to date',
|
||||
'update_unknown' => 'Unknown',
|
||||
'update_requested' => 'Update requested — it starts within a few minutes.',
|
||||
'update_already_requested' => 'An update has already been requested.',
|
||||
'update_no_agent' => 'The server-side update service is not running. Set it up once on the server: sudo bash /opt/clupilot/deploy/install-agent.sh',
|
||||
'update_log' => 'Log of the last run',
|
||||
|
||||
'update_error' => [
|
||||
'repo_unreachable' => 'The repository cannot be reached from the server.',
|
||||
'detached_no_release' => 'The checkout is on no branch and pinned to no release.',
|
||||
'update_failed' => 'The update failed (code :code). See the log.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,76 @@
|
|||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Version & update --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.update_title') }}</h2>
|
||||
@if ($update['running'])
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_running') }}
|
||||
</span>
|
||||
@elseif ($update['available'])
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-accent-border bg-accent-subtle px-2.5 py-0.5 text-xs font-medium text-accent-text">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_available', ['n' => $update['behind']]) }}
|
||||
</span>
|
||||
@elseif ($update['behind'] === 0)
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_current') }}
|
||||
</span>
|
||||
@else
|
||||
{{-- Never shown as "up to date": not knowing and being
|
||||
current are different answers. --}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border border-line-strong bg-surface-2 px-2.5 py-0.5 text-xs font-medium text-muted">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_unknown') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 space-y-1 text-sm">
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_version') }}:</dt>
|
||||
<dd class="font-mono text-body">{{ $update['version'] }}@if ($update['commit']) · {{ substr($update['commit'], 0, 7) }}@endif</dd></div>
|
||||
@if ($update['source'])
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_source') }}:</dt>
|
||||
<dd class="font-mono text-body">{{ $update['source'] }}</dd></div>
|
||||
@endif
|
||||
@if ($update['deployed_at'])
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_deployed') }}:</dt>
|
||||
<dd class="text-body">{{ $update['deployed_at']->diffForHumans() }}</dd></div>
|
||||
@endif
|
||||
@if ($update['checked_at'])
|
||||
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_checked') }}:</dt>
|
||||
<dd class="text-body">{{ $update['checked_at']->diffForHumans() }}</dd></div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{{-- Bound, not @disabled(): the directive compiles to inline PHP
|
||||
inside the component tag, and Blade then no longer
|
||||
recognises the tag as a component at all. --}}
|
||||
<x-ui.button variant="primary" wire:click="requestUpdate" wire:loading.attr="disabled"
|
||||
:disabled="$update['running'] || ! $update['agent_seen']">
|
||||
{{ __('admin_settings.update_now') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
|
||||
@if (! $update['agent_seen'])
|
||||
<x-ui.alert variant="warning" class="mt-4">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
|
||||
@elseif ($update['last_error'])
|
||||
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
@if ($updateLog)
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.update_log') }}</summary>
|
||||
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $updateLog }}</pre>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- My account --}}
|
||||
@if ($canManageSite)
|
||||
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
|
|
@ -83,12 +153,17 @@
|
|||
<p class="text-sm text-muted">{{ __('admin_settings.console_none') }}</p>
|
||||
@endforelse
|
||||
|
||||
<form wire:submit="addConsoleIp" class="flex gap-2 pt-2">
|
||||
<div class="flex-1">
|
||||
<x-ui.input name="consoleIp" wire:model="consoleIp"
|
||||
:hint="__('admin_settings.console_ip_hint')" placeholder="203.0.113.7" />
|
||||
{{-- The hint sits under the row, not inside it: as a sibling of
|
||||
the field it made the flex row as tall as field-plus-hint,
|
||||
and the button stretched to match. --}}
|
||||
<form wire:submit="addConsoleIp" class="pt-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<x-ui.input name="consoleIp" wire:model="consoleIp" placeholder="203.0.113.7" />
|
||||
</div>
|
||||
<x-ui.button type="submit" wire:loading.attr="disabled">{{ __('admin_settings.console_add') }}</x-ui.button>
|
||||
</div>
|
||||
<x-ui.button type="submit" wire:loading.attr="disabled">{{ __('admin_settings.console_add') }}</x-ui.button>
|
||||
<p class="mt-1.5 text-xs text-faint">{{ __('admin_settings.console_ip_hint') }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Settings as AdminSettings;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Updating the installation from the console.
|
||||
*
|
||||
* The panel cannot run the update — it is www-data inside the container the
|
||||
* update restarts. It leaves a request for the host-side agent instead. These
|
||||
* tests cover the panel's half: what it reports, what it refuses, and what it
|
||||
* must never claim.
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
it('does not offer to update when the agent has never reported in', function () {
|
||||
// Without an agent the button would do nothing at all, and a button that
|
||||
// does nothing is worse than a missing one.
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['agent_seen'])->toBeFalse()
|
||||
->and($state['behind'])->toBeNull()
|
||||
->and($state['available'])->toBeFalse();
|
||||
|
||||
Livewire::actingAs(operator('Owner'))
|
||||
->test(AdminSettings::class)
|
||||
->assertSee(__('admin_settings.update_no_agent'));
|
||||
});
|
||||
|
||||
it('never reports "up to date" when it simply does not know', function () {
|
||||
// Null and zero are different answers. Showing the first as the second is
|
||||
// how an installation sits three versions behind looking healthy.
|
||||
expect(app(UpdateChannel::class)->state()['available'])->toBeFalse();
|
||||
|
||||
Livewire::actingAs(operator('Owner'))
|
||||
->test(AdminSettings::class)
|
||||
->assertSee(__('admin_settings.update_unknown'))
|
||||
->assertDontSee(__('admin_settings.update_current'));
|
||||
});
|
||||
|
||||
it('reports an available update once the agent has looked', function () {
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 3]);
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['available'])->toBeTrue()->and($state['behind'])->toBe(3);
|
||||
|
||||
Livewire::actingAs(operator('Owner'))
|
||||
->test(AdminSettings::class)
|
||||
->assertSee(__('admin_settings.update_available', ['n' => 3]));
|
||||
});
|
||||
|
||||
it('leaves a request the agent can find', function () {
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
|
||||
|
||||
Livewire::actingAs(operator('Owner'))
|
||||
->test(AdminSettings::class)
|
||||
->call('requestUpdate')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true);
|
||||
|
||||
expect($request['requested_by'])->toBeString()->not->toBeEmpty()
|
||||
->and($request['requested_at'])->toBeString();
|
||||
});
|
||||
|
||||
it('does not queue a second run when the button is pressed twice', function () {
|
||||
$channel = app(UpdateChannel::class);
|
||||
|
||||
expect($channel->request('someone@example.com'))->toBeTrue()
|
||||
// Impatience, not intent — and two overlapping runs of update.sh fight
|
||||
// over the checkout.
|
||||
->and($channel->request('someone@example.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('forgets a request nobody ever collected', function () {
|
||||
// Written while the agent was down. Firing it days later, whenever the
|
||||
// agent happens to come back, is not what anyone asked for.
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-request.json'), json_encode([
|
||||
'requested_at' => Carbon::now()->subHours(3)->toIso8601String(),
|
||||
'requested_by' => 'someone@example.com',
|
||||
]));
|
||||
|
||||
$channel = app(UpdateChannel::class);
|
||||
|
||||
expect($channel->pendingRequest())->toBeNull()
|
||||
->and($channel->state()['running'])->toBeFalse()
|
||||
// And a fresh request is accepted again rather than blocked forever.
|
||||
->and($channel->request('someone@example.com'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('needs the capability, not merely an operator account', function () {
|
||||
Livewire::actingAs(operator('Support'))
|
||||
->test(AdminSettings::class)
|
||||
->call('requestUpdate')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('survives a status file that is corrupt', function () {
|
||||
// This is the page an operator opens when something is already wrong. It
|
||||
// must not be the second thing that is broken.
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), '{ this is not json');
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['agent_seen'])->toBeFalse();
|
||||
|
||||
Livewire::actingAs(operator('Owner'))
|
||||
->test(AdminSettings::class)
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
function writeStatus(array $status): void
|
||||
{
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode($status));
|
||||
}
|
||||
|
||||
function writeLastRun(array $run): void
|
||||
{
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-last-run.json'), json_encode($run));
|
||||
}
|
||||
|
||||
it('stops trusting an agent that has gone quiet', function () {
|
||||
// A status file written once by an agent since stopped would otherwise
|
||||
// leave the button enabled forever, writing requests nobody collects.
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 2]);
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['agent_seen'])->toBeFalse()
|
||||
->and($state['behind'])->toBeNull()
|
||||
->and($state['available'])->toBeFalse();
|
||||
|
||||
Livewire::actingAs(operator('Owner'))
|
||||
->test(AdminSettings::class)
|
||||
->assertSee(__('admin_settings.update_no_agent'));
|
||||
});
|
||||
|
||||
it('shows the agent’s failure in the operator’s language', function () {
|
||||
// The agent is a shell script and is not translated; it reports a code.
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
|
||||
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 3]);
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['last_error'])
|
||||
->toBe(__('admin_settings.update_error.update_failed', ['code' => '3']));
|
||||
|
||||
// An unknown code is passed through rather than swallowed: a message
|
||||
// nobody has worded yet still beats silence while an update is failing.
|
||||
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'something_new']);
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['last_error'])->toBe('something_new');
|
||||
});
|
||||
|
||||
it('still shows a failed update after the next routine check', function () {
|
||||
// The check runs every five minutes. Writing both into one file meant a
|
||||
// failure was usually gone before anyone looked at it.
|
||||
writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 2]);
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]);
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['last_state'])->toBe('failed')
|
||||
->and($state['last_error'])->toBe(__('admin_settings.update_error.update_failed', ['code' => '2']));
|
||||
});
|
||||
|
||||
it('refuses a second request while the agent is mid-run', function () {
|
||||
// The agent deletes the request before it starts, so between that moment
|
||||
// and the end of the run there is nothing "pending" to block on.
|
||||
writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]);
|
||||
|
||||
$channel = app(UpdateChannel::class);
|
||||
|
||||
expect($channel->isRunning())->toBeTrue()
|
||||
->and($channel->request('someone@example.com'))->toBeFalse();
|
||||
|
||||
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue