diff --git a/Partnership/partnership_onboard.sh b/Partnership/partnership_onboard.sh index c44cc8b..1f2e949 100755 --- a/Partnership/partnership_onboard.sh +++ b/Partnership/partnership_onboard.sh @@ -535,17 +535,22 @@ if [[ "$PHASE1_ONLY" == true ]]; then echo " Duration: $(format_duration $(( END - START )))" echo "" echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin." - # The push needs a Varaverk install to land in — varaverk.cfg and Configurations/ have to - # exist on the far side. Before the plugin is installed there is nowhere to put the file, so - # a skip here is the expected result of running Phase 1 first, not a fault to chase. + # The push no longer waits for a Varaverk install on the far side. It resolves the partner's + # conf directory across both layouts and creates the internal one if neither exists, so the + # conf arrives BEFORE the plugin — which is the order that makes it useful. The .plg only + # seeds master.conf from the template when none is present, so what lands here survives the + # install and the wizard reads its identity straight out of it. if [[ "$CONF_PUSH_OK" == true ]]; then - echo " master.conf is on $MIRROR — the wizard will find it and take the partner path." + echo " master.conf is on $MIRROR — the wizard will find it and take the partner path," + echo " already knowing $MY_ID and $MIRROR_ID. If the operator picks flash storage there," + echo " storage_migrate.sh moves the conf to appdata with the rest of the install." else - echo " master.conf was NOT delivered — $MIRROR has no Varaverk install to receive it yet." - echo " That is expected at this stage. Once the plugin is installed there, either:" - echo " • re-run: bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh" + echo " master.conf was NOT delivered. Phase 1 seeds it into a bare host, so this is a" + echo " real failure, not the pre-install state — check SSH and that /boot is writable" + echo " on $MIRROR. Retry with:" + echo " • bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh" echo " • or push from Scheduler → master.conf → Save Conf" - echo " • or, from HOST2's Setup tab, use 'Pull from HOST1' on the master.conf row" + echo " • or, once the plugin is installed, 'Pull from HOST1' on HOST2's Setup tab" fi echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here." echo "━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/Plugin/unraid/adapter.sh b/Plugin/unraid/adapter.sh index 66d1f86..a7eafb2 100755 --- a/Plugin/unraid/adapter.sh +++ b/Plugin/unraid/adapter.sh @@ -412,7 +412,12 @@ require_once '/usr/local/emhttp/plugins/varaverk/include/config.php'; if (empty(\$results)) { echo 'no remote hosts'; exit(0); } \$failed = 0; foreach (\$results as \$r) { - echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL; + // 'seed' means the partner had no master.conf anywhere — say so, because that is the case + // where the push carried the mesh's identity to a node that could not have known it. + \$what = (\$r['mode'] ?? '') === 'seed' + ? 'seeded → ' . (\$r['path'] ?? '') + : 'pushed → ' . (\$r['path'] ?? ''); + echo \$r['host'] . ': ' . (\$r['ok'] ? \$what : 'FAILED — ' . \$r['error']) . PHP_EOL; if (!\$r['ok']) \$failed++; } exit(\$failed > 0 ? 1 : 0); diff --git a/Plugin/unraid/api/checklist.php b/Plugin/unraid/api/checklist.php index 5c3d4f9..0939587 100644 --- a/Plugin/unraid/api/checklist.php +++ b/Plugin/unraid/api/checklist.php @@ -176,18 +176,41 @@ if (!empty($jfContainer)) { ]; } -// ── master.conf pull (partner servers only) ─────────────────────────────────────────────────── +// ── master.conf delivery (partner servers only) ─────────────────────────────────────────────── +// +// The question is whether this host HAS the owner's master.conf, not whether it went and +// fetched one. There are two ways it arrives and only one of them used to count: +// +// pull — this host ran 'Pull from HOST1', which sets master_conf_pulled in the setup state. +// push — HOST1 sent it during Phase 1 of onboarding, before this host even had Varaverk +// installed. Nothing on this side runs, so no flag is written here. +// +// Keying solely off the pull flag meant a node whose conf had been seeded by Phase 1 — +// populated, correct, naming both hosts — was told "Not yet pulled from HOST1" and offered a +// button to fetch what it already had. +// +// The push leaves its own evidence: Phase 1 ends by writing _PHASE1_DONE into the +// owner's setup state and pushing that file here, so the flag can only be present on this +// machine because the owner completed a push AT this machine. That is the discriminator. +// +// A populated HOST1 line is deliberately NOT the test. The wizard's manual partner path writes +// HOST1 and HOST2 into the LOCAL template conf from what the operator typed, which would make +// a template look delivered and hide the one button that fixes it. if ($hostId !== 'host1' && $hostId !== 'unknown') { - $state = vv_setup_state_read(); - $pulled = !empty($state['master_conf_pulled']); + $state = vv_setup_state_read(); + $pulled = !empty($state['master_conf_pulled']); + // Both spellings, for the same reason the partnership item below accepts both. + $pushed = !empty($state[$hostIdUp . '_PHASE1_DONE']) || !empty($state[$hostId . '_phase1_done']); $items[] = [ 'id' => 'master_conf', 'label' => 'master.conf', - 'ok' => $pulled, + 'ok' => $pulled || $pushed, 'detail' => $pulled - ? 'Synced from HOST1' - : ($host1 ? "Not yet pulled from $host1" : 'HOST1 hostname not set in master.conf'), - 'action' => (!$pulled && $host1) ? 'pull_master' : null, + ? "Pulled from $host1" + : ($pushed + ? "Seeded by $host1 — identity read from it" + : ($host1 ? "Not yet pulled from $host1" : 'HOST1 hostname not set in master.conf')), + 'action' => (!$pulled && !$pushed && $host1) ? 'pull_master' : null, ]; } diff --git a/Plugin/unraid/include/config.php b/Plugin/unraid/include/config.php index 799e80b..50a8d5d 100644 --- a/Plugin/unraid/include/config.php +++ b/Plugin/unraid/include/config.php @@ -162,6 +162,20 @@ unset($_vv_cfg); define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db'); +// ── The two install layouts ─────────────────────────────────────────────────── +// The same pair storage_migrate.sh moves an installation between, and the same pair +// HOST*_STORAGE_MODE_INTERNAL selects: true → internal, false → flash. They are stated here +// because vv_push_master_conf() has to reason about a *remote* host's layout, where SCRIPTS_DIR +// describes this host and says nothing about the partner's — and about a partner that has no +// layout yet because Varaverk is not installed on it. +// +// Naming follows the conf, which names the boot device rather than the destination: "internal" +// is a host that boots from internal NVMe/SSD and can therefore keep everything on /boot; +// "flash" is a host booting from a USB stick, which must not take the writes, so its data lives +// in appdata instead. +define('VV_DIR_INTERNAL', '/boot/config/plugins/varaverk'); +define('VV_DIR_FLASH', '/mnt/user/appdata/Varaverk'); + // ── Cache roots ─────────────────────────────────────────────────────────────── // Read from master.conf so this layer and load_config.sh resolve the same paths from the same // line. They used to be literals in three PHP files, restating what load_config.sh already said, @@ -296,18 +310,32 @@ function vv_push_master_conf(): array { continue; } - // Single SSH call: get remote SCRIPTS_DIR and verify the plugin is installed and - // Configurations/ exists. Any missing piece means the remote isn't ready — skip rather - // than push blind. + // Single SSH call decides which directory on the remote receives the conf. Two layouts + // exist and a third state — not installed yet — is the one this has to serve first. // - // It deliberately does NOT require master.conf to already be there. Requiring it meant - // this could only ever *update* a conf, never deliver one — and delivering is what Step 10 - // of partnership_onboard.sh exists to do. Every conf is gitignored, so a freshly installed - // node has varaverk.cfg and Configurations/ (README.md is tracked, so the clone creates the - // dir) but no master.conf, and the push skipped exactly the node it was meant to seed. + // 1. varaverk.cfg names SCRIPTS_DIR and that install has a Configurations/ — the + // remote has told us where it lives, and it outranks anything found by looking. + // A node migrated to appdata can still have a stale master.conf on /boot; picking + // the file over the declaration would write to the copy nothing reads. + // 2. no varaverk.cfg — look for an existing master.conf in the internal layout, then + // the flash layout (the same two roots storage_migrate.sh moves between). A + // half-installed or part-migrated node is found this way. + // 3. neither — SEED. mkdir the internal path and deliver there. // - // The two surviving checks still refuse a bare or half-installed host, so nothing is - // pushed into a directory that is not a Varaverk install. + // Case 3 is the point of the whole function during Phase 1 of onboarding. The partner + // has no Varaverk at all yet, and the conf is what tells its installer who HOST1 and + // HOST2 are, so the wizard can skip the identity questions it would otherwise ask about + // a mesh it is already a member of. Refusing to push until the plugin was installed + // made that impossible: the information had to arrive first to be useful. + // + // Internal is the seed target because /boot is mounted before the array is and the .plg + // installs there unconditionally — appdata may not exist yet on a node whose array has + // never started. If the operator then chooses flash in the wizard, storage_migrate.sh + // carries the conf across with the rest of the install. + // + // The .plg only seeds master.conf from the template when none is present, so a conf + // delivered ahead of the install survives it, as does `git reset --hard` — every conf + // is gitignored and therefore untracked. // // NOTE: this sends the owner's master.conf as-is, credentials included — TAILSCALE_API_KEY // and WEBHOOK_SECRET among them. That is the accepted trade for one shared config across @@ -318,27 +346,40 @@ function vv_push_master_conf(): array { // of on the remote host. escapeshellarg() keeps it opaque until the remote shell runs it. $sshBase = 'ssh -i ' . escapeshellarg($sshKey) . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip; - $remoteCmd = 'cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)' - . ' && sd=$(echo "$cfg" | grep -oP \'(?<=SCRIPTS_DIR=")[^"]+\')' - . ' && test -d "${sd}/Configurations"' - . ' && echo "$sd"'; - $probe = trim(shell_exec($sshBase . ' ' . escapeshellarg($remoteCmd)) ?: ''); + $remoteCmd = 'sd=$(grep -oP \'(?<=SCRIPTS_DIR=")[^"]+\' /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null); ' + . 'd=""; ' + . '[ -n "$sd" ] && [ -d "$sd/Configurations" ] && d="$sd/Configurations"; ' + . '[ -z "$d" ] && [ -f "' . VV_DIR_INTERNAL . '/Configurations/master.conf" ] && d="' . VV_DIR_INTERNAL . '/Configurations"; ' + . '[ -z "$d" ] && [ -f "' . VV_DIR_FLASH . '/Configurations/master.conf" ] && d="' . VV_DIR_FLASH . '/Configurations"; ' + . '[ -z "$d" ] && { mkdir -p "' . VV_DIR_INTERNAL . '/Configurations" || exit 1; d="' . VV_DIR_INTERNAL . '/Configurations"; }; ' + . 'if [ -f "$d/master.conf" ]; then echo "$d|update"; else echo "$d|seed"; fi'; + // Last non-empty line only. Anything the remote's login shell prints of its own accord — + // a profile banner, an MOTD echoed to stdout — arrives ahead of the answer, and taking + // the whole output would build an scp destination out of it. + $probeRaw = trim(shell_exec($sshBase . ' ' . escapeshellarg($remoteCmd)) ?: ''); + $probeLines = array_filter(array_map('trim', explode("\n", $probeRaw)), fn($l) => $l !== ''); + $probe = $probeLines ? end($probeLines) : ''; - if ($probe === '') { + if ($probe === '' || !str_contains($probe, '|')) { $results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, - 'error' => 'plugin not installed or Configurations/ missing — skipped']; + 'error' => 'no usable conf directory on the remote — SSH failed or /boot is not writable']; continue; } - $remoteConf = rtrim($probe, '/') . '/Configurations'; + [$remoteConf, $mode] = explode('|', $probe, 2); $dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf'); $cmd = 'scp -i ' . escapeshellarg($sshKey) . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no' . ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1'; + // exec() APPENDS to its output array. Left uncleared, a failure on one host would be + // reported again as part of the next host's error. + $out = []; exec($cmd, $out, $rc); $results[] = [ 'host' => $hostKey, 'ok' => $rc === 0, + 'mode' => $mode, // 'seed' = delivered where there was nothing, 'update' = replaced + 'path' => $remoteConf, 'error' => $rc !== 0 ? implode('; ', $out) : '', ]; } diff --git a/Plugin/varaverk.plg b/Plugin/varaverk.plg index 908e35f..6df8a81 100644 --- a/Plugin/varaverk.plg +++ b/Plugin/varaverk.plg @@ -220,6 +220,16 @@ else fi # Seed master.conf from template if absent. +# +# "If absent" is load-bearing, not defensive. Phase 1 of partnership onboarding pushes the +# owner's master.conf to this host BEFORE the plugin is installed — it lands in exactly this +# directory, which the push creates. That conf already names HOST1 and HOST2, which is how the +# setup wizard knows this machine's identity without asking for it. Overwriting it with the +# template here would replace a populated conf with one whose HOSTn slots are all empty and +# throw the wizard back to the questions the push exists to answer. +# +# The clone above cannot destroy it either: every conf is gitignored, so it is untracked and +# `git reset --hard` leaves it alone. mkdir -p "$CONF_DIR" if [[ ! -f "$CONF_DIR/master.conf" && -f "$DEPLOY_DIR/master.conf.template" ]]; then cp "$DEPLOY_DIR/master.conf.template" "$CONF_DIR/master.conf"