212 lines
10 KiB
PHP
212 lines
10 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);
|
|
|
|
// 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)]);
|