From f5d6edd9d0b95ebc0d09931cbfcf3f18caa775e6 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Fri, 21 Aug 2026 21:50:22 -0400 Subject: [PATCH] The mirrored folder layout never reached the container card, which reads Varaverk's own store and not folder.view3's --- Plugin/unraid/Tools/mirror_folders.php | 198 ++++++++++++++++--------- 1 file changed, 130 insertions(+), 68 deletions(-) diff --git a/Plugin/unraid/Tools/mirror_folders.php b/Plugin/unraid/Tools/mirror_folders.php index 81e6042..0d1543f 100644 --- a/Plugin/unraid/Tools/mirror_folders.php +++ b/Plugin/unraid/Tools/mirror_folders.php @@ -11,7 +11,8 @@ // --export --containers=a,b,c on the OWNER. Reads folder.view3's docker.json, intersects // each folder with the deployed list, prints a JSON plan. // --import on the MIRROR. Reads that plan on stdin and upserts each -// folder by name into the mirror's own docker.json. +// folder by name into BOTH the mirror's folder.view3 docker.json +// and Varaverk's own docker_folders.json. // // Onboard Step 12 runs the pair. Nothing is assumed about the mirror's layout: folders it // already has are matched by name and extended, never duplicated or replaced. @@ -39,8 +40,13 @@ // // 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. +// Both stores or the layout is only half real. The Monitor container card reads Varaverk's +// docker_folders.json, never folder.view3's, and include/docker.php seeds it exactly once — +// when the file is absent. A mirror onboarded before this step existed had already been +// seeded, so writing only folder.view3 left the card showing the old single lump forever. +// +// Absent plugin is no longer a skip on import. folder.view3 is optional; Varaverk's own +// store is not, and it is the one the card reads. Export still needs the plugin and says so. // // 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. @@ -58,11 +64,21 @@ // import: one line per folder written, then a count // // DEPENDS ON -// /boot/config/plugins/folder.view3/docker.json the layout both sides read +// /boot/config/plugins/folder.view3/docker.json the third-party layout, optional on import +// SCRIPTS_DIR/docker_folders.json Varaverk's own layout — what the card reads // Tools/fallback_folder.php still owns the "-Fallback" folder // ═══════════════════════════════════════════════════════════════════════════════════════════════ +$pluginDir = dirname(__DIR__); +require_once $pluginDir . '/include/config.php'; + define('FV3_JSON', '/boot/config/plugins/folder.view3/docker.json'); +// Varaverk's own store. Written as well as folder.view3's, because the Monitor container card +// reads THIS file and nothing else — include/docker.php imports folder.view3 exactly once, when +// this file does not yet exist. On a mirror onboarded before the layout step existed, that +// bootstrap had already happened, so the card went on showing one lump "-Fallback" +// holding the entire auth and arr stacks while folder.view3 next to it showed the real shelves. +define('VV_STORE', SCRIPTS_DIR . '/docker_folders.json'); $opts = getopt('', ['export', 'import', 'containers:', 'fallback-only:', 'dry-run']); @@ -72,15 +88,18 @@ $listArg = function (string $k) use ($opts): array { }; // ── 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"; +// folder.view3 is optional on the mirror. Export needs it and has nothing to say without it; +// import does not — Varaverk's own store is the one the card reads, and it is always writable. +$haveFv3 = file_exists(FV3_JSON); +if (!$haveFv3 && isset($opts['export'])) { + echo json_encode(['folders' => [], 'unfiled' => $listArg('containers')]) . "\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); } - +$fv3 = []; +if ($haveFv3) { + $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'); @@ -117,6 +136,7 @@ if (isset($opts['export'])) { exit(0); } + // ── Import ─────────────────────────────────────────────────────────────────────────────────── if (!isset($opts['import'])) { fwrite(STDERR, "usage: mirror_folders.php --export --containers=… | --import\n"); @@ -130,71 +150,113 @@ if (!is_array($plan) || !isset($plan['folders']) || !is_array($plan['folders'])) 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; +// Applied to both stores, so they cannot drift apart the way they already did once. Takes a +// store, returns the store with the plan folded in plus what changed — no writing, no printing, +// because the two callers report differently. +$applyPlan = function (array $store) use ($plan): array { + $written = 0; + $lines = []; + 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; } + $targetId = null; + foreach ($store 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); + $store[$targetId] = ['name' => $name, 'icon' => '', 'settings' => ['', '', '1', '', '1', ''], + 'regex' => '', 'containers' => [], 'containerImages' => []]; + $created = true; + } + $store[$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)($store[$targetId]['icon'] ?? '')) === '') { + $store[$targetId]['icon'] = (string)$spec['icon']; + } + $have = (array)($store[$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++; + } + $store[$targetId]['containers'] = array_values($have); + $lines[] = sprintf(" %-7s %-22s +%d (%s)", $created ? 'create' : 'update', $name, $added, implode(', ', $cs)); + $written++; } - $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; + + // ── 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; } - $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']; + $pruned = 0; + foreach ($store 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; + } + $store[$id]['containers'] = array_values($keep); } - $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++; + return [$store, $written, $pruned, $lines]; +}; + +$dryRun = isset($opts['dry-run']); + +// ── folder.view3's file ────────────────────────────────────────────────────────────────────── +$fvWritten = 0; +if ($haveFv3) { + [$fv3, $fvWritten, $fvPruned, $fvLines] = $applyPlan($fv3); + foreach ($fvLines as $l) echo $l . "\n"; + if ($fvPruned) printf(" pruned %d container(s) from -Fallback folder(s) — they are filed properly now\n", $fvPruned); + if (($fvWritten || $fvPruned) && !$dryRun) { + $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 to folder.view3 ✅\n", $fvWritten); } - $fv3[$targetId]['containers'] = array_values($have); - printf(" %-7s %-22s +%d (%s)\n", $created ? 'create' : 'update', $name, $added, implode(', ', $cs)); - $written++; +} else { + echo " folder.view3 not installed — Varaverk's own layout only\n"; } -// ── 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; +// ── Varaverk's own store ───────────────────────────────────────────────────────────────────── +// Seeded from folder.view3 when it does not exist yet, which is the same bootstrap +// include/docker.php performs — done here too so the very first import lands on a real layout +// rather than an empty file. +$vvStore = []; +if (file_exists(VV_STORE)) { + $vvStore = json_decode((string)@file_get_contents(VV_STORE), true); + if (!is_array($vvStore)) $vvStore = []; +} elseif ($haveFv3) { + $vvStore = $fv3; } -$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; +[$vvStore, $vvWritten, $vvPruned, ] = $applyPlan($vvStore); +if (($vvWritten || $vvPruned) && !$dryRun) { + @mkdir(dirname(VV_STORE), 0755, true); + $tmp = VV_STORE . '.vv.tmp'; + if (file_put_contents($tmp, json_encode($vvStore, JSON_UNESCAPED_SLASHES)) === false || !rename($tmp, VV_STORE)) { + fwrite(STDERR, "failed to write " . VV_STORE . "\n"); exit(1); } - 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); +printf(" %d folder(s) %s Varaverk's layout%s\n", $vvWritten, + $dryRun ? 'would be written to' : 'written to', + $vvPruned ? sprintf(" (%d unfiled from -Fallback)", $vvPruned) : ''); -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); +if ($dryRun) echo " DRY RUN — nothing written\n"; +if (!$fvWritten && !$vvWritten) echo " nothing to write\n";