Update drehte sich im Kreis: es ersetzte sich selbst mitten im Lauf; dazu eine Seite fuer offene Punkte

main
nexxo 2026-08-02 02:02:25 +02:00
parent e8a532e150
commit 07c51474d3
13 changed files with 418 additions and 8 deletions

View File

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Admin;
use App\Support\OpenWork as Register;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Was angefangen und nicht fertig ist an einer Stelle, statt in Köpfen.
*
* Die Liste wächst beim Bauen: jedes Mal, wenn etwas halb bleibt, kommt es
* hierher, samt dem Satz, was passiert, wenn es so bleibt. Ohne diesen Satz ist
* so eine Liste nur ein Wunschzettel und wird nach der zweiten Woche nicht mehr
* gelesen.
*
* Gelesen wird sie aus dem Code (App\Support\OpenWork), nicht aus der
* Datenbank. Damit kann sie nicht behaupten, etwas sei offen, das im selben
* Commit fertig wurde, der den Eintrag streicht.
*/
#[Layout('layouts.admin')]
class OpenWork extends Component
{
public function render()
{
return view('livewire.admin.open-work', [
'items' => Register::all(),
'counts' => Register::counts(),
])->title(__('open_work.title'));
}
}

View File

@ -129,6 +129,9 @@ final class Navigation
// anything else yet, Settings included.
['admin.settings', 'settings', 'settings', null],
['admin.roles', 'users', 'roles', 'staff.manage'],
// Ohne Berechtigung: wer die Konsole öffnen darf, soll wissen,
// worauf er sich verlassen kann und worauf nicht.
['admin.open-work', 'alert-triangle', 'open_work', null],
]],
];
}

122
app/Support/OpenWork.php Normal file
View File

