Files
KantSites/zentrale/app/Bestand.php
T
kantarciandClaude Opus 5.5 723680b7de Add the central site registry and remote updates
Every installation now reports address and version to sites.kant-it.de
every 15 minutes and picks up update orders there (ADR 123). Reports and
answers are HMAC-signed with a per-installation key sent on first
contact; an order can only install a published release with checksum.
The next request after the file swap runs the migrations, so a remote
update finishes without anyone in the admin.

The platform itself lives in zentrale/ (plain PHP + SQLite, not part of
the release archive): overview with outdated, silent and unassigned
installations, single and bulk updates, per-site detail and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:43:13 +02:00

152 lines
5.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Zentrale;
/**
* Die Installationen und ihre Aufträge - was die Übersicht zeigt und was ihre Knöpfe tun.
*/
final class Bestand
{
/** Ohne Meldung seit so vielen Stunden gilt eine Installation als verstummt. */
public const STILL_STUNDEN = 48;
/** Ein abgeholter Auftrag ohne Ergebnis nach so vielen Stunden: keine Rückmeldung. */
public const AUFTRAG_STUNDEN = 2;
public function __construct(private \PDO $db)
{
}
/** @return list<array<string, mixed>> */
public function alle(?string $neueste): array
{
$zeilen = $this->db->query('SELECT * FROM installation ORDER BY domain, erste_meldung')->fetchAll();
$auftraege = [];
foreach ($this->db->query('SELECT a.* FROM auftrag a JOIN (SELECT installation, MAX(id) AS id FROM auftrag GROUP BY installation) j ON j.id = a.id')->fetchAll() as $auftrag) {
$auftraege[$auftrag['installation']] = $auftrag;
}
return array_map(fn (array $zeile): array => $this->anreichern($zeile, $auftraege[$zeile['id']] ?? null, $neueste), $zeilen);
}
/** @return array<string, mixed>|null */
public function eine(string $id, ?string $neueste): ?array
{
$abfrage = $this->db->prepare('SELECT * FROM installation WHERE id = ?');
$abfrage->execute([$id]);
$zeile = $abfrage->fetch() ?: null;
if (null === $zeile) {
return null;
}
$verlauf = $this->db->prepare('SELECT * FROM auftrag WHERE installation = ? ORDER BY id DESC LIMIT 30');
$verlauf->execute([$id]);
$auftraege = $verlauf->fetchAll();
$eine = $this->anreichern($zeile, $auftraege[0] ?? null, $neueste);
$eine['verlauf'] = array_map(fn (array $a): array => [...$a, 'zustand' => $this->auftragZustand($a)], $auftraege);
return $eine;
}
/**
* Ein Update in Auftrag geben. Gibt null zurück oder den Grund, warum nicht.
*/
public function beauftragen(string $id, string $fassung): ?string
{
$eine = $this->eine($id, $fassung);
if (null === $eine) {
return 'Diese Installation gibt es nicht.';
}
if (!$eine['kann_update']) {
return $eine['kein_update_grund'];
}
$offen = $eine['auftrag'];
if (null !== $offen && null === $offen['abgeholt'] && null === $offen['erledigt']) {
$this->db->prepare('UPDATE auftrag SET fassung = ?, erstellt = ? WHERE id = ?')->execute([$fassung, Datenbank::jetzt(), $offen['id']]);
return null;
}
$this->db->prepare('INSERT INTO auftrag (installation, aktion, fassung, erstellt) VALUES (?, ?, ?, ?)')
->execute([$id, 'update', $fassung, Datenbank::jetzt()]);
return null;
}
/** Einen noch nicht abgeholten Auftrag zurückziehen. */
public function zurueckziehen(string $id): bool
{
$abfrage = $this->db->prepare('DELETE FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL');
$abfrage->execute([$id]);
return $abfrage->rowCount() > 0;
}
public function notiz(string $id, string $notiz): void
{
$this->db->prepare('UPDATE installation SET notiz = ? WHERE id = ?')->execute([mb_substr(trim($notiz), 0, 200), $id]);
}
public function entfernen(string $id): void
{
$this->db->prepare('DELETE FROM installation WHERE id = ?')->execute([$id]);
}
/**
* @param array<string, mixed> $zeile
* @param array<string, mixed>|null $auftrag
*
* @return array<string, mixed>
*/
private function anreichern(array $zeile, ?array $auftrag, ?string $neueste): array
{
$alter = time() - (int) strtotime((string) $zeile['letzte_meldung']);
$veraltet = null !== $neueste && 'unbekannt' !== $zeile['fassung'] && version_compare((string) $zeile['fassung'], $neueste, '<');
$zustand = null === $auftrag ? null : $this->auftragZustand($auftrag);
// Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft.
$laeuft = 'laeuft' === ($zustand['art'] ?? null);
$grund = match (true) {
null === $neueste => 'Die neueste Fassung ist gerade nicht bekannt.',
!$veraltet => 'Diese Installation ist auf dem neuesten Stand.',
'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.',
$laeuft => 'Ein Update läuft gerade.',
default => '',
};
return [
...$zeile,
'alter' => $alter,
'still' => $alter > self::STILL_STUNDEN * 3600,
'veraltet' => $veraltet,
'unbekannt' => '' === trim((string) $zeile['notiz']),
'auftrag' => $auftrag,
'auftrag_zustand' => $zustand,
'kann_update' => '' === $grund,
'kein_update_grund' => $grund,
];
}
/**
* @param array<string, mixed> $auftrag
*
* @return array{art: string, text: string}
*/
private function auftragZustand(array $auftrag): array
{
if (null !== $auftrag['erledigt']) {
return (int) $auftrag['ok'] === 1
? ['art' => 'ok', 'text' => sprintf('Update auf %s erledigt', $auftrag['fassung'])]
: ['art' => 'fehler', 'text' => sprintf('Update auf %s gescheitert', $auftrag['fassung'])];
}
if (null === $auftrag['abgeholt']) {
return ['art' => 'wartet', 'text' => sprintf('Update auf %s wartet auf die nächste Meldung', $auftrag['fassung'])];
}
if ((int) strtotime((string) $auftrag['abgeholt']) < time() - self::AUFTRAG_STUNDEN * 3600) {
return ['art' => 'fehler', 'text' => sprintf('Update auf %s: keine Rückmeldung', $auftrag['fassung'])];
}
return ['art' => 'laeuft', 'text' => sprintf('Update auf %s läuft', $auftrag['fassung'])];
}
}