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
commit
033578d75d
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue