_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= 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__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); // Containers the partner already runs continuously, because onboard deployed them there. // They cannot be failover material: there is nothing to start during an outage that is not // already up, and a tier assignment for one would be a delay applied to a running container. // // The stacks store XML template filenames, and their case does not have to agree with the // container's — this conf ships "my-prowlarr.xml" against a container named "Prowlarr" — so // the mapping is done case-insensitively, once, here. $stackOf = []; $byLower = []; foreach ($containers as $n) $byLower[strtolower($n)] = $n; foreach ([ 'auth' => "{$hostUp}_PARTNERSHIP_AUTH_STACK", 'arrs' => "{$hostUp}_PARTNERSHIP_ARR_STACK", 'services' => "{$hostUp}_PARTNERSHIP_SERVICES_STACK", ] as $label => $var) { foreach (vv_parse_conf_list($raw, $var) as $xml) { $n = preg_replace('/^my-|\.xml$/', '', trim($xml)); if ($n === '') continue; // The container's real name where this host has one, the template's otherwise: a // stack entry for something not installed here is still a stack entry. $stackOf[$byLower[strtolower($n)] ?? $n] = $label; } } // 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, 'stacks' => (object)$stackOf, '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; } } // Stack containers refused here, not only greyed out in the picker. The card disables their // selects, but a disabled input is a courtesy to the operator, not a constraint on the endpoint — // and a tier assignment for a container the partner already runs permanently is a delay attached // to something that never stops. $stackNames = []; foreach ([ "{$hostUp}_PARTNERSHIP_AUTH_STACK", "{$hostUp}_PARTNERSHIP_ARR_STACK", "{$hostUp}_PARTNERSHIP_SERVICES_STACK", ] as $var) { foreach (vv_parse_conf_list($existingRaw, $var) as $xml) { $n = preg_replace('/^my-|\.xml$/', '', trim($xml)); if ($n !== '') $stackNames[strtolower($n)] = true; } } $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; } if (isset($stackNames[strtolower((string)$name)])) { echo json_encode(['ok' => false, 'error' => "$name is deployed to the partner as part of a stack — it already runs there, so it cannot be given a fallback tier"]); 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)]);