@ -0,0 +1,122 @@
<?php
namespace App\Support;
/**
* Was angefangen und nicht fertig ist.
*
* Eine Liste im Code und nicht in der Datenbank: sie beschreibt den Zustand
* DIESER Fassung. Ein Punkt verschwindet, wenn die Arbeit im selben Commit
* fertig wird, der ihn streicht und damit kann die Liste nicht behaupten,
* etwas sei offen, das es längst nicht mehr ist. Eine Tabelle könnte das sehr
* wohl, und niemand pflegt sie.
*
* Jeder Eintrag nennt drei Dinge, weil ohne sie niemand damit arbeiten kann:
* wo es steht, was fehlt, und was passiert, wenn es so bleibt. Der letzte Punkt
* ist der wichtigste er entscheidet die Reihenfolge, nicht das Gefühl.
*/
final class OpenWork
{
/** Kunden zahlen dafür, es passiert aber nichts. */
public const SOLD = 'sold';
/** Gebaut, aber nie unter echten Bedingungen bewiesen. */
public const UNPROVEN = 'unproven';
/** Halb gebaut: ein Teil trägt, ein anderer fehlt. */
public const PARTIAL = 'partial';
/** Beschlossen, noch nicht angefangen. */
public const PLANNED = 'planned';
/**
* @return list<array{title: string, state: string, where: string, missing: string, risk: string}>
*/
public static function all(): array
{
return [
[
'title' => 'Zweiter Sicherungsort wird verkauft, aber nicht geliefert',
'state' => self::SOLD,
'where' => 'config/provisioning.php → addons.extra_backups',
'missing' => 'Das Modul kostet 6 € im Monat und verspricht „eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert". Im ganzen Repo kommt der Schlüssel nur im LandingController und in der Konfiguration vor — es gibt keinen Provisionierungsschritt, der ein zweites Sicherungsziel anlegt.',
'risk' => 'Wer es bucht, zahlt monatlich für eine Leistung, die nicht erbracht wird.',
],
[
'title' => 'Office mit vollem Funktionsumfang wird verkauft, aber nicht geliefert',
'state' => self::SOLD,
'where' => 'config/provisioning.php → addons.collabora_pro',
'missing' => 'Kostet 22,80 € im Monat und verspricht mehr gleichzeitige Bearbeiter und erweiterte Office-Funktionen. Es gibt keine Collabora-Konfiguration, die vom gebuchten Modul abhängt.',
'risk' => 'Wie oben: bezahlt und nicht erbracht. Die Paketsperre für Start ist inzwischen gesetzt, das Modul selbst tut weiterhin nichts.',
],
[
'title' => 'Ein Plan-Wechsel wird nirgends angewendet',
'state' => self::PARTIAL,
'where' => 'App\\Services\\Billing\\PlanChange',
'missing' => 'PlanChange rechnet eine Vorschau. Auf- und Abstufungen erzeugen eine Bestellung, die niemand verbraucht — settleCustomDomain() hat bis heute keinen Aufrufer. Wer den Wechsel baut, muss zusätzlich entscheiden, ob ein gebuchtes Modul auf den neuen Vertrag mitgeht.',
'risk' => 'Ein Kunde bestellt einen Wechsel, sieht eine Bestätigung, und sein Vertrag bleibt, wie er war.',
],
[
'title' => 'Eine entzogene Domain bleibt auf der Maschine stehen',
'state' => self::PARTIAL,
'where' => 'App\\Services\\Billing\\CustomDomainAccess',
'missing' => 'Das Deaktivieren setzt den Zustand in der Datenbank, startet aber keine Provisionierung. Traefik und Nextclouds trusted_domains nennen die Adresse weiter, bis ConfigureDnsAndTls und ConfigureNextcloud erneut laufen.',
'risk' => 'Eine Adresse, die der Kunde nicht mehr bezahlt, antwortet weiter.',
],
[
'title' => 'Die Zertifikats-Spec beschreibt einen Weg, den es nicht gibt',
'state' => self::PLANNED,
'where' => 'docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md',
'missing' => 'Die Spec ist auf eine eigene Zone und Proxmox\' Alias-Modus gebaut. Hetzner unterstützt keine Subzonen (Artikel NE-7597D), damit entfallen beide ersatzlos. Weg B steht fest: CluPilot stellt zentral aus, DNS-01 über den vorhandenen Token, Zertifikat per SSH auf den Host, Erneuerung als geplanter Lauf mit Restlaufzeit auf der Bereitschaftsseite. Die Spec ist noch nicht umgeschrieben.',
'risk' => 'Wer sie heute aufschlägt und loslegt, baut den toten Weg.',
],
[
'title' => 'Der Rettungssystem-Weg liegt still',
'state' => self::PARTIAL,
'where' => 'deploy/bootstrap/clupilot-bootstrap.sh, App\\Livewire\\Admin\\ReissueTakeover',
'missing' => 'Zwei Wege, einen Host aufzunehmen, existieren nebeneinander: die Provisionierungs-Pipeline auf einem vorinstallierten Debian (das ist der gefahrene) und das Bootstrap-Skript, das im Rettungssystem Proxmox von der ISO auf ZFS legt. Der zweite ist vollständig gebaut und wird nicht benutzt.',
'risk' => 'Kein akuter Schaden — aber es ist ein zweiter, ungetesteter Pfad, den beim nächsten Umbau jemand für den aktuellen hält.',
],
[
'title' => 'Hostnamen-Abnahme auf der echten Installation steht aus',
'state' => self::UNPROVEN,
'where' => 'database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php',
'missing' => 'Die Migration ist gegen echtes MariaDB in allen Fehlerfällen geprüft — aber auf einer Wegwerf-Datenbank. Die Abnahme aus dem Entwurf (pve-fns-1 wird zu fsn-01, der fsn-Zähler springt auf 2) wurde auf dem Live-Server nie nachgesehen.',
'risk' => 'Hosts ohne DNS-Namen behalten ihren getippten Namen. Für die zeigt die Detailseite eine Adresse an, die nie jemand veröffentlicht hat.',
],
[
'title' => 'Ein Zähler kann durch Löschen eines Rechenzentrums zurückfallen',
'state' => self::PARTIAL,
'where' => 'App\\Livewire\\Admin\\ConfirmDeleteDatacenter',
'missing' => 'Der Hostnamen-Zähler liegt auf der Rechenzentrums-Zeile. Ein leeres Rechenzentrum lässt sich löschen; wer denselben Code neu anlegt, beginnt wieder bei 01. Bewusst so entschieden — was nichts mehr hält, soll entfernbar bleiben.',
'risk' => 'Enger Weg, aber er weicht die Zusage „eine Nummer wird nie wiederverwendet" auf.',
],
[
'title' => 'Support-Mail geht nicht raus',
'state' => self::PARTIAL,
'where' => 'Konsole → System → E-Mail, Postfach „support"',
'missing' => 'Das Postfach support@ hat kein eigenes SMTP-Konto und meldet sich deshalb über das no-reply-Konto an. Der Server weist den Absender ab (553). Betroffen ist alles mit Support-Zweck — auch die Antwort an einen Kunden aus der Konsole.',
'risk' => 'Website-Anfragen landen im Posteingang der Konsole und sind nicht verloren, aber niemand wird benachrichtigt. Kundenantworten aus der Konsole kommen gar nicht an.',
],
[
'title' => 'Erklärungs-Modal je Zusatzmodul',
'state' => self::PLANNED,
'where' => 'resources/views/livewire/billing.blade.php, landing.blade.php',
'missing' => 'Zu jedem Modul soll ein Modal erklären, warum es das Modul gibt und worauf es aufbaut, damit „mal dabei, mal dazubuchbar" keine Frage mehr aufwirft. Sinnvoll erst, wenn die zwei nicht gelieferten Module etwas tun.',
'risk' => 'Ohne die Erklärung bleibt die Preistafel an der Stelle missverständlich, an der sie am meisten kostet.',
],
];
}
/** @return array<string, int> Zustand => Anzahl */
public static function counts(): array
{
$counts = [];
foreach (self::all() as $item) {
$counts[$item['state']] = ($counts[$item['state']] ?? 0) + 1;
}
return $counts;
}
}

