Files
Varaverk/Plugin/unraid/api/fallback_coverage.php
T

167 lines
8.2 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Failover coverage: FALLBACK_<me>_TIER1-4 in this host's own conf — which of THIS host's
// containers the partner starts when this host goes dark, and in which delay tier.
//
// OPERATIONAL MODEL
// GET this host's containers plus current tier membership.
// POST action=cover tiers=<json {container: tier}> rewrites all four tier arrays.
//
// Originally the Coverage picker on the Partnership page (e8ee5b0), removed the same day in
// 1a836da — "they describe what the partner runs during an outage, which is the Fallback tab's
// subject" — and never rehomed, because that tab had nothing to host it. This is that card,
// rebuilt where it belongs, with the services half left behind: pushing XML templates to a
// mirror is an onboard concern, not a failover one.
//
// DESIGN PRINCIPLES
// Edit the array fallback.sh actually reads, not a parallel one.
// The coverage tiers already exist and already carry the timing. A second "what to fail
// over" list would be a second answer to the same question, and the two would drift.
//
// A host edits only its OWN tiers, and the page says so.
// FALLBACK_<host>_TIER* lives in that host's conf and describes what someone else runs for
// it. Sparse checkout means this host does not have the partner's host*.conf at all — only
// the read-only RAM cache conf_sync fills — so an editor for the partner's coverage would
// be writing to a cache that the next sync overwrites. Configure HOST2's coverage from
// HOST2. This is the same trap that left the Watchdog card reporting a partner's lists as
// empty when they were merely somewhere else.
//
// OPERATIONAL SAFEGUARDS
// POST only for writes, so Unraid's CSRF guard applies.
//
// Names are validated against containers this host runs, PLUS whatever the tiers already name.
// The conf legitimately holds entries for containers not present right now — removed,
// stopped, or renamed. Validating only against the running set would refuse to save a list
// the operator never touched. New names still have to be real; the guard is against
// inventing containers, not against keeping ones already recorded.
//
// A tier outside 1-4 is rejected, never clamped. Silently moving a container from tier 9 to
// tier 4 would give it a 24-hour delay nobody asked for.
//
// An absent array is refused, not appended. Writing a new block into an unknown position in a
// conf is how a setting ends up in the wrong section and stops being read.
//
// Writing an empty list is allowed — "cover nothing" is a legitimate choice and the only way
// to express it.
//
// REQUEST
// GET → current lists
// POST action=cover tiers={"Emby":1,...} → rewrite tiers 1-4
//
// RESPONSE
// {"ok":true,...} read payload, or {"ok":true,"counts":{...}} after a write
// {"ok":false,"error":string} validation or write failure, stated
//
// DEPENDS ON
// include/confform.php vv_conf_edit(), vv_conf_last_error(), vv_parse_conf_list()
// include/common.php vv_docker_containers()
// include/config.php vv_detect_host(), vv_read_conf_raw(), vv_push_master_conf()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/confform.php';
require_once dirname(__DIR__) . '/include/common.php';
$hostId = vv_detect_host();
$hostUp = strtoupper($hostId);
$myConf = $hostId . '.conf';
$TIERS = [1, 2, 3, 4];
$tierVar = fn(int $t) => "FALLBACK_{$hostUp}_TIER{$t}";
// ── Read ─────────────────────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$raw = vv_read_conf_raw($myConf);
$mRaw = vv_read_conf_raw('master.conf');
$cover = [];
foreach ($TIERS as $t) {
// host*.conf first, master.conf second — installs that kept the tiers there still read.
$vals = vv_parse_conf_list($raw, $tierVar($t)) ?: vv_parse_conf_list($mRaw, $tierVar($t));
foreach ($vals as $c) { $c = trim($c); if ($c !== '') $cover[$c] = $t; }
}
$containers = [];
foreach (vv_docker_containers() as $c) {
$n = is_array($c) ? ($c['name'] ?? '') : (string)$c;
if ($n !== '') $containers[] = $n;
}
sort($containers, SORT_NATURAL | SORT_FLAG_CASE);
// Named in a tier but not installed here. Reported rather than filtered: a tier entry for a
// container that does not exist is a line fallback.sh fails on during an outage, which is
// the worst possible moment to find a typo.
$missing = [];
$have = array_map('strtolower', $containers);
foreach (array_keys($cover) as $n) if (!in_array(strtolower($n), $have, true)) $missing[] = $n;
echo json_encode([
'ok' => true,
'host' => $hostUp,
'containers' => $containers,
'cover' => (object)$cover,
'missing' => $missing,
'tier_vars' => array_map($tierVar, $TIERS),
]);
exit;
}
// ── Write ────────────────────────────────────────────────────────────────────────────────────
if (($_POST['action'] ?? '') !== 'cover') {
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
exit;
}
$known = [];
foreach (vv_docker_containers() as $c) {
$n = is_array($c) ? ($c['name'] ?? '') : (string)$c;
if ($n !== '') $known[strtolower($n)] = $n;
}
$existingRaw = vv_read_conf_raw($myConf);
foreach ($TIERS as $t) {
foreach (vv_parse_conf_list($existingRaw, $tierVar($t)) as $n) {
$n = trim($n);
if ($n !== '' && !isset($known[strtolower($n)])) $known[strtolower($n)] = $n;
}
}
$map = json_decode((string)($_POST['tiers'] ?? ''), true);
if (!is_array($map)) { echo json_encode(['ok' => false, 'error' => 'tiers must be an object']); exit; }
$byTier = array_fill_keys($TIERS, []);
foreach ($map as $name => $tier) {
$t = (int)$tier;
if (!in_array($t, $TIERS, true)) {
echo json_encode(['ok' => false, 'error' => "Tier $tier is not 1-4 (for $name)"]); exit;
}
if (!isset($known[strtolower((string)$name)])) {
echo json_encode(['ok' => false, 'error' => "No container named $name on this host"]); exit;
}
$byTier[$t][] = $known[strtolower((string)$name)];
}
// Rewrites one `NAME=(` … `)` block in place, preserving the conf's leading indent.
$rewrite = function (string $cur, string $var, array $items): ?string {
$body = '';
foreach ($items as $i) $body .= " \"" . $i . "\"\n";
$pattern = '/^([ \t]*)' . preg_quote($var, '/') . '=\((?:[^)]*)\)/m';
if (!preg_match($pattern, $cur)) return null; // absent: refuse rather than append blind
return preg_replace_callback($pattern,
fn($m) => $m[1] . $var . "=(\n" . $body . $m[1] . ")", $cur, 1);
};
$ok = vv_conf_edit($myConf, function (string $cur) use ($byTier, $TIERS, $tierVar, $rewrite): ?string {
foreach ($TIERS as $t) {
$next = $rewrite($cur, $tierVar($t), $byTier[$t]);
if ($next === null) return null;
$cur = $next;
}
return $cur;
}, [], array_map($tierVar, $TIERS));
if (!$ok) { echo json_encode(['ok' => false, 'error' => vv_conf_last_error() ?: 'Write failed']); exit; }
// A partner holding the old list is a partner that will act on the old list.
vv_push_master_conf();
echo json_encode(['ok' => true, 'counts' => array_map('count', $byTier)]);