From e3ed5a53f27d9b24ae9f40c711b856a738c142df Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Mon, 17 Aug 2026 14:08:35 -0400 Subject: [PATCH] Reproduce the owner's docker folder layout on the mirror instead of one lump fallback folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 12 filed all thirteen deployed containers under -Fallback, which says whose they are and nothing about what they do — and they are not failover coverage, they run there continuously. --- Plugin/unraid/Tools/mirror_folders.php | 200 +++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 Plugin/unraid/Tools/mirror_folders.php diff --git a/Plugin/unraid/Tools/mirror_folders.php b/Plugin/unraid/Tools/mirror_folders.php new file mode 100644 index 0000000..81e6042 --- /dev/null +++ b/Plugin/unraid/Tools/mirror_folders.php @@ -0,0 +1,200 @@ +-Fallback". That says whose +// they are and nothing about what they do, and it is the wrong shape for a stack that runs +// continuously rather than only during a failover. +// +// The fallback folder is for what is genuinely fallback-only. +// Anything in PARTNERSHIP_FALLBACK_ONLY, plus anything deployed that the owner does not +// file anywhere, goes to "-Fallback". A container that runs on the mirror all +// the time belongs with its peers; a container that exists only to cover the owner going +// dark belongs in a folder named after the owner. +// +// OPERATIONAL SAFEGUARDS +// Upsert by name, never append — folder.view3 keys by random id, so a blind write produces a +// second folder with the same name and half the contents. +// +// Atomic write: .vv.tmp then rename(), so folder.view3 never reads a truncated file. +// +// Absent plugin is a clean skip, not an error. A mirror without folder.view3 installed has +// nowhere to put folders and that is not a failure of the onboard. +// +// Import trusts nothing about shape: every folder needs a non-empty name and an array of +// container names, and anything else in the payload is ignored rather than merged. +// +// Icons are carried across as URLs. They are the owner's own icon values, already resolvable +// from any host, and an icon that fails to load is cosmetic. +// +// REQUEST +// mirror_folders.php --export --containers=Sonarr,Radarr[,…] [--fallback-only=a,b] [--owner=HOST1] +// mirror_folders.php --import [--dry-run] (plan on stdin) +// +// RESPONSE +// export: JSON plan on stdout — {"folders":[{"name":…,"icon":…,"containers":[…]}, …], +// "unfiled":[…]} +// import: one line per folder written, then a count +// +// DEPENDS ON +// /boot/config/plugins/folder.view3/docker.json the layout both sides read +// Tools/fallback_folder.php still owns the "-Fallback" folder +// ═══════════════════════════════════════════════════════════════════════════════════════════════ + +define('FV3_JSON', '/boot/config/plugins/folder.view3/docker.json'); + +$opts = getopt('', ['export', 'import', 'containers:', 'fallback-only:', 'dry-run']); + +$listArg = function (string $k) use ($opts): array { + if (!isset($opts[$k])) return []; + return array_values(array_filter(array_map('trim', explode(',', (string)$opts[$k])), 'strlen')); +}; + +// ── Load ───────────────────────────────────────────────────────────────────────────────────── +if (!file_exists(FV3_JSON)) { + // Export has nothing to read; import has nowhere to write. Both are clean no-ops. + if (isset($opts['export'])) { echo json_encode(['folders' => [], 'unfiled' => $listArg('containers')]) . "\n"; exit(0); } + echo "folder.view3 is not installed — nothing to do\n"; + exit(0); +} +$fv3 = json_decode((string)@file_get_contents(FV3_JSON), true); +if (!is_array($fv3)) { fwrite(STDERR, "folder.view3 docker.json is unreadable\n"); exit(1); } + +// ── Export ─────────────────────────────────────────────────────────────────────────────────── +if (isset($opts['export'])) { + $deployed = $listArg('containers'); + $fallbackOnly = array_map('strtolower', $listArg('fallback-only')); + if (!$deployed) { fwrite(STDERR, "--containers is required for --export\n"); exit(2); } + + // Case-insensitive membership, because container names come from XML filenames on one side + // and docker on the other, and those have disagreed on capitalisation before (my-prowlarr). + $remaining = []; + foreach ($deployed as $c) $remaining[strtolower($c)] = $c; + + $plan = []; + foreach ($fv3 as $f) { + if (!is_array($f)) continue; + $name = trim((string)($f['name'] ?? '')); + if ($name === '') continue; + // The owner's own fallback folders describe the owner's coverage of someone else. They + // are not part of the mirror's layout and copying them would be nonsense on that host. + if (preg_match('/-Fallback$/i', $name)) continue; + + $members = []; + foreach ((array)($f['containers'] ?? []) as $c) { + $lc = strtolower(trim((string)$c)); + if ($lc === '' || !isset($remaining[$lc])) continue; + if (in_array($lc, $fallbackOnly, true)) continue; // claimed by the fallback folder + $members[] = $remaining[$lc]; + unset($remaining[$lc]); + } + if ($members) $plan[] = ['name' => $name, 'icon' => (string)($f['icon'] ?? ''), 'containers' => $members]; + } + + // Whatever the owner files nowhere, plus everything explicitly marked fallback-only. + echo json_encode(['folders' => $plan, 'unfiled' => array_values($remaining)], JSON_UNESCAPED_SLASHES) . "\n"; + exit(0); +} + +// ── Import ─────────────────────────────────────────────────────────────────────────────────── +if (!isset($opts['import'])) { + fwrite(STDERR, "usage: mirror_folders.php --export --containers=… | --import\n"); + exit(2); +} + +$raw = stream_get_contents(STDIN); +$plan = json_decode((string)$raw, true); +if (!is_array($plan) || !isset($plan['folders']) || !is_array($plan['folders'])) { + fwrite(STDERR, "import: no usable plan on stdin\n"); + exit(1); +} + +$written = 0; +foreach ($plan['folders'] as $spec) { + if (!is_array($spec)) continue; + $name = trim((string)($spec['name'] ?? '')); + $cs = array_values(array_filter(array_map('trim', (array)($spec['containers'] ?? [])), 'strlen')); + if ($name === '' || !$cs) continue; + + $targetId = null; + foreach ($fv3 as $id => $f) { + if (is_array($f) && strcasecmp((string)($f['name'] ?? ''), $name) === 0) { $targetId = $id; break; } + } + $created = false; + if ($targetId === null) { + // folder.view3's own id shape: 20 chars of url-safe base64. + $targetId = substr(str_replace(['+', '/', '='], '', base64_encode(random_bytes(15))), 0, 20); + $fv3[$targetId] = ['name' => $name, 'icon' => '', 'settings' => ['', '', '1', '', '1', ''], + 'regex' => '', 'containers' => [], 'containerImages' => []]; + $created = true; + } + $fv3[$targetId]['name'] = $name; + // Only fill an icon that is missing — a mirror that has styled its own folder keeps its choice. + if (($spec['icon'] ?? '') !== '' && trim((string)($fv3[$targetId]['icon'] ?? '')) === '') { + $fv3[$targetId]['icon'] = (string)$spec['icon']; + } + $have = (array)($fv3[$targetId]['containers'] ?? []); + $lc = array_map('strtolower', array_map('strval', $have)); + $added = 0; + foreach ($cs as $c) { + if (in_array(strtolower($c), $lc, true)) continue; + $have[] = $c; $lc[] = strtolower($c); $added++; + } + $fv3[$targetId]['containers'] = array_values($have); + printf(" %-7s %-22s +%d (%s)\n", $created ? 'create' : 'update', $name, $added, implode(', ', $cs)); + $written++; +} + +// ── Take the filed containers back out of any "-Fallback" folder ───────────────────────────── +// A container that now sits in "Arrs Stack" must not also sit in "Gmer4Lfe-Fallback". The +// fallback folder answers "what is this host covering for the owner", and an earlier Step 12 +// dumped every deployed container into it — so it claimed the whole auth and arr stacks, which +// run here continuously and are not failover coverage at all. +// +// Only containers this plan just filed are removed. Anything the operator put in that folder by +// hand, or that the onboard filed there deliberately as fallback-only, is left alone. +$filed = []; +foreach ($plan['folders'] as $spec) { + foreach ((array)($spec['containers'] ?? []) as $c) $filed[strtolower(trim((string)$c))] = true; +} +$pruned = 0; +foreach ($fv3 as $id => $f) { + if (!is_array($f) || !preg_match('/-Fallback$/i', (string)($f['name'] ?? ''))) continue; + $keep = []; + foreach ((array)($f['containers'] ?? []) as $c) { + if (isset($filed[strtolower(trim((string)$c))])) { $pruned++; continue; } + $keep[] = $c; + } + if ($pruned) $fv3[$id]['containers'] = array_values($keep); +} +if ($pruned) printf(" pruned %d container(s) from -Fallback folder(s) — they are filed properly now\n", $pruned); + +if (!$written && !$pruned) { echo " nothing to write\n"; exit(0); } +if (isset($opts['dry-run'])) { echo " DRY RUN — nothing written\n"; exit(0); } + +$tmp = FV3_JSON . '.vv.tmp'; +if (file_put_contents($tmp, json_encode($fv3, JSON_UNESCAPED_SLASHES)) === false || !rename($tmp, FV3_JSON)) { + fwrite(STDERR, "failed to write " . FV3_JSON . "\n"); exit(1); +} +printf(" %d folder(s) written ✅\n", $written);