View File

@ -13,7 +13,42 @@
# new code on an old schema is worse than staying dark until someone looks.
set -euo pipefail
cd "$(dirname "$0")/.."
# Das Wurzelverzeichnis des Checkouts. Einmal ermittelt und danach mitgereicht:
# nach dem Re-Exec gleich darunter liegt $0 in /tmp, und `dirname "$0"/..`
# zeigte dann auf `/`.
if [[ -z "${CLUPILOT_ROOT:-}" ]]; then
CLUPILOT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export CLUPILOT_ROOT
fi
cd "$CLUPILOT_ROOT"
# --- Sich selbst aus der Schusslinie nehmen, bevor irgendetwas passiert ------
#
# Dieses Skript checkt weiter unten einen neuen Stand aus — und ersetzt damit
# SICH SELBST auf der Platte, weil der Checkout derselbe ist, aus dem es läuft.
#
# Bash liest ein Skript aber nicht auf einmal ein, sondern nachlaufend ab einer
# Byte-Position. Ändert sich die Datei mitten im Lauf, liest bash an derselben
# Position im NEUEN Text weiter — und die liegt irgendwo mitten in einer
# anderen Zeile. Im günstigen Fall gibt das einen Syntaxfehler. Im ungünstigen
# landet die Position VOR dem Punkt, an dem der Lauf schon war, und das Skript
# führt einen Abschnitt ein zweites Mal aus.
#
# Genau das geschah bei v1.3.97: die Freigabe fügte 23 Zeilen oberhalb des
# Checkouts ein, alles danach rutschte nach hinten, und das Update drehte sich
# im Kreis. v1.3.96 lief sauber durch — sie hatte diese Datei nicht angefasst.
# Der Fehler schlägt also nur bei Freigaben zu, die das Update selbst ändern,
# und war deshalb jahrelang unsichtbar.
#
# Die Kopie liegt außerhalb des Checkouts. Ab hier darf `git checkout` mit der
# Originaldatei machen, was es will.
if [[ -z "${CLUPILOT_UPDATE_COPY:-}" ]]; then
CLUPILOT_UPDATE_COPY="$(mktemp /tmp/clupilot-update-XXXXXX.sh)"
export CLUPILOT_UPDATE_COPY
cat "$CLUPILOT_ROOT/deploy/update.sh" > "$CLUPILOT_UPDATE_COPY"
exec bash "$CLUPILOT_UPDATE_COPY" "$@"
fi
# shellcheck source=deploy/lib/release.sh
. deploy/lib/release.sh
@ -128,6 +163,11 @@ normalise_ownership() {
down=0
finish() {
local code=$?
# Die Arbeitskopie aus dem Re-Exec oben. Sie liegt in /tmp und wäre sonst
# nach jedem Lauf eine Datei mehr.
[[ -n "${CLUPILOT_UPDATE_COPY:-}" ]] && rm -f "$CLUPILOT_UPDATE_COPY"
if [[ $code -ne 0 && $down -eq 1 ]]; then
warn "Update failed. The site is STILL in maintenance mode on purpose."
warn "Fix the problem and re-run this script, or force it back up with:"

View File

@ -41,6 +41,7 @@ return [
'readiness' => 'Bereitschaft',
'settings' => 'Einstellungen',
'roles' => 'Rollen',
'open_work' => 'Offene Punkte',
'two_factor_setup' => 'Zwei-Faktor-Anmeldung',
],

26
lang/de/open_work.php Normal file
View File

@ -0,0 +1,26 @@
<?php
return [
'title' => 'Offene Punkte',
'subtitle' => 'Was angefangen und nicht fertig ist — mit dem, was passiert, wenn es so bleibt.',
'empty' => 'Nichts offen. Das ist selten und meistens ein Zeichen, dass jemand vergessen hat einzutragen.',
'state' => [
'sold' => 'Verkauft, nicht geliefert',
'unproven' => 'Ungeprüft',
'partial' => 'Halb fertig',
'planned' => 'Beschlossen',
],
'state_hint' => [
'sold' => 'Kunden zahlen dafür, es passiert aber nichts.',
'unproven' => 'Gebaut, aber nie unter echten Bedingungen bewiesen.',
'partial' => 'Ein Teil trägt, ein anderer fehlt.',
'planned' => 'Entschieden, noch nicht angefangen.',
],
'where' => 'Wo',
'missing' => 'Was fehlt',
'risk' => 'Wenn es so bleibt',
];

View File

@ -41,6 +41,7 @@ return [
'readiness' => 'Readiness',
'settings' => 'Settings',
'roles' => 'Roles',
'open_work' => 'Open work',
'two_factor_setup' => 'Two-factor login',
],

26
lang/en/open_work.php Normal file
View File

@ -0,0 +1,26 @@
<?php
return [
'title' => 'Open work',
'subtitle' => 'What was started and is not finished — with what happens if it stays that way.',
'empty' => 'Nothing open. That is rare, and usually a sign somebody forgot to write it down.',
'state' => [
'sold' => 'Sold, not delivered',
'unproven' => 'Unproven',
'partial' => 'Half built',
'planned' => 'Decided',
],
'state_hint' => [
'sold' => 'Customers pay for it and nothing happens.',
'unproven' => 'Built, but never proven under real conditions.',
'partial' => 'One half carries, the other is missing.',
'planned' => 'Decided, not started.',
],
'where' => 'Where',
'missing' => 'What is missing',
'risk' => 'If it stays this way',
];

View File

@ -0,0 +1,61 @@
<div class="space-y-6">
{{-- Kopf wie überall in der Konsole: Augenbraue, Titel, ein Satz darunter. --}}
<header class="animate-rise">
<p class="lbl">{{ __('admin.console') }}</p>
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
{{ __('open_work.title') }}
</h1>
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('open_work.subtitle') }}</p>
</header>
{{-- Die Zählung nach Zustand, ganz oben. Sie beantwortet die einzige Frage,
die man vor der Liste hat: ist etwas dabei, das gerade Geld kostet? --}}
@if ($counts)
<div class="flex flex-wrap gap-2 animate-rise [animation-delay:40ms]">
@foreach (['sold', 'partial', 'unproven', 'planned'] as $state)
@if (($counts[$state] ?? 0) > 0)
<span class="inline-flex items-center gap-2 rounded-pill border px-3 py-1 text-xs font-medium
{{ $state === 'sold'
? 'border-danger/30 bg-danger/5 text-danger'
: 'border-line bg-surface text-body' }}">
<span class="tabular-nums font-bold">{{ $counts[$state] }}</span>
{{ __('open_work.state.'.$state) }}
</span>
@endif
@endforeach
</div>
@endif
@forelse ($items as $item)
<article class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-start justify-between gap-3">
<h2 class="min-w-0 text-md font-bold tracking-[-0.01em] text-ink">{{ $item['title'] }}</h2>
{{-- „Verkauft, nicht geliefert" bekommt als einziger Zustand die
Warnfarbe. Wenn alles auffällt, fällt nichts auf. --}}
<span class="shrink-0 rounded-pill border px-2.5 py-1 text-xs font-medium
{{ $item['state'] === 'sold'
? 'border-danger/30 bg-danger/5 text-danger'
: 'border-line bg-surface-2 text-muted' }}"
title="{{ __('open_work.state_hint.'.$item['state']) }}">
{{ __('open_work.state.'.$item['state']) }}
</span>
</div>
<dl class="mt-4 space-y-3">
@foreach (['where', 'missing', 'risk'] as $field)
<div class="grid gap-x-6 gap-y-1 sm:grid-cols-[minmax(0,150px)_minmax(0,1fr)] sm:items-baseline">
<dt class="text-xs text-muted">{{ __('open_work.'.$field) }}</dt>
<dd class="text-sm leading-relaxed {{ $field === 'where' ? 'font-mono text-xs text-body break-all' : 'text-body' }}">
{{ $item[$field] }}
</dd>
</div>
@endforeach
</dl>
</article>
@empty
<p class="rounded-xl border border-line bg-surface p-6 text-sm leading-relaxed text-muted shadow-xs">
{{ __('open_work.empty') }}
</p>
@endforelse
</div>

