_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. // services HOST_PARTNERSHIP_SERVICES_STACK — XML templates pushed to the mirror during // onboard, for services that are neither auth nor arr. // // OPERATIONAL MODEL // GET returns this host's docker containers plus the current membership of both lists. // POST action=cover tiers= rewrites all four tier arrays // POST action=services stack= rewrites the services stack // // Both write through vv_conf_edit(), the same locked read-modify-write every other conf // endpoint uses, and both push the result — a partner holding the old list is a partner that // will act on the old list. // // 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. // // The services stack stores XML template filenames, not container names. // That is what onboard pushes. The picker speaks container names because that is what the // operator recognises, and the mapping to my-.xml happens here, once. // // OPERATIONAL SAFEGUARDS // POST only for writes, so Unraid's CSRF guard applies. See README-unraid.md. // // Names are validated against the containers this host actually runs. A tier or stack entry // for a container that does not exist here is a line fallback.sh will fail on during an // outage, which is the worst possible time to discover a typo. // // A tier value outside 1-4 is rejected rather than clamped — silently moving a container from // tier 9 to tier 4 would give it a 24-hour delay nobody asked for. // // Writing an empty list is allowed. "Cover nothing" is a legitimate choice and the only way to // express it. // // DEPENDS ON // include/confform.php vv_conf_edit() // 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'; // vv_docker_containers() $hostId = vv_detect_host(); // host1 / host2 $hostUp = strtoupper($hostId); // HOST1 $myConf = $hostId . '.conf'; $TIERS = [1, 2, 3, 4]; $tierVar = fn(int $t) => "FALLBACK_{$hostUp}_TIER{$t}"; $svcVar = "{$hostUp}_PARTNERSHIP_SERVICES_STACK"; // Container name ⇄ template filename. Unraid writes my-.xml; the list stores that. $toXml = fn(string $c) => 'my-' . $c . '.xml'; $fromXml = fn(string $x) => preg_replace('/^my-|\.xml$/', '', trim($x)); // ── Read ───────────────────────────────────────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $raw = vv_read_conf_raw($myConf); $mRaw = vv_read_conf_raw('master.conf'); $cover = []; foreach ($TIERS as $t) { // Tiers live in host*.conf on this host; fall back to master.conf for installs that // kept them there. Reading both costs nothing and avoids an empty card on either shape. $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; } } $services = []; foreach (vv_parse_conf_list($raw, $svcVar) as $x) { $n = $fromXml($x); if ($n !== '') $services[] = $n; } $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); // Templates that exist, so the services picker can say which choices onboard could actually // push. A container with no XML cannot be deployed to the partner however it is ticked. $haveXml = []; foreach (glob('/boot/config/plugins/dockerMan/templates-user/my-*.xml') ?: [] as $p) { $haveXml[] = $fromXml(basename($p)); } echo json_encode([ 'ok' => true, 'host' => $hostUp, 'containers' => $containers, 'have_xml' => $haveXml, 'cover' => (object)$cover, 'services' => $services, 'tier_vars' => array_map($tierVar, $TIERS), 'svc_var' => $svcVar, ]); exit; } // ── Write ──────────────────────────────────────────────────────────────────────────────────── $action = $_POST['action'] ?? ''; // Containers this host runs, PLUS whatever the lists already name. The conf legitimately holds // entries for containers that are not here right now — one removed since it was added, one // living on a partner, one simply stopped. Validating only against the running set would refuse // to save a list the operator had not touched: FALLBACK_HOST1_TIER4 still carries "LidaTube", // which no longer exists here, so opening the card and pressing Save would fail on a line // nobody had edited. // // New names still have to be real. The guard is against inventing containers, not against // keeping ones already recorded. $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; } } foreach (vv_parse_conf_list($existingRaw, $svcVar) as $x) { $n = $fromXml($x); if ($n !== '' && !isset($known[strtolower($n)])) $known[strtolower($n)] = $n; } // Rewrites one `NAME=(` … `)` block in place, preserving the leading indent the conf uses. $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 preg_replace_callback($pattern, fn($m) => $m[1] . $var . "=(\n" . $body . $m[1] . ")", $cur, 1); } return null; // absent: refuse rather than append into an unknown section }; if ($action === 'cover') { $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)]; } $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; } vv_push_master_conf(); echo json_encode(['ok' => true, 'counts' => array_map('count', $byTier)]); exit; } if ($action === 'services') { $list = json_decode((string)($_POST['stack'] ?? ''), true); if (!is_array($list)) { echo json_encode(['ok' => false, 'error' => 'stack must be an array']); exit; } $xml = []; foreach ($list as $name) { if (!isset($known[strtolower((string)$name)])) { echo json_encode(['ok' => false, 'error' => "No container named $name on this host"]); exit; } $real = $known[strtolower((string)$name)]; $path = '/boot/config/plugins/dockerMan/templates-user/' . $toXml($real); if (!is_file($path)) { echo json_encode(['ok' => false, 'error' => "No template " . $toXml($real) . " — the partner could not deploy it"]); exit; } $xml[] = $toXml($real); } $ok = vv_conf_edit($myConf, fn(string $cur): ?string => $rewrite($cur, $svcVar, $xml), [], [$svcVar]); if (!$ok) { echo json_encode(['ok' => false, 'error' => vv_conf_last_error() ?: 'Write failed']); exit; } vv_push_master_conf(); echo json_encode(['ok' => true, 'count' => count($xml)]); exit; } echo json_encode(['ok' => false, 'error' => 'Unknown action']);