Release-Decke: einen Server aus der Konsole auf eine Version festnageln

Bei zehn Servern liess sich eine Auslieferung nicht staffeln: entweder alle
nehmen die neueste Version oder niemand. Der einzige Griff, der eine bestimmte
setzt, war RELEASE=vX.Y.Z bash deploy/update.sh auf der Kommandozeile.

Der Kern ist eine geklemmte Variable. Der Agent uebergab in Zeile 669 ohnehin
schon RELEASE="$TARGET_RELEASE"; wird die an der Decke geklemmt, faellt
`behind` aus derselben Rechnung, und Knopf wie Wartungsfenster folgen von
selbst. Task 4 belegt genau das mit einem Test, der KEINEN Produktivcode
braucht: es gibt keinen zweiten Weg in eine Auslieferung.

Die Decke faellt zu, nicht auf. Unlesbar, formwidrig oder ins Leere zeigend
heisst: nichts wird angeboten. Ein Rueckfall auf "neueste Version"
installierte genau das, wovon weggenagelt wurde.

Nicht enthalten: Zurueckrollen. Das ist verboten (update.sh:222), und der
Datenbank-Schnappschuss, auf den die Fehlermeldung dort verweist, wird
nirgends genommen. Eigene Baustelle, ihr fehlendes Stueck ist der
Schnappschuss, nicht der Knopf.

Unterwegs gefunden und mitbehoben: zwei Stellen, an denen eine Zuweisung aus
einer Kommandoersetzung unter set -e + pipefail den Agenten toetete, BEVOR er
eine Statusdatei schreiben konnte (sync_vpn_certificate, release_manifest_
version) — dieselbe Ausfallart, die die Konsole eine nie endende Pruefung
zeigen laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 16:37:33 +02:00
commit 033578d75d
14 changed files with 1134 additions and 46 deletions

View File

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Rückfrage vor dem Festnageln (R23).
*
* Das Modal mutiert nichts: es löst `pin-release-confirmed` aus, und
* App\Livewire\Admin\Settings führt die Handlung mit ihrer eigenen
* Berechtigungsprüfung aus. Damit bleibt die Prüfung an der einen Stelle, an
* der sie schon stand, statt hier verdoppelt zu werden.
*
* Die Version kommt als Argument aus dem Auswahlfeld der Seite (R20: kein
* Modal fürs Auswahlfeld selbst, nur für die Bestätigung der Handlung). Ein
* Modal ist ohne die Route-Middleware der Seite erreichbar, deshalb prüft
* auch `mount()` die Berechtigung selbst statt sich auf die aufrufende Seite
* zu verlassen.
*/
class ConfirmPinRelease extends ModalComponent
{
public string $version = '';
public function mount(string $version = ''): void
{
$this->authorize('site.manage');
$this->version = $version;
}
public function confirm(): void
{
$this->authorize('site.manage');
// Die Version geht als Event-Nutzlast mit, nicht nur als das, was
// dieses Modal auf dem Bildschirm zeigt — genau wie
// ConfirmSaveSecret::confirm() den Schlüssel mitschickt. Sonst
// bestätigt der Klick nur, DASS etwas festgenagelt wird, nicht WAS:
// die Seite las bisher ihr eigenes `ceilingChoice` erneut, und eine
// leere Auswahl dort landete unbestätigt in setCeiling(null) — der
// Gegenhandlung, nicht dem Festnageln.
$this->dispatch('pin-release-confirmed', version: $this->version);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-pin-release');
}
}

View File

@ -167,6 +167,12 @@ class Settings extends Component
$this->autoDays = $window->days();
$this->autoFrom = $window->from();
$this->autoTo = $window->to();
// Das Auswahlfeld zeigt, was tatsächlich gilt, statt auf "Neueste
// Version (nicht festgenagelt)" zu stehen, während die Zeile darunter
// "Festgenagelt auf 1.8.0" sagt — zwei Bedienelemente derselben
// Karte, die sich sonst widersprechen.
$this->ceilingChoice = app(UpdateChannel::class)->ceiling() ?? '';
}
/**
@ -717,6 +723,70 @@ class Settings extends Component
: 'admin_settings.update_already_requested'));
}
/** Die im Auswahlfeld stehende Version, ehe sie bestätigt wird. */
public string $ceilingChoice = '';
/**
* Den Server auf eine Version festnageln.
*
* Der Auslöser kommt aus dem Bestätigungs-Modal (R23); die Prüfung steht
* hier, an derselben Stelle wie bei jeder anderen Handlung dieser Seite,
* statt im Modal verdoppelt zu werden.
*
* `site.manage` wie `requestUpdate()`: festnageln entscheidet, welche
* Fassung dieser Server je bekommt, und ist damit dieselbe Entscheidung
* wie sie auszulösen nur früher.
*
* Die Version kommt als Parameter aus dem Event, nicht mehr aus
* `$this->ceilingChoice`: ConfirmPinRelease schickt mit, was im Modal
* tatsächlich bestätigt wurde (genau wie ConfirmSaveSecret den
* Schlüssel mitschickt). Eine leere Version wird HIER ausdrücklich
* abgewiesen, mit ihrer eigenen Meldung nicht länger `?: null` in
* setCeiling() gereicht, das eine leere Auswahl als "Decke abnehmen"
* gelesen und dabei die Festnageln-Erfolgsmeldung gezeigt hätte. Das
* Auswahlfeld trägt `<option value="">…nicht festgenagelt</option>`,
* und das Einzige, was einen Klick darauf bisher verhinderte, war ein
* Alpine-`disabled` kein Ersatz für eine serverseitige Prüfung, siehe
* `requestUpdate()` oben.
*/
#[On('pin-release-confirmed')]
public function pinRelease(string $version): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
if ($version === '') {
$this->dispatch('notify', message: __('admin_settings.release_pin_empty'));
return;
}
$accepted = app(UpdateChannel::class)->setCeiling($operator->email, $version);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.release_pinned'
: 'admin_settings.release_pin_invalid'));
}
/**
* Die Decke abnehmen der Server nimmt wieder, was neu ist.
*/
public function unpinRelease(): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
app(UpdateChannel::class)->setCeiling($operator->email, null);
$this->dispatch('notify', message: __('admin_settings.release_unpinned'));
}
/**
* Eine hängende Sperre auf dem Wirt lösen lassen.
*

View File

@ -46,6 +46,16 @@ final class UpdateChannel
/** Written by the panel, consumed by the agent. */
private const REQUEST = 'deploy/update-request.json';
/**
* Wie weit dieser Server gehen darf.
*
* Keine Anfrage an den Agenten, sondern Zustand, den er liest: die Datei
* bleibt liegen und gilt, bis sie jemand ändert. Deshalb steht sie neben
* dem Postkasten und nicht darin eine Anfrage wird verbraucht, eine
* Decke nicht.
*/
private const CEILING = 'deploy/release-ceiling';
/**
* Die Bitte, eine hängende Sperre zu lösen in einer EIGENEN Datei.
*
@ -275,6 +285,11 @@ final class UpdateChannel
// "three updates behind" from last week reads exactly like now.
$behind = $agentWorking && isset($status['behind']) ? (int) $status['behind'] : null;
// Aus der Datei, nicht aus $status: siehe ceiling() weiter unten. Nur
// einmal gelesen, weil state() bei jedem Seitenaufbau läuft und die
// Datei sonst zweimal geöffnet würde, um zweimal dasselbe zu sagen.
$ceiling = $this->ceiling();
return [
'version' => $release->version,
'commit' => $release->commit,
@ -284,6 +299,22 @@ final class UpdateChannel
// Null means "we do not know", which is not the same as "up to
// date" and must not be shown as it.
'behind' => $behind,
// Die Decke aus der Datei, ihre Beanstandung vom Agenten: nur er
// sieht die Tags und kann sagen, ob sie ins Leere zeigt.
'ceiling' => $ceiling,
'ceiling_error' => ($status['ceiling_error'] ?? '') !== ''
? (string) $status['ceiling_error']
: null,
// Die Decke steht UNTER dem, was läuft. Kein Fehler, aber auch
// nicht „aktuell": es steht eine Decke, sie ist nur überholt.
// Hier gerechnet und nicht vom Agenten geholt — die Konsole hat
// beide Zahlen bereits, und eine Frage weniger an den Wirt ist
// eine Frage weniger, die veralten kann.
'ceiling_passed' => $ceiling !== null
&& version_compare(ltrim($ceiling, 'v'), $release->version, '<'),
'releases' => is_array($status['releases'] ?? null)
? array_values(array_filter($status['releases'], 'is_string'))
: [],
'available' => $behind !== null && $behind > 0,
// The tag an update would install, e.g. "v1.1.0". An update is
// always to a released version now — a commit landing on main is
@ -559,6 +590,118 @@ final class UpdateChannel
return $this->submit($by, self::KIND_CHECK);
}
/**
* Den Server auf eine Version festnageln oder die Decke abnehmen.
*
* `null` nimmt sie ab. Rückgabe `false` heißt: die Form stimmt nicht, oder
* das Schreiben ist fehlgeschlagen (volle Platte, Rechteproblem auf
* storage/app/deploy) in beiden Fällen wurde nichts Halbes hinterlassen,
* das der Agent falsch lesen müsste.
*
* Die Decke wirkt NICHT dadurch, dass hier etwas ausgelöst wird. Der Agent
* liest die Datei bei jedem Takt und rechnet `behind` dagegen; Knopf und
* Wartungsfenster folgen daraus von selbst. Die `KIND_CHECK`-Anfrage
* daneben ist reine Höflichkeit gegenüber der Anzeige: ohne sie stünde die
* alte Zahl bis zum nächsten Takt, und ein Betreiber, der gerade
* festgenagelt hat, läse sie als „hat nicht gewirkt".
*/
public function setCeiling(string $by, ?string $tag): bool
{
if ($tag === null) {
File::delete(storage_path('app/'.self::CEILING));
$this->requestCheck($by);
return true;
}
// Dieselbe Form, die der Agent prüft. Beide Seiten prüfen sie, weil
// keine der anderen glauben kann: der Agent nicht der Datei, die
// Konsole nicht der Eingabe.
if (! preg_match('/^v\d+(\.\d+)*$/', $tag)) {
return false;
}
if (! $this->writeAtomic(self::CEILING, $tag)) {
return false;
}
$this->requestCheck($by);
return true;
}
/**
* Die gesetzte Decke, direkt aus der Datei.
*
* Nicht aus der Statusdatei des Agenten: die ist seine Rückmeldung und
* hinkt bis zu einem Takt hinterher. Was die Konsole selbst geschrieben
* hat, muss sie sofort anzeigen können.
*
* Zwischen `File::exists()` und `File::get()` liegt ein Zeitfenster
* verschwindet die Datei genau dort (ein zweiter Aufruf nimmt die Decke
* ab, während dieser noch liest), wirft `File::get()` eine
* FileNotFoundException. Dasselbe Muster wie readJson() und lastLog()
* weiter unten: state() darf unter keinen Umständen werfen.
*
* Öffentlich, weil auch App\Livewire\Admin\Settings::mount() sie
* braucht um `ceilingChoice` mit der tatsächlich gesetzten Decke
* vorzubelegen, statt das Auswahlfeld auf einem festgenagelten Server
* "Neueste Version (nicht festgenagelt)" zeigen zu lassen, während der
* Satz darunter das Gegenteil sagt.
*/
public function ceiling(): ?string
{
try {
$path = storage_path('app/'.self::CEILING);
if (! File::exists($path)) {
return null;
}
$tag = trim((string) File::get($path));
return $tag === '' ? null : $tag;
} catch (Throwable) {
return null;
}
}
/**
* Schreiben, das der Agent nie halb sieht.
*
* `write()` daneben benutzt File::put und taugt dafür nicht: der Agent
* liest diese Datei bei jedem Takt, und eine halbe Zeile ist genau die
* Art Decke, die er als kaputt ablehnen müsste.
*
* Prüft beide Rückgabewerte, statt ihnen zu glauben: `File::move()` ruft
* nur `rename()` auf, und ein fehlgeschlagenes `rename()` liefert `false`
* ohne von sich aus eine Ausnahme zu werfen. Schlägt einer der beiden
* Schritte fehl volle Platte, Rechteproblem räumt diese Methode die
* `.tmp` weg und meldet `false`, statt einen Erfolg zu behaupten, der
* nicht stattfand.
*/
private function writeAtomic(string $relative, string $contents): bool
{
$path = storage_path('app/'.$relative);
$tmp = $path.'.tmp';
try {
File::ensureDirectoryExists(dirname($path));
if (File::put($tmp, $contents) === false || ! File::move($tmp, $path)) {
File::delete($tmp);
return false;
}
return true;
} catch (Throwable) {
File::delete($tmp);
return false;
}
}
/**
* Den Proxy die Hostnamen aus der Konsole übernehmen lassen.
*

View File

@ -38,8 +38,17 @@ release_manifest_commit() {
# failed in between leaves a newer number on disk than is serving. Deciding
# "is there something newer" against the file would then offer nothing, because
# the checkout already claims to be the version it never finished installing.
#
# `|| true` at the end, same idiom as release_version()'s `|| echo '0.0.0'`:
# guarantee a zero exit status regardless of what happened upstream. Without
# it, a missing manifest — an ordinary state before the first install — makes
# `sed` exit 2, `pipefail` carries that out of the pipeline, and the caller's
# `DEPLOYED_VERSION="$(release_manifest_version)"` is a bare assignment: under
# `set -e` that ends the agent on every tick, before it ever writes a status.
# Empty output either way, so the caller's existing fallback to
# release_version() still fires exactly as before.
release_manifest_version() {
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1 || true
}
# release_version_gt A B — true when A is strictly a higher version than B.
@ -51,7 +60,12 @@ release_version_gt() {
[[ "$1" != "$2" ]] && [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]]
}
# release_newest_tag — the highest v* tag by version order, or nothing.
# release_newest_tag [CEILING] — the highest v* tag by version order, or nothing.
#
# Mit CEILING: der hoechste Tag, der NICHT darueber liegt. Das ist die ganze
# Mechanik des Festnagelns — der Agent fragt nach dem neuesten Tag, den er
# nehmen DARF, und `behind`, der Knopf und das Wartungsfenster fallen alle aus
# dieser einen Antwort.
#
# Deliberately not `git tag … | head -1`. head exits after the first line, git
# gets SIGPIPE, and under `set -o pipefail` that non-zero status leaves the
@ -59,9 +73,13 @@ release_version_gt() {
# and the same trap install-agent.sh already carries a warning about. A read
# loop over a process substitution is not a pipeline and cannot do it.
release_newest_tag() {
local tag
local ceiling="${1-}" tag
while read -r tag; do
[[ -n "$tag" ]] || continue
if [[ -n "$ceiling" ]] && release_version_gt "${tag#v}" "${ceiling#v}"; then
continue
fi
printf '%s' "$tag"
return 0
done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null)
@ -69,31 +87,82 @@ release_newest_tag() {
return 0
}
# release_tags_ahead VERSION — how many v* tags are higher than VERSION.
# release_tags_ahead VERSION [CEILING] — how many v* tags are higher than
# VERSION, counting only up to CEILING.
#
# Lives here rather than inline in the agent because it has to be testable. The
# inline version killed the update agent on every tick: the loop body was
# `release_version_gt … && echo`, so the LAST iteration — almost always a tag
# that is not newer — left the loop with a non-zero status, `pipefail` carried
# it out of the pipeline, the command substitution inherited it, and `set -e`
# ended the agent before it could write a status. The console then showed a
# check that never finished and a last-checked time frozen at the deployment.
# ended the agent before it could write a status.
#
# `if` rather than `&&` is the whole fix: an if whose condition is false still
# returns 0. Anything added here must keep that property.
# returns 0. Anything added here must keep that property — auch die
# Decken-Pruefung unten ist deshalb ein verschachteltes `if` und kein `&&`.
release_tags_ahead() {
local current="$1" tag count=0
local current="$1" ceiling="${2-}" tag count=0
while read -r tag; do
[[ -n "$tag" ]] || continue
if release_version_gt "${tag#v}" "$current"; then
count=$((count + 1))
if [[ -z "$ceiling" ]] || ! release_version_gt "${tag#v}" "${ceiling#v}"; then
count=$((count + 1))
fi
fi
done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null)
printf '%s' "$count"
}
# release_tag_exists TAG — true when TAG is a real tag in this checkout.
#
# Form und Existenz sind zwei Fragen. Eine Decke, die bloss AUSSIEHT wie eine
# Version, ist keine — ein Tag, den jemand von Hand in die Deckendatei
# geschrieben hat und der nie existiert hat, oder einer, den DIESER Wirt
# schlicht noch nie geholt hat.
#
# Was das NICHT abdeckt: einen Tag, der hier schon lag und auf der
# Gegenstelle geloescht wurde. Der Agent holt mit
# `git fetch --quiet --tags --force origin` (update-agent.sh), und das
# ENTFERNT keine lokalen Tags, die drueben verschwunden sind — dafuer
# braeuchte es `--prune --prune-tags`. Das ist hier absichtlich NICHT
# gesetzt: es waere eine Entscheidung darueber, was jede der zehn
# Server-Konsolen mit ihren eigenen lokalen Tags tut, sobald irgendwo einer
# zurueckgezogen wird — und die steht dem Besitzer zu, nicht diesem Helfer.
# `git rev-parse --verify` beantwortet also auch dann noch mit "ja", wenn
# der Tag auf der Gegenstelle laengst weg ist, solange dieser Wirt ihn
# irgendwann einmal geholt hatte.
release_tag_exists() {
[[ -n "${1-}" ]] || return 1
git rev-parse -q --verify "refs/tags/${1}^{commit}" >/dev/null 2>&1
}
# release_tags_from VERSION [LIMIT] — die Tags, auf die festgenagelt werden
# darf: VERSION selbst und alles darueber, neueste zuerst, einer je Zeile.
#
# VERSION ist EINGESCHLOSSEN, und das ist der haeufigste Fall: „hier
# einfrieren, nichts Neues nehmen" ist das, was neun von zehn Servern wollen,
# waehrend der zehnte die neue Fassung bekommt.
#
# LIMIT, weil die Statusdatei sonst mit der Tag-Historie mitwaechst.
release_tags_from() {
local current="$1" limit="${2:-20}" tag count=0 out=''
while read -r tag; do
[[ -n "$tag" ]] || continue
if [[ "${tag#v}" == "$current" ]] || release_version_gt "${tag#v}" "$current"; then
out+="${tag}"$'\n'
count=$((count + 1))
if (( count >= limit )); then
break
fi
fi
done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null)
printf '%s' "$out"
}
release_remember() { # release_remember MODE SOURCE
mkdir -p "$(dirname "$MODE_FILE")"
printf '%s' "$1" > "$MODE_FILE"

View File

@ -43,6 +43,13 @@ RESTARTLAST="$STATE_DIR/restart-last-run.json"
# initial admin password an instance holds until somebody notes it down.
ARCHIVE_KEY="$STATE_DIR/archive-key.json"
LOCK="$STATE_DIR/.agent.lock"
# Wie weit dieser Server gehen darf. Von der Konsole geschrieben, hier gelesen.
#
# Sie faellt ZU, nicht auf: ist die Datei unlesbar, formwidrig oder zeigt sie
# auf einen Tag, den es nicht (mehr) gibt, wird NICHTS angeboten. Ein Rueckfall
# auf "neueste Version" installierte genau das, wovon weggenagelt wurde — eine
# Sicherung, die im Zweifel oeffnet, ist keine.
CEILING_FILE="$STATE_DIR/release-ceiling"
# Ein Lebenszeichen, geschrieben als ERSTES bei jedem Lauf — vor dem Abruf der
# Gegenstelle, vor allem, was blockieren kann.
#
@ -329,6 +336,13 @@ sync_console_allowlist() {
sync_vpn_certificate() {
local stamp seen="$STATE_DIR/.vpn-cert-stamp" path
# Ohne diese Wache stirbt der ganze Agent an dieser Stelle, wenn `.env`
# fehlt: `sed` auf einer nicht vorhandenen Datei liefert Status 2, und eine
# einfache (nicht-`local`) Zuweisung reicht diesen Status unter `pipefail`
# an `set -e` weiter — bevor je eine Statusdatei geschrieben wurde. Auf
# einem echten Wirt existiert `.env` immer; das aendert hier nichts.
[[ -f "$ROOT/.env" ]] || return 0
path="$(sed -n 's/^VPN_CERT_PATH=//p' "$ROOT/.env" 2>/dev/null | tail -1)"
[[ -n "$path" ]] || return 0
@ -368,6 +382,17 @@ json_escape() {
# needed once, to record which step a failed run died at.
read_phase() { cut -f1 "$PHASE_FILE" 2>/dev/null | tr -d '\r\n' || true; }
# Die wählbaren Versionen als JSON-Liste. Eigene Funktion, weil eine Schleife
# nicht in eine Here-Dokument-Ersetzung passt.
releases_json() {
local tag out=''
while read -r tag; do
[[ -n "$tag" ]] || continue
out+="\"$(json_escape "$tag")\","
done <<< "${RELEASES:-}"
printf '%s' "${out%,}"
}
write_status() {
local state="$1" error="${2-}"
cat > "$STATUS.tmp" <<EOF
@ -383,6 +408,9 @@ write_status() {
"remote_commit": "$(json_escape "${REMOTE_COMMIT:-}")",
"target_release": "$(json_escape "${TARGET_RELEASE:-}")",
"behind": ${BEHIND:-null},
"ceiling": "$(json_escape "${CEILING:-}")",
"ceiling_error": "$(json_escape "${CEILING_ERROR:-}")",
"releases": [$(releases_json)],
"branch": "$(json_escape "$BRANCH")",
"error": "$(json_escape "$error")"
}
@ -442,6 +470,20 @@ FETCH_ERROR=''
# Compared by VERSION, not by ancestry or by tag date: a tag can be cut from
# anywhere, and what an operator is being offered is a version number. `sort -V`
# does the comparing, so v1.10.0 correctly beats v1.9.0.
# Die Decke, in zwei Schritten geprueft: Form hier, Existenz nach dem Abruf —
# vorher sind die Tags noch nicht da.
CEILING=''
CEILING_ERROR=''
RELEASES=''
if [[ -f "$CEILING_FILE" ]]; then
CEILING="$(tr -d ' \t\r\n' < "$CEILING_FILE" 2>/dev/null || true)"
if [[ ! "$CEILING" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
CEILING_ERROR='ceiling_invalid'
fi
fi
DEPLOYED_VERSION="$(release_manifest_version)"
[[ -n "$DEPLOYED_VERSION" ]] || DEPLOYED_VERSION="$(release_version)"
@ -451,25 +493,36 @@ DEPLOYED_VERSION="$(release_manifest_version)"
# grosszuegig fuer einen fetch gegen EINE Gegenstelle; laenger ist kein
# langsames Netz mehr, sondern eines, das nicht antwortet.
if timeout -k 10 120 git fetch --quiet --tags --force origin 2>/dev/null; then
# Newest by version order, not by tag date. Through the helper rather than
# `| head -1`: head exits after one line, git takes SIGPIPE, and pipefail
# ends the agent — see release_newest_tag.
NEWEST_TAG="$(release_newest_tag)"
NEWEST_VERSION="${NEWEST_TAG#v}"
# Erst jetzt sind die Tags da, also erst jetzt laesst sich sagen, ob die
# Decke auf etwas Wirkliches zeigt.
if [[ -n "$CEILING" && -z "$CEILING_ERROR" ]] && ! release_tag_exists "$CEILING"; then
CEILING_ERROR='ceiling_missing'
fi
if [[ -n "$NEWEST_TAG" ]] && release_version_gt "$NEWEST_VERSION" "$DEPLOYED_VERSION"; then
# How many releases ahead, so the console can say "2 Aktualisierungen"
# rather than only that something exists. Both helpers live in
# deploy/lib/release.sh so a test can run them — the version that lived
# here ended the agent on every tick under `set -e`, and no test in a
# PHP suite can reach a bash pipeline.
BEHIND="$(release_tags_ahead "$DEPLOYED_VERSION")"
TARGET_RELEASE="$NEWEST_TAG"
REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')"
else
# Includes "no tags at all yet": nothing has been released, so there is
# nothing to offer, and saying "up to date" is the truth.
# Was ueberhaupt als Decke in Frage kommt — unabhaengig davon, ob gerade
# eine gesetzt ist. Ohne diese Liste hat die Konsole nichts anzubieten.
RELEASES="$(release_tags_from "$DEPLOYED_VERSION" 20)"
if [[ -n "$CEILING_ERROR" ]]; then
# Eine kaputte Decke bietet nichts an. Bewusst kein `else`-Zweig, der
# auf die neueste Version zurueckfaellt.
BEHIND=0
else
# Newest by version order, not by tag date — und nicht ueber die Decke
# hinaus. Durch den Helfer statt `| head -1`: head exits after one
# line, git takes SIGPIPE, and pipefail ends the agent.
NEWEST_TAG="$(release_newest_tag "$CEILING")"
NEWEST_VERSION="${NEWEST_TAG#v}"
if [[ -n "$NEWEST_TAG" ]] && release_version_gt "$NEWEST_VERSION" "$DEPLOYED_VERSION"; then
BEHIND="$(release_tags_ahead "$DEPLOYED_VERSION" "$CEILING")"
TARGET_RELEASE="$NEWEST_TAG"
REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')"
else
# Schliesst „gar keine Tags" und „Decke gleich dem Ausgelieferten"
# ein: beides heisst, es gibt nichts anzubieten.
BEHIND=0
fi
fi
else
# Reported, not swallowed: "cannot reach the repository" and "already up to

View File

@ -275,29 +275,50 @@ use Illuminate\Support\Facades\Process;
*/
function runAgentWithCeiling(?string $ceiling, array $tags, string $deployedVersion = '1.7.3'): array
{
$dir = storage_path('app/deploy');
File::ensureDirectoryExists($dir);
File::delete(File::glob($dir.'/*'));
// Ein WEGWERF-Checkout mit EIGENEM .git — niemals das Repository dieses
// Arbeitsbaums.
//
// Tags liegen im gemeinsamen .git und sind damit auch fuer den Hauptbaum
// und jede Parallelsitzung sichtbar. Ein hier angelegtes v9.9.9 wuerde
// `git tag -l 'v*' --sort=-v:refname | head -1` falsch beantworten — und
// genau diese Frage entscheidet, wohin ein Server aktualisiert. Stirbt der
// Test vor seinem Aufraeumen, bliebe es liegen.
//
// Der Agent bestimmt seine Wurzel aus dem eigenen Pfad
// (`ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"`). Es genuegt
// also, deploy/ zu kopieren und ihn dort zu starten.
$root = sys_get_temp_dir().'/clupilot-ceiling-'.bin2hex(random_bytes(6));
File::ensureDirectoryExists($root.'/deploy/lib');
File::ensureDirectoryExists($root.'/storage/app/deploy');
// Der Agent liest die ausgelieferte Version aus dem Manifest.
File::put($dir.'/deployment.json', json_encode([
File::copy(base_path('deploy/update-agent.sh'), $root.'/deploy/update-agent.sh');
File::copy(base_path('deploy/lib/release.sh'), $root.'/deploy/lib/release.sh');
// MANIFEST_FILE ist `storage/app/deployment.json` — eine Ebene UEBER
// storage/app/deploy/. Siehe deploy/lib/release.sh:19.
File::put($root.'/storage/app/deployment.json', json_encode([
'version' => $deployedVersion,
'commit' => 'deadbeef',
'source' => 'refs/tags/v'.$deployedVersion,
]));
if ($ceiling !== null) {
File::put($dir.'/release-ceiling', $ceiling);
File::put($root.'/storage/app/deploy/release-ceiling', $ceiling);
}
$tagCommands = '';
foreach ($tags as $tag) {
$tagCommands .= "git tag {$tag} 2>/dev/null || true\n";
$tagCommands .= "git tag {$tag}\n";
}
$result = Process::path(base_path())->timeout(90)->run(<<<BASH
$result = Process::path($root)->timeout(90)->run(<<<BASH
set -e
git init -q .
git -c user.email=t@example.com -c user.name=t commit -q --allow-empty -m init
{$tagCommands}
stub="\$(mktemp -d)"
# Nur `fetch` wird abgefangen; alles andere geht ans echte git.
# Nur `fetch` wird abgefangen — es gibt keine Gegenstelle. Alles andere
# geht ans echte git und wirkt im Wegwerf-Checkout.
cat > "\$stub/git" <<'GIT'
#!/bin/sh
if [ "\$1" = "fetch" ]; then exit 0; fi
@ -306,20 +327,18 @@ function runAgentWithCeiling(?string $ceiling, array $tags, string $deployedVers
chmod +x "\$stub/git"
printf '#!/bin/sh\nexit 1\n' > "\$stub/docker"
chmod +x "\$stub/docker"
{$tagCommands}
PATH="\$stub:\$PATH" bash deploy/update-agent.sh >/dev/null 2>&1 || true
rm -rf "\$stub"
BASH);
expect($result->successful())->toBeTrue($result->errorOutput());
return json_decode(File::get($dir.'/update-status.json'), true);
}
$status = json_decode(File::get($root.'/storage/app/deploy/update-status.json'), true);
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
Process::path(base_path())->run("git tag -d v9.9.8 v9.9.9 2>/dev/null || true");
});
File::deleteDirectory($root);
return $status;
}
it('offers only up to the ceiling', function () {
// Ausgeliefert 1.7.3, vorhanden bis v9.9.9, Decke auf v9.9.8: genau eine

View File

@ -154,9 +154,21 @@ weggenagelt hat. Stattdessen: `behind = 0`, nichts wird angeboten, und
Eine Sicherung, die im Zweifel öffnet, ist keine.
Dass ein Tag verschwindet, ist hier kein Randfall: der Release-Prozess sieht
ausdrücklich vor, dass ein **falscher Tag gelöscht und die Nummer übersprungen**
wird.
`ceiling_missing` ist **nicht** die Sicherung gegen ein zurückgezogenes
Release. Der Agent holt Tags mit `git fetch --quiet --tags --force origin`
(deploy/update-agent.sh), und das entfernt **keine** lokalen Tags, die auf
der Gegenstelle verschwunden sind — dafür bräuchte es
`--prune --prune-tags`, eine Änderung, die hier bewusst nicht gemacht ist:
sie würde festlegen, was jede der zehn Server-Konsolen mit ihren eigenen
lokalen Tags tut, sobald irgendwo ein Release zurückgezogen wird, und das ist
eine Entscheidung des Besitzers, keine dieser Spezifikation. Ein Tag, den ein
Wirt einmal geholt hat, bleibt für `release_tag_exists` (deploy/lib/release.sh)
also „vorhanden", auch nachdem er drüben gelöscht wurde.
Sinnvoll bleibt `ceiling_missing` trotzdem — nur gegen einen anderen Fall:
eine Decke, die auf einen Tag zeigt, den DIESER Wirt nie geholt hat (ein
Tippfehler, eine von Hand in die Datei geschriebene Version) oder der nie
existiert hat.
Weiter:
@ -175,7 +187,7 @@ Weiter:
|---|---|
| `ReleaseComparisonTest` (besteht) | `release_newest_tag`/`release_tags_ahead` mit Decke — die echten Bash-Helfer |
| neu, Agent | Echtes Skript gegen ein Wegwerf-Repo mit Tags: Decke gesetzt → `behind` zählt nur bis dorthin, `target_release` **ist** die Decke |
| neu, fällt zu | Decke zeigt auf gelöschten Tag`behind = 0` **und** `ceiling_error`, nicht „neueste" |
| neu, fällt zu | Decke zeigt auf einen Tag, den dieser Wirt nie geholt hat`behind = 0` **und** `ceiling_error`, nicht „neueste" |
| neu, Automatik | `clupilot:auto-update` mit gesetzter Decke tut nichts |
| neu, Konsole | Festnageln schreibt die Datei, „Decke abnehmen" löscht sie, ohne `site.manage` geht beides nicht |

View File

@ -152,6 +152,30 @@ return [
'release_lock_requested' => 'Wird gelöst — der Dienst meldet sich binnen einer Minute zurück.',
'release_lock_already_requested' => 'Ist schon angefordert — bitte kurz warten.',
// ── Festnageln ────────────────────────────────────────────────────────
// Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt
// (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird
// nur das Festnageln selbst im Modal (R23); das Auswahlfeld daneben
// braucht keins (R20).
'release_pin_action' => 'Festnageln',
'release_pin_none' => 'Neueste Version (nicht festgenagelt)',
'release_pin_title' => 'Auf diese Version festnageln?',
'release_pin_body' => 'Dieser Server nimmt dann nichts Neueres als :version — auch nicht im Wartungsfenster. Zurück geht es nicht; die Decke begrenzt nur nach oben.',
'release_pin_cancel' => 'Abbrechen',
'release_pin_confirm' => 'Festnageln',
'release_pinned' => 'Festgenagelt.',
'release_pin_invalid' => 'Das ist keine gültige Version.',
// Eigene Meldung für eine leere Bestätigung — nicht die Erfolgsmeldung
// von oben. Eine leere Auswahl darf niemals als "Decke abnehmen" gelten,
// nur weil setCeiling(null) genau das bedeutet.
'release_pin_empty' => 'Keine Version ausgewählt — nichts wurde geändert.',
'release_pinned_at' => 'Festgenagelt auf :version.',
'release_pinned_passed' => 'Festgenagelt auf :version — dieser Server ist bereits weiter. Die Decke hält nichts mehr zurück; zurück geht es nicht.',
'release_unpin_action' => 'Decke abnehmen',
'release_unpinned' => 'Decke abgenommen.',
'release_ceiling_ceiling_missing' => 'Die festgenagelte Version gibt es nicht (mehr). Es wird solange nichts installiert — bitte neu festnageln oder die Decke abnehmen.',
'release_ceiling_ceiling_invalid' => 'Die festgenagelte Version ist unlesbar. Es wird solange nichts installiert — bitte neu festnageln oder die Decke abnehmen.',
'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.',

View File

@ -149,6 +149,30 @@ return [
'release_lock_requested' => 'Releasing — the service reports back within a minute.',
'release_lock_already_requested' => 'Already requested — please wait a moment.',
// ── Pinning a ceiling ────────────────────────────────────────────────
// The ceiling that limits both the update agent AND the maintenance
// window (App\Services\Deployment\UpdateChannel::setCeiling()). Only
// pinning itself is confirmed in a modal (R23); the select beside it
// needs none (R20).
'release_pin_action' => 'Pin',
'release_pin_none' => 'Newest release (not pinned)',
'release_pin_title' => 'Pin to this release?',
'release_pin_body' => 'This server will then take nothing newer than :version — not in the maintenance window either. There is no way back; the ceiling only limits upwards.',
'release_pin_cancel' => 'Cancel',
'release_pin_confirm' => 'Pin',
'release_pinned' => 'Pinned.',
'release_pin_invalid' => 'That is not a valid release.',
// Its own message for an empty confirmation — not the success message
// above. An empty selection must never count as "remove the ceiling"
// just because setCeiling(null) means exactly that.
'release_pin_empty' => 'No release selected — nothing was changed.',
'release_pinned_at' => 'Pinned to :version.',
'release_pinned_passed' => 'Pinned to :version — this server is already past it. The ceiling no longer holds anything back; there is no way back.',
'release_unpin_action' => 'Remove ceiling',
'release_unpinned' => 'Ceiling removed.',
'release_ceiling_ceiling_missing' => 'The pinned release no longer exists. Nothing will be installed until this is fixed — pin again or remove the ceiling.',
'release_ceiling_ceiling_invalid' => 'The pinned release is unreadable. Nothing will be installed until this is fixed — pin again or remove the ceiling.',
'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.',

View File

@ -0,0 +1,19 @@
{{-- Zweizeilige Rückfrage mit einem Knopf: braucht nach R24 kein
<x-ui.modal>, weil nichts darin scrollen kann. --}}
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="lock" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('admin_settings.release_pin_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.release_pin_body', ['version' => ltrim($version, 'v')]) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('admin_settings.release_pin_cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
<x-ui.icon name="lock" class="size-4" />{{ __('admin_settings.release_pin_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -173,10 +173,68 @@
<x-ui.icon name="download" class="size-4" />
{{ __('admin_settings.update_now') }}
</x-ui.button>
{{-- Festnageln, in derselben Handlungsleiste wie Prüfen/Aktualisieren
statt einer zweiten Leiste daneben dieselbe Begründung wie bei
den beiden Knöpfen selbst (flex-wrap, w-full sm:w-auto). Kein
Modal für das Auswahlfeld selbst (R20 gilt fürs Bearbeiten
bestehender Datensätze in Tabellenzeilen, nicht für ein
Auswahlfeld, das Teil der Seite ist); bestätigt wird nur das
Festnageln selbst im Modal (R23), weil die Wahl entscheidet,
welche Fassung dieser Server je bekommt. --}}
@if ($update['releases'])
<select wire:model="ceilingChoice"
class="min-h-10 w-full rounded border border-line bg-surface px-3 text-sm text-ink sm:w-auto">
<option value="">{{ __('admin_settings.release_pin_none') }}</option>
@foreach ($update['releases'] as $tag)
<option value="{{ $tag }}">{{ ltrim($tag, 'v') }}</option>
@endforeach
</select>
<x-ui.button class="w-full sm:w-auto" variant="secondary"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-pin-release', arguments: { version: $wire.ceilingChoice } })"
x-bind:disabled="! $wire.ceilingChoice">
<x-ui.icon name="lock" class="size-4" />
{{ __('admin_settings.release_pin_action') }}
</x-ui.button>
@endif
@endif
</div>
</div>
{{-- Der Zustand der Decke selbst direkt unter der Leiste, die sie
setzt. „Festgenagelt, aber bereits weiter" ist ein eigener Satz und
nicht dieselbe Zeile mit anderer Zahl: wer v1.8.0 festgenagelt hat
und v1.8.1 laufen sieht, muss erfahren, dass die Decke nichts mehr
tut sonst liest er sie als Zusicherung, die sie nicht ist. Zurück
geht es nicht (update.sh:222).
Der Knopf steht auch OHNE `$update['ceiling']`, sobald
`ceiling_error` etwas meldet: eine von Hand geleerte
Deckendatei liest die Konsole (UpdateChannel::ceiling())
als "keine Decke" leerer Inhalt wird zu null , während
der Agent dieselbe leere Datei als `ceiling_invalid`
zurückmeldet. Ohne diese Bedingung verschwand der einzige
Ort mit dem "Decke abnehmen"-Knopf genau in dem Zustand,
den nur eine Hand am Wirt erzeugen kann und aus dem die
Konsole ohne Kommandozeile zurückführen muss. --}}
@if ($update['ceiling'] || $update['ceiling_error'])
<p class="mt-3 text-xs text-muted">
@if ($update['ceiling'])
{{ $update['ceiling_passed']
? __('admin_settings.release_pinned_passed', ['version' => ltrim($update['ceiling'], 'v')])
: __('admin_settings.release_pinned_at', ['version' => ltrim($update['ceiling'], 'v')]) }}
@endif
<button type="button" wire:click="unpinRelease" class="underline">{{ __('admin_settings.release_unpin_action') }}</button>
</p>
@endif
@if ($update['ceiling_error'])
<x-ui.alert variant="warning" class="mt-3">
{{ __('admin_settings.release_ceiling_'.$update['ceiling_error']) }}
</x-ui.alert>
@endif
@if (! $update['agent_seen'])
<x-ui.alert variant="warning" class="mt-4">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
@elseif ($update['blocked_since'])

View File

@ -0,0 +1,123 @@
<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
/**
* Die Decke, gefahren vom echten Agenten.
*
* Nicht nachgerechnet: die Klemmung sitzt zwischen `git fetch` und dem
* Schreiben der Statusdatei, und genau dieses Zusammenspiel ist die Frage.
* `git` liegt als Attrappe im PATH, deren `fetch` sofort gelingt, ohne eine
* Gegenstelle zu brauchen alle uebrigen git-Aufrufe gehen ans echte git im
* Wegwerf-Repo.
*/
function runAgentWithCeiling(?string $ceiling, array $tags, string $deployedVersion = '1.7.3'): array
{
// Ein WEGWERF-Checkout mit EIGENEM .git — niemals das Repository dieses
// Arbeitsbaums.
//
// Tags liegen im gemeinsamen .git und sind damit auch fuer den Hauptbaum
// und jede Parallelsitzung sichtbar. Ein hier angelegtes v9.9.9 wuerde
// `git tag -l 'v*' --sort=-v:refname | head -1` falsch beantworten — und
// genau diese Frage entscheidet, wohin ein Server aktualisiert. Stirbt der
// Test vor seinem Aufraeumen, bliebe es liegen.
//
// Der Agent bestimmt seine Wurzel aus dem eigenen Pfad
// (`ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"`). Es genuegt
// also, deploy/ zu kopieren und ihn dort zu starten.
$root = sys_get_temp_dir().'/clupilot-ceiling-'.bin2hex(random_bytes(6));
File::ensureDirectoryExists($root.'/deploy/lib');
File::ensureDirectoryExists($root.'/storage/app/deploy');
File::copy(base_path('deploy/update-agent.sh'), $root.'/deploy/update-agent.sh');
File::copy(base_path('deploy/lib/release.sh'), $root.'/deploy/lib/release.sh');
// MANIFEST_FILE ist `storage/app/deployment.json` — eine Ebene UEBER
// storage/app/deploy/. Siehe deploy/lib/release.sh:19.
File::put($root.'/storage/app/deployment.json', json_encode([
'version' => $deployedVersion,
'commit' => 'deadbeef',
'source' => 'refs/tags/v'.$deployedVersion,
]));
if ($ceiling !== null) {
File::put($root.'/storage/app/deploy/release-ceiling', $ceiling);
}
$tagCommands = '';
foreach ($tags as $tag) {
$tagCommands .= "git tag {$tag}\n";
}
$result = Process::path($root)->timeout(90)->run(<<<BASH
set -e
git init -q .
git -c user.email=t@example.com -c user.name=t commit -q --allow-empty -m init
{$tagCommands}
stub="\$(mktemp -d)"
# Nur `fetch` wird abgefangen — es gibt keine Gegenstelle. Alles andere
# geht ans echte git und wirkt im Wegwerf-Checkout.
cat > "\$stub/git" <<'GIT'
#!/bin/sh
if [ "\$1" = "fetch" ]; then exit 0; fi
exec /usr/bin/git "\$@"
GIT
chmod +x "\$stub/git"
printf '#!/bin/sh\nexit 1\n' > "\$stub/docker"
chmod +x "\$stub/docker"
PATH="\$stub:\$PATH" bash deploy/update-agent.sh >/dev/null 2>&1 || true
rm -rf "\$stub"
BASH);
expect($result->successful())->toBeTrue($result->errorOutput());
$status = json_decode(File::get($root.'/storage/app/deploy/update-status.json'), true);
File::deleteDirectory($root);
return $status;
}
it('offers only up to the ceiling', function () {
// Ausgeliefert 1.7.3, vorhanden bis v9.9.9, Decke auf v9.9.8: genau eine
// Aktualisierung, und das Ziel ist die Decke.
$status = runAgentWithCeiling('v9.9.8', ['v9.9.8', 'v9.9.9']);
expect($status['target_release'])->toBe('v9.9.8')
->and($status['behind'])->toBe(1)
->and($status['ceiling'])->toBe('v9.9.8')
->and($status['ceiling_error'])->toBe('');
});
it('offers nothing when the ceiling points at a tag that does not exist', function () {
// Die Decke faellt ZU. Ein Rueckfall auf "neueste" installierte genau das,
// wovon der Besitzer weggenagelt hat.
$status = runAgentWithCeiling('v9.9.7', ['v9.9.8', 'v9.9.9']);
expect($status['behind'])->toBe(0)
->and($status['ceiling_error'])->toBe('ceiling_missing')
->and($status['target_release'])->toBe('');
});
it('offers nothing when the ceiling is malformed', function () {
$status = runAgentWithCeiling('neueste bitte', ['v9.9.8', 'v9.9.9']);
expect($status['behind'])->toBe(0)
->and($status['ceiling_error'])->toBe('ceiling_invalid');
});
it('behaves exactly as before without a ceiling', function () {
$status = runAgentWithCeiling(null, ['v9.9.8', 'v9.9.9']);
expect($status['target_release'])->toBe('v9.9.9')
->and($status['ceiling'])->toBe('')
->and($status['ceiling_error'])->toBe('');
});
it('reports the versions that may be pinned to', function () {
$status = runAgentWithCeiling(null, ['v9.9.8', 'v9.9.9']);
expect($status['releases'])->toContain('v9.9.9')
->and($status['releases'])->toContain('v9.9.8');
});

View File

@ -0,0 +1,339 @@
<?php
use App\Livewire\Admin\ConfirmPinRelease;
use App\Livewire\Admin\Settings;
use App\Models\Operator;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
beforeEach(function () {
File::ensureDirectoryExists(storage_path('app/deploy'));
File::delete(File::glob(storage_path('app/deploy/*')));
});
afterEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
it('writes and removes the ceiling', function () {
$channel = app(UpdateChannel::class);
$path = storage_path('app/deploy/release-ceiling');
expect($channel->setCeiling('chef@example.com', 'v1.8.0'))->toBeTrue()
->and(trim(File::get($path)))->toBe('v1.8.0');
expect($channel->setCeiling('chef@example.com', null))->toBeTrue()
->and(File::exists($path))->toBeFalse();
});
it('refuses a ceiling that is not a version', function () {
// Die Form wird auf BEIDEN Seiten geprueft. Der Agent kann sich auf nichts
// verlassen, was aus einer Datei kommt — und die Konsole soll gar nicht
// erst etwas hinlegen, das er ablehnen muss.
$channel = app(UpdateChannel::class);
expect($channel->setCeiling('chef@example.com', 'neueste'))->toBeFalse()
->and(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});
it('leaves no temporary file behind', function () {
// Atomar heisst: der Agent sieht die Datei ganz oder gar nicht. Bliebe die
// .tmp liegen, waere das der Beleg fuer ein File::put statt eines rename.
app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0');
expect(File::exists(storage_path('app/deploy/release-ceiling.tmp')))->toBeFalse();
});
it('reports the ceiling from the file, not from the agent', function () {
// Die Decke ist der Konsole eigener Zustand. Sie muss sofort stehen, statt
// zu warten, bis der Agent sie zurueckmeldet.
app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0');
expect(app(UpdateChannel::class)->state()['ceiling'])->toBe('v1.8.0');
});
it('passes the ceiling complaint from the agent through', function () {
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'ceiling_error' => 'ceiling_missing',
'releases' => ['v1.8.1', 'v1.8.0'],
]));
$state = app(UpdateChannel::class)->state();
expect($state['ceiling_error'])->toBe('ceiling_missing')
->and($state['releases'])->toBe(['v1.8.1', 'v1.8.0']);
});
it('marks a ceiling that sits below what is deployed', function () {
// Über das Auswahlfeld nicht erreichbar, über die Kommandozeile schon:
// Decke auf v1.8.0, jemand fährt `RELEASE=v1.8.1 update.sh`. Die Lage
// muss lesbar sein statt als „aktuell" durchzugehen — es steht ja eine
// Decke, sie ist nur überholt.
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
$state = app(UpdateChannel::class)->state();
// `version` kommt aus Release::current(); in der Testumgebung ist das die
// VERSION-Datei des Checkouts, die über 1.8.0 liegt.
expect($state['ceiling_passed'])->toBeTrue();
});
it('does not mark a ceiling that is still ahead', function () {
File::put(storage_path('app/deploy/release-ceiling'), 'v99.0.0');
expect(app(UpdateChannel::class)->state()['ceiling_passed'])->toBeFalse();
});
it('does not throw when the ceiling file exists but cannot be read as a file', function () {
// Kein echtes Wettrennen im Test moeglich, aber derselbe Codepfad wie ein
// Verschwinden zwischen File::exists() und File::get(): ein Verzeichnis
// existiert (File::exists() prueft file_exists(), das ist fuer
// Verzeichnisse wahr), ist aber keine Datei (File::get() prueft
// isFile() und wirft eine FileNotFoundException). Ohne Absicherung
// wuerfe state() genau hier — die Vorgabe, die es unter keinen
// Umstaenden darf.
//
// Bewusst kein expect(fn () => ...)->not->toThrow(Throwable::class):
// Throwable ist ein Interface, class_exists() verneint es, und die
// Pruefung faellt intern auf einen Substring-Vergleich zurueck, der
// nichts mehr ueber ein tatsaechliches Werfen aussagt. Ein direkter
// Aufruf laesst eine durchschlagende Exception den Test regulaer als
// fehlgeschlagen melden — das ist der verlaessliche Nachweis.
File::ensureDirectoryExists(storage_path('app/deploy/release-ceiling'));
$state = app(UpdateChannel::class)->state();
expect($state['ceiling'])->toBeNull();
});
it('does not claim success when writing the ceiling fails', function () {
// Kein File-Facade-Mock: eine echte Rechteverweigerung auf einem
// Verzeichnis, das dieser (nicht-root) Testbenutzer selbst besitzt.
// File::put() auf die .tmp darin schlaegt wirklich fehl, nicht nur dem
// Namen nach.
$deployDir = storage_path('app/deploy');
chmod($deployDir, 0500);
try {
$result = app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0');
} finally {
chmod($deployDir, 0755);
}
expect($result)->toBeFalse()
->and(File::exists($deployDir.'/release-ceiling.tmp'))->toBeFalse()
->and(File::exists($deployDir.'/release-ceiling'))->toBeFalse();
});
it('makes the automatic window do nothing while the ceiling is reached', function () {
// Der wichtigste Test des ganzen Entwurfs. Er belegt, dass Knopf und
// Wartungsfenster denselben Zustand lesen — die Behauptung, auf der alles
// ruht. Ohne ihn ist „es gibt keinen zweiten Weg in eine Auslieferung"
// eine Absichtserklaerung im Kommentar von AutoUpdate.php, kein Beleg.
//
// Damit ALLEIN die Decke den Ausschlag gibt, muessen alle anderen
// Vorbedingungen aus AutoUpdate::handle() erfuellt sein: das Fenster ist
// eingeschaltet (--force uebergeht nur isOpen(), nicht enabled() — ein
// ausgeschaltetes Fenster wuerde den Test aus dem falschen Grund gruen
// machen), der Agent gilt als lebendig, und es laeuft nichts. Der Agent
// hat gemeldet: nichts verfuegbar, weil die Decke erreicht ist.
AppSettings::set(UpdateWindow::KEY_ENABLED, true);
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'behind' => 0,
'ceiling_error' => '',
'checked_at' => now()->toIso8601String(),
]));
File::put(storage_path('app/deploy/agent-alive.json'), json_encode([
'at' => now()->toIso8601String(),
'state' => 'running',
'skips' => 0,
]));
// Der Zustand, den Knopf und Wartungsfenster BEIDE lesen: schon hier
// steht `available` auf false, bevor der Befehl ueberhaupt laeuft.
expect(app(UpdateChannel::class)->state()['available'])->toBeFalse();
$this->artisan('clupilot:auto-update', ['--force' => true])
->assertSuccessful();
// Nichts abgelegt: keine Anfrage, kein Wartungsfenster, kein Lauf.
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse();
});
it('takes the same state without a ceiling, and does request an update', function () {
// Der Gegenbeweis zum Test oben: exakt derselbe Aufbau — Fenster an, Agent
// lebendig, nichts laeuft — nur `behind` steht auf 1 statt 0, so wie der
// Agent es meldet, wenn keine Decke ihn bremst. Erst beide Tests
// zusammen belegen, dass die Decke — und keine der anderen
// Vorbedingungen — den Ausschlag gibt: ohne sie legt dieselbe Automatik
// sehr wohl eine Anfrage ab.
AppSettings::set(UpdateWindow::KEY_ENABLED, true);
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'behind' => 1,
'ceiling_error' => '',
'checked_at' => now()->toIso8601String(),
]));
File::put(storage_path('app/deploy/agent-alive.json'), json_encode([
'at' => now()->toIso8601String(),
'state' => 'running',
'skips' => 0,
]));
expect(app(UpdateChannel::class)->state()['available'])->toBeTrue();
$this->artisan('clupilot:auto-update', ['--force' => true])
->assertSuccessful();
expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue();
});
it('pins from the console', function () {
// Rolle, nicht Einzelberechtigung: die siebzehn Konsolen-Berechtigungen
// hängen am `operator`-Guard über Rollen (R21). `->role('Owner')` ist das
// Muster der übrigen Tests, z. B. SwitchOperatingModeTest.
$owner = Operator::factory()->role('Owner')->create();
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'releases' => ['v1.8.1', 'v1.8.0'],
]));
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->set('ceilingChoice', 'v1.8.0')
->call('pinRelease', 'v1.8.0');
expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0');
});
it('takes the ceiling off again', function () {
$owner = Operator::factory()->role('Owner')->create();
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->call('unpinRelease');
expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});
it('refuses to pin without site.manage', function () {
// Eine Livewire-Aktion ist ein oeffentlicher Endpunkt. Die eine Stelle, an
// der eine Regel NICHT allein stehen darf, ist das disabled-Attribut eines
// Knopfes.
//
// Angemeldet, aber ohne die Rolle, die `site.manage` traegt: `site.manage`
// liegt am operator-Guard bei Owner und Admin (siehe die Migration
// 2026_07_25_220001 und die Begruendung in 2026_08_04_160000). Geprueft
// wird also die Berechtigung, nicht die Anmeldung.
$staff = Operator::factory()->create();
Livewire::actingAs($staff, 'operator')
->test(Settings::class)
->set('ceilingChoice', 'v1.8.0')
->call('pinRelease', 'v1.8.0')
->assertForbidden();
});
it('does not let an empty confirmation take the ceiling off while reporting success', function () {
// BEFUND 1: `setCeiling($operator->email, $this->ceilingChoice ?: null)`
// liess eine leere Auswahl als "Decke abnehmen" durchgehen — und meldete
// dabei `release_pinned`, die Erfolgsmeldung der GEGENHANDLUNG. Erreichbar
// war das, weil das Auswahlfeld `<option value="">` traegt und einzig ein
// Alpine-`x-bind:disabled` den Klick verhinderte — kein Ersatz fuer eine
// serverseitige Pruefung, siehe requestUpdate() im selben Bauteil.
//
// Die Decke steht bereits auf v1.8.0. Eine leere Bestaetigung darf sie
// weder loeschen noch die Festnageln-Erfolgsmeldung zeigen.
$owner = Operator::factory()->role('Owner')->create();
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->call('pinRelease', '')
->assertDispatched('notify', message: __('admin_settings.release_pin_empty'));
expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0');
});
it('prefills the select with the ceiling that is actually set', function () {
// BEFUND 5: `ceilingChoice` wurde nie aus dem gesetzten Zustand
// vorbelegt. Auf einem festgenagelten Server las das Feld "Neueste
// Version (nicht festgenagelt)", waehrend die Zeile darunter
// "Festgenagelt auf 1.8.0" sagte — zwei Bedienelemente derselben Karte,
// die sich widersprachen.
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->assertSet('ceilingChoice', 'v1.8.0');
});
it('leaves the select empty when nothing is pinned', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->assertSet('ceilingChoice', '');
});
it('keeps the "remove ceiling" button reachable when the file reads back empty', function () {
// BEFUND 6: Bash wertet eine leere Deckendatei als `ceiling_invalid`;
// PHP (UpdateChannel::ceiling()) liefert dafuer `null` — leerer Inhalt
// wird als "keine Decke" gelesen. Die Konsole zeigte dann zwar den
// Warnkasten, aber der `@if ($update['ceiling'])`-Block — der einzige
// Ort mit dem Knopf "Decke abnehmen" — wurde uebersprungen. Nur eine
// Hand am Wirt kann diesen Zustand erzeugen (eine leere Datei), aber die
// Konsole muss trotzdem einen Weg zurueck bieten, ohne Kommandozeile.
File::put(storage_path('app/deploy/release-ceiling'), '');
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'ceiling_error' => 'ceiling_invalid',
'releases' => ['v1.8.1', 'v1.8.0'],
]));
$owner = Operator::factory()->role('Owner')->create();
// Die Konsole selbst liest die leere Datei als "keine Decke" — der Bug
// sass genau in diesem Widerspruch zwischen den beiden Seiten.
expect(app(UpdateChannel::class)->state()['ceiling'])->toBeNull();
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->assertSeeHtml('wire:click="unpinRelease"')
->call('unpinRelease');
expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});
it('does not open the pin confirmation without the capability', function () {
// Nach dem Vorbild von IntegrationsPageTest::"does not open the
// save/forget confirmation without the capability" fuer ConfirmSaveSecret
// — ConfirmPinRelease trug dieselbe authorize('site.manage') in mount(),
// ohne dass irgendein Test sie je geprueft hat (BEFUND 4).
$staff = Operator::factory()->create();
Livewire::actingAs($staff, 'operator')
->test(ConfirmPinRelease::class, ['version' => 'v1.8.0'])
->assertForbidden();
});
it('confirms a pin through the modal without writing anything itself', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(ConfirmPinRelease::class, ['version' => 'v1.8.0'])
->assertSee('1.8.0')
->call('confirm')
->assertDispatched('pin-release-confirmed', version: 'v1.8.0');
expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});

View File

@ -115,6 +115,21 @@ it('answers with nothing, and cleanly, when there are no releases yet', function
->and(trim($process->getOutput()))->toBe('[0]');
});
it('survives a missing manifest instead of taking the agent down with it', function () {
// Kein storage/app/deployment.json in diesem Wegwerf-Repo — der Zustand
// eines frischen Checkouts vor der ersten Installation. `sed` auf einer
// fehlenden Datei liefert Status 2; die Zeile, die diese Funktion in
// update-agent.sh tatsaechlich verbraucht — eine reine Zuweisung,
// `DEPLOYED_VERSION="$(release_manifest_version)"` — reicht diesen
// Status unter `pipefail` an `set -e` weiter. Ohne Absicherung stirbt der
// Agent bei JEDEM Tick, bevor er je eine Statusdatei schreibt — dieselbe
// Fehlerklasse wie bei sync_vpn_certificate und einem fehlenden `.env`.
$process = runRelease('DEPLOYED_VERSION="$(release_manifest_version)"; echo "[$?][$DEPLOYED_VERSION]"');
expect($process->getExitCode())->toBe(0)
->and(trim($process->getOutput()))->toBe('[0][]');
});
it('orders by version, not alphabetically', function () {
// v1.10.0 is newer than v1.9.0 and sorts earlier as a string. Getting this
// wrong means a server sitting on 1.9.0 is told it is current.
@ -143,3 +158,72 @@ it('answers the pairwise comparison the same way, and exits cleanly either way',
->and(trim($process->getOutput()))->toBe($expected, "{$a} > {$b}");
}
});
it('stops at the ceiling instead of taking the newest tag', function () {
// Der Kern des Festnagelns: v1.8.1 existiert, darf aber nicht genommen
// werden, weil die Decke auf v1.8.0 steht.
$p = runRelease('release_newest_tag v1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1']);
expect($p->getExitCode())->toBe(0)
->and(trim($p->getOutput()))->toBe('v1.8.0');
});
it('takes the newest tag when no ceiling is given', function () {
// Das bisherige Verhalten muss unveraendert bleiben — ohne Decke aendert
// sich nichts an einem Server, der nie festgenagelt wurde.
$p = runRelease('release_newest_tag', ['v1.7.3', 'v1.8.0', 'v1.8.1']);
expect($p->getExitCode())->toBe(0)
->and(trim($p->getOutput()))->toBe('v1.8.1');
});
it('answers nothing when every tag is above the ceiling', function () {
$p = runRelease('release_newest_tag v1.0.0', ['v1.7.3', 'v1.8.1']);
expect($p->getExitCode())->toBe(0)
->and(trim($p->getOutput()))->toBe('');
});
it('counts only the tags up to the ceiling as ahead', function () {
// Ausgeliefert 1.7.3, Decke v1.8.0, vorhanden bis v1.8.2: genau EINE
// Aktualisierung ist verfuegbar, nicht drei.
$p = runRelease('release_tags_ahead 1.7.3 v1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1', 'v1.8.2']);
expect($p->getExitCode())->toBe(0)
->and(trim($p->getOutput()))->toBe('1');
});
it('counts nothing ahead when the ceiling equals what is deployed', function () {
// Der haeufigste Fall: "hier einfrieren". Muss sich wie "aktuell"
// rechnen, sonst bietet die Konsole etwas an, das nie installiert wird.
$p = runRelease('release_tags_ahead 1.8.0 v1.8.0', ['v1.8.0', 'v1.8.1', 'v1.8.2']);
expect($p->getExitCode())->toBe(0)
->and(trim($p->getOutput()))->toBe('0');
});
it('knows whether a tag really exists', function () {
// Form und Existenz sind zwei Fragen. Der Release-Prozess loescht einen
// falschen Tag und ueberspringt die Nummer — eine Decke kann also ohne
// Zutun ihres Setzers ins Leere zeigen.
$p = runRelease('release_tag_exists v1.8.0 && echo ja; release_tag_exists v9.9.9 || echo nein', ['v1.8.0']);
expect($p->getExitCode())->toBe(0)
->and(trim($p->getOutput()))->toBe("ja\nnein");
});
it('offers the deployed version itself as a ceiling, newest first', function () {
// Die ausgelieferte Version steht MIT in der Liste: "einfrieren, nichts
// Neues nehmen" ist das, was neun von zehn Servern brauchen.
$p = runRelease('release_tags_from 1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1']);
expect($p->getExitCode())->toBe(0)
->and(trim($p->getOutput()))->toBe("v1.8.1\nv1.8.0");
});
it('caps the offered list', function () {
$p = runRelease('release_tags_from 1.0.0 2', ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v1.3.0']);
expect($p->getExitCode())->toBe(0)
->and(trim($p->getOutput()))->toBe("v1.3.0\nv1.2.0");
});