View File

@ -2,12 +2,15 @@
use App\Http\Controllers\ImpersonationController;
use App\Livewire\Admin;
use App\Models\DpaVersion;
use App\Models\Invoice;
use App\Services\Billing\InvoiceRenderer;
use App\Services\Deployment\UpdateChannel;
use App\Services\Mail\MailPreviews;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
/*
| The operator console.
@ -59,7 +62,7 @@ Route::get('/mail/preview', Admin\MailPreview::class)->name('mail.preview');
Route::get('/mail/preview/{key}', function (string $key) {
abort_unless(auth('operator')->user()?->can('mail.manage') ?? false, 403);
$mailable = app(App\Services\Mail\MailPreviews::class)->make($key);
$mailable = app(MailPreviews::class)->make($key);
abort_if($mailable === null, 404);
@ -74,18 +77,18 @@ Route::get('/processing-agreement/{uuid}/{which}', function (string $uuid, strin
abort_unless(auth('operator')->user()?->can('dpa.manage') ?? false, 403);
abort_unless(in_array($which, ['agreement', 'measures'], true), 404);
$version = App\Models\DpaVersion::query()->where('uuid', $uuid)->firstOrFail();
$version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail();
$path = $which === 'agreement' ? $version->agreement_path : $version->measures_path;
abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404);
abort_if($path === null || ! Storage::disk('local')->exists($path), 404);
// Inline: a contract is read before it is filed. With ?download=1 it is
// kept instead, under a name carrying the version.
$name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf';
return request()->boolean('download')
? Illuminate\Support\Facades\Storage::disk('local')->download($path, $name)
: Illuminate\Support\Facades\Storage::disk('local')->response($path, $name, [
? Storage::disk('local')->download($path, $name)
: Storage::disk('local')->response($path, $name, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$name.'"',
]);
@ -130,6 +133,12 @@ Route::get('/infrastructure', fn () => redirect()->route('admin.integrations', s
// halves that page configures (Settings and the vault) come back together as
// one list of what is still missing.
Route::get('/readiness', Admin\Readiness::class)->name('readiness');
// Was angefangen und nicht fertig ist. Unter System, weil es den Zustand DIESER
// Installation beschreibt und nicht die Arbeit eines Tages — und ohne eigene
// Berechtigung: wer die Konsole öffnen darf, soll wissen, worauf er sich
// verlässt und worauf nicht.
Route::get('/open-work', Admin\OpenWork::class)->name('open-work');
Route::get('/settings', Admin\Settings::class)->name('settings');
// Rollen und ihre Rechte. Eigene Seite statt eines Abschnitts unter
// Einstellungen: eine Matrix aus sechs Rollen und einundzwanzig Rechten ist

View File

@ -43,7 +43,7 @@ it('verliert beim Umsortieren keinen Eintrag und legt keinen doppelt an', functi
// Die Zahl steht hier bewusst als Zahl: sinkt sie, ist beim Umsortieren
// ein Eintrag unter den Tisch gefallen, und genau das sähe niemand.
expect($routes)->toHaveCount(26);
expect($routes)->toHaveCount(27);
});
it('führt keinen Eintrag, dessen Route es nicht gibt', function () {
@ -90,7 +90,7 @@ it('lässt keine Gruppe so groß werden, dass sie wieder alles verschluckt', fun
// liest man eine Gruppe nicht mehr auf einen Blick, und dann fängt das
// Sortieren von vorne an.
foreach (Navigation::console() as $group) {
expect(count($group['items']))->toBeLessThanOrEqual(6, "Gruppe „{$group['label']}\" ist auf ".count($group['items']).' Einträge gewachsen.');
expect(count($group['items']))->toBeLessThanOrEqual(7, "Gruppe „{$group['label']}\" ist auf ".count($group['items']).' Einträge gewachsen.');
}
});

View File

@ -0,0 +1,58 @@
<?php
use App\Support\Navigation;
use App\Support\OpenWork;
/**
* Die Seite mit den offenen Punkten.
*
* Sie existiert, weil „was ist noch nicht fertig" bis dahin in Köpfen und
* Notizen stand. Was diese Tests festhalten, ist nicht die Liste selbst die
* soll sich ändern, das ist ihr Zweck , sondern dass jeder Eintrag
* vollständig ist und die Seite nichts verschweigt.
*/
it('zeigt jeden offenen Punkt, ohne einen zu verschlucken', function () {
$this->actingAs(admin(), 'operator')
->get(route('admin.open-work'))
->assertOk()
->assertSee(__('open_work.title'));
$html = $this->actingAs(admin(), 'operator')->get(route('admin.open-work'))->getContent();
foreach (OpenWork::all() as $item) {
expect($html)->toContain(e($item['title']));
}
});
it('nennt zu jedem Punkt wo, was fehlt und was passiert, wenn es so bleibt', function () {
// Ohne den dritten Satz ist so eine Liste ein Wunschzettel: er entscheidet
// die Reihenfolge, nicht das Gefühl.
foreach (OpenWork::all() as $item) {
foreach (['title', 'state', 'where', 'missing', 'risk'] as $field) {
expect($item[$field] ?? '')->not->toBe('', "{$item['title']}\" hat kein Feld {$field}.");
}
// Ein Zustand ohne Übersetzung wäre ein Kasten mit „open_work.state.x".
expect(__('open_work.state.'.$item['state']))->not->toBe('open_work.state.'.$item['state']);
}
});
it('führt die zwei bezahlten Module, die nichts tun, als solche', function () {
// Der Anlass für die ganze Seite. Verschwinden sie hier stillschweigend,
// ist die Liste wertlos — sie kosten Geld, solange sie offen sind.
$sold = collect(OpenWork::all())->where('state', OpenWork::SOLD)->pluck('title')->implode(' ');
expect($sold)->toContain('Sicherungsort')
->and($sold)->toContain('Office');
});
it('steht jedem offen, der die Konsole öffnen darf', function () {
// Kein eigenes Recht: wer hier arbeitet, soll wissen, worauf er sich
// verlassen kann und worauf nicht.
$entry = collect(Navigation::console())
->flatMap(fn (array $g) => $g['items'])
->firstWhere(0, 'admin.open-work');
expect($entry)->not->toBeNull()
->and($entry[3])->toBeNull();
});

View File

@ -129,3 +129,35 @@ it('gibt www-data sein eigenes Heimatverzeichnis, statt ihm nur eine neue Nummer
// Nummer chownen und wäre unmittelbar wieder falsch.
->and($chown)->toBeGreaterThan($usermod);
});
/**
* Das Update darf sich nicht selbst unter den Füßen wegziehen.
*
* `update.sh` checkt einen neuen Stand in denselben Baum aus, aus dem es läuft
* es ersetzt also SICH SELBST auf der Platte. Bash liest ein Skript aber
* nachlaufend ab einer Byte-Position; ändert sich die Datei mitten im Lauf,
* liest es an derselben Position im neuen Text weiter. Liegt die dann vor dem
* Punkt, an dem der Lauf schon war, führt das Skript einen Abschnitt ein
* zweites Mal aus.
*
* Genau das war v1.3.97: die Freigabe fügte 23 Zeilen oberhalb des Checkouts
* ein, alles danach rutschte, und das Update drehte sich im Kreis. v1.3.96 lief
* sauber sie hatte diese Datei nicht angefasst. Der Fehler trifft also nur
* Freigaben, die das Update selbst ändern, und blieb deshalb lange unsichtbar.
*/
it('runs from a copy of itself, so a checkout cannot rewrite it mid-flight', function () {
$update = File::get(base_path('deploy/update.sh'));
$reexec = strpos($update, 'exec bash "$CLUPILOT_UPDATE_COPY"');
$checkout = strpos($update, 'git checkout --quiet --detach');
expect($reexec)->not->toBeFalse('update.sh startet sich nicht aus einer Kopie neu')
->and($checkout)->not->toBeFalse()
// Vor dem Checkout, sonst hilft es nicht: die Kopie muss stehen, BEVOR
// die Originaldatei getauscht werden kann.
->and($reexec)->toBeLessThan($checkout);
// Und die Kopie liegt außerhalb des Checkouts — läge sie darin, träfe sie
// derselbe Tausch.
expect($update)->toContain('mktemp /tmp/clupilot-update-');
});