263 lines
14 KiB
PHP
263 lines
14 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Reproduce the owner's docker folder layout on the mirror, for the containers the owner
|
|
// actually deployed there. Sonarr lands in "Arrs Stack", NginxProxyManager and Lldap in
|
|
// "Networking", the databases in "Databases" — the same shelves they sit on at home.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Two modes, one file, piped over SSH:
|
|
//
|
|
// --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 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.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// The owner's layout is the source, and only the deployed subset travels.
|
|
// "Arrs Stack" holds ten containers on the owner and five of them were deployed, so the
|
|
// mirror's "Arrs Stack" gets those five. Sending the whole list would name containers the
|
|
// mirror does not have, and folder.view3 renders those as empty tiles.
|
|
//
|
|
// One folder per owner folder — not one folder for everything.
|
|
// Step 12 used to put all thirteen containers into "<Owner>-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 "<OwnerShort>-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.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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 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 "<Owner>-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 "<Owner>-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']);
|
|
|
|
$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 ─────────────────────────────────────────────────────────────────────────────────────
|
|
// 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 = [];
|
|
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');
|
|
$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);
|
|
}
|
|
|
|
// 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 ($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++;
|
|
}
|
|
|
|
// ── 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 ($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);
|
|
}
|
|
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);
|
|
}
|
|
} else {
|
|
echo " folder.view3 not installed — Varaverk's own layout only\n";
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
[$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);
|
|
}
|
|
}
|
|
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 ($dryRun) echo " DRY RUN — nothing written\n";
|
|
if (!$fvWritten && !$vvWritten) echo " nothing to write\n";
|