Files
Varaverk/Plugin/unraid/api/checklist.php
T
Gmer4Lfe 7ae510ed28 Point the WebGUI symlink at the actual install, and report this host's identity rather than HOST1's
The .plg hardcoded the flash path and runs every boot, so an appdata node served a stale copy that never receives pulls — fixes appeared to do nothing, indefinitely.
2026-08-17 11:52:33 -04:00

285 lines
15 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Setup checklist. Evaluates what this host still needs before it is fully configured —
// identity, conf, API and SSH keys, service discovery, media keys, and partnership state —
// and names the action that fixes each gap.
//
// OPERATIONAL MODEL
// Derived, never stored. Every item is computed from the conf files and the setup state on
// each request, so the checklist cannot go stale or disagree with reality. There is no
// "completed" flag anyone could tick.
//
// The item list is not fixed. Emby and Jellyfin checks appear only once their container is
// configured; the master.conf pull appears only on a partner host; partnership appears only
// once a HOST2 is named. A checklist that listed everything would tell a single-host
// install it was permanently incomplete.
//
// Items carry an action name, not a URL. The page maps create_key, ssh_setup, run_populate,
// pull_master and onboard onto the right endpoint or instruction — so this file describes
// what is wrong, and the UI owns how to fix it.
//
// DESIGN PRINCIPLES
// Each check answers the narrowest useful question.
// The SSH item tests that the configured path exists on disk, not merely that a path is
// set — a path set to a file that was never generated is the actual failure mode, and
// the detail text distinguishes the two cases.
//
// Auto-populate is satisfied by any one service.
// The check passes on the first arr key or media container found. Requiring all of them
// would leave the item permanently red on a host that legitimately runs only some.
//
// Partnership reports its phases separately.
// Phase 1 done with phase 2 outstanding is its own message, because the fix is to go
// finish onboarding on the other host — not to re-run anything here.
//
// State keys are read case-insensitively.
// Both HOST2_PHASE1_DONE and host2_phase1_done are accepted, because the state file has
// been written by both the shell layer and the PHP layer over its life.
//
// complete is derived from the items, not tracked.
// A single strict in_array(false, …) over the item results, so the summary can never
// disagree with the list it summarises.
//
// OPERATIONAL SAFEGUARDS
// Read-only. This endpoint diagnoses and never fixes — every remedy is a separate,
// explicitly invoked action. That separation is what makes it safe to poll.
//
// No input at all. There are no parameters, so there is nothing to validate and no way to
// ask about a host other than this one.
//
// An unidentified host degrades to a report rather than an error.
// vv_detect_host() returning 'unknown' is handled at every use — the host conf is not
// read, and the identity and host_conf items say so explicitly. That is the exact state
// a fresh install is in, and it is the checklist's job to describe it.
//
// Every conf read is defaulted.
// vv_read_conf_raw() returns empty for a missing file and every scalar lookup is
// trimmed with a ?? fallback, so a partial or absent conf yields items marked not-ok
// rather than a fatal that would blank the whole panel.
//
// Key presence is reported, key values never are.
// The API, SSH, Emby and Jellyfin items report only whether a value is set — and for
// SSH, the basename of the path. No credential is returned.
//
// Missing is reported as missing, never as fine.
// Every item defaults to ok:false and is only set true by a positive test. A check that
// cannot run reports the gap it could not rule out.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"ok":true,"complete":bool,"host_id":"host<n>|unknown",
// "items":[{"id","label","ok","detail","action"?}, …]}
// action is present and non-null only when there is a remedy the UI can invoke.
//
// DEPENDS ON
// include/config.php vv_detect_host(), vv_read_conf_raw(), vv_parse_conf_scalar(),
// vv_setup_state_read(), CONF_DIR
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$hostId = vv_detect_host();
$hostIdUp = strtoupper($hostId);
$master = vv_read_conf_raw('master.conf');
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
$items = [];
// ── Identity ──────────────────────────────────────────────────────────────────
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1);
$host1 = trim($m1[1] ?? '');
// "Server identity" is this server's, not HOST1's. It was hardcoded to read master.conf's HOST1
// and print "HOST1: <name>", so on the mirror the row that answers "which host am I" confidently
// named the other machine — and reported ok purely because the owner's slot was filled in, which
// says nothing about whether this host resolved to a slot at all.
$myName = '';
if ($hostId !== 'unknown') {
preg_match('/^\s*' . preg_quote($hostIdUp, '/') . '\s*=\s*"([^"]*)"/m', $master, $mSelf);
$myName = trim($mSelf[1] ?? '');
}
$items[] = [
'id' => 'identity',
'label' => 'Server identity',
'ok' => $hostId !== 'unknown' && !empty($myName),
'detail' => ($hostId !== 'unknown' && $myName)
? "$hostIdUp: $myName"
: 'This host does not match any HOST* entry in master.conf',
];
// ── Host conf ─────────────────────────────────────────────────────────────────
$confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf');
$items[] = [
'id' => 'host_conf',
'label' => 'Host configuration',
'ok' => $confExists,
'detail' => $confExists
? "$hostId.conf present"
: ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"),
];
// ── Unraid API key ─────────────────────────────────────────────────────────────
$apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY'));
$items[] = [
'id' => 'api_key',
'label' => 'Unraid API key',
'ok' => !empty($apiKey),
'detail' => $apiKey ? 'Key present' : 'Not set',
'action' => $apiKey ? null : 'create_key',
];
// ── SSH key ────────────────────────────────────────────────────────────────────
$sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY'));
$sshOk = $sshPath && file_exists($sshPath);
$items[] = [
'id' => 'ssh_key',
'label' => 'SSH key',
'ok' => $sshOk,
'detail' => $sshOk
? basename($sshPath)
: ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'),
'action' => $sshOk ? null : 'ssh_setup',
];
// ── Auto-populate (any service key or container detected) ──────────────────────
$populated = false;
foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) {
if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') {
$populated = true;
break;
}
}
$items[] = [
'id' => 'populated',
'label' => 'Auto-populate',
'ok' => $populated,
'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet',
'action' => $populated ? null : 'run_populate',
];
// ── Emby API key ───────────────────────────────────────────────────────────────
$embyContainer = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_EMBY_CONTAINER'));
if (!empty($embyContainer)) {
$embyKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_EMBY_API_KEY'));
$items[] = [
'id' => 'emby_key',
'label' => 'Emby API key',
'ok' => !empty($embyKey),
'detail' => $embyKey
? 'Key present'
: 'Not set — Emby Dashboard → API Keys → + New Key',
];
}
// ── Jellyfin API key ───────────────────────────────────────────────────────────
$jfContainer = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_JELLYFIN_CONTAINER'));
if (!empty($jfContainer)) {
$jfKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_JELLYFIN_API_KEY'));
$items[] = [
'id' => 'jellyfin_key',
'label' => 'Jellyfin API key',
'ok' => !empty($jfKey),
'detail' => $jfKey
? 'Key present'
: 'Not set — Jellyfin Dashboard → Administration → API Keys',
];
}
// ── 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 <THIS_HOST>_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']);
// 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 || $pushed,
'detail' => $pulled
? "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,
];
}
// ── Partnership (only if a partner is configured) ──────────────────────────────
preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2);
$host2 = trim($m2[1] ?? '');
if (!empty($host2)) {
$state = vv_setup_state_read();
$p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']);
$p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']);
$items[] = [
'id' => 'partnership',
'label' => 'Partnership',
'ok' => $p1done && $p2done,
'detail' => ($p1done && $p2done)
? "Active with $host2"
: ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"),
'action' => (!$p1done) ? 'onboard' : null,
];
}
// ── Blocking vs deferrable ────────────────────────────────────────────────────
// Setup used to be complete only when every item was green, which made a media-server API key a
// gate on finishing onboarding. A mirror that runs no Emby, or runs one the operator has not got
// round to configuring, could never reach "complete" — and the checklist is what tells them
// whether they are done.
//
// Blocking items are the ones the mesh genuinely cannot work without: who this host is, its conf,
// its SSH key, the owner's master.conf, and the partnership itself. Everything else improves the
// install without being load-bearing, and can be dismissed with a recorded decision.
//
// Deferring is per item and reversible, stored in the setup state so it survives a reload. The
// item still shows — amber, "deferred" — rather than disappearing, because a dismissed item is a
// decision to revisit, not a thing that stopped being true.
$deferrable = ['api_key' => true, 'populated' => true, 'emby_key' => true, 'jellyfin_key' => true];
$state = vv_setup_state_read();
foreach ($items as &$item) {
$canDefer = !empty($deferrable[$item['id']]);
$item['blocking'] = !$canDefer;
$item['deferred'] = $canDefer && !$item['ok']
&& !empty($state['DEFER_' . strtoupper($item['id'])]);
if ($item['deferred']) {
$item['detail'] = ($item['detail'] ?? '') . ' — deferred';
$item['action'] = 'undefer';
} elseif ($canDefer && !$item['ok']) {
// Keep the item's real action as the primary; the UI offers defer alongside it.
$item['can_defer'] = true;
}
}
unset($item);
// Complete when every blocking item is green and every deferrable one is green or dismissed.
$allOk = true;
foreach ($items as $i) {
if ($i['ok']) continue;
if ($i['blocking'] || empty($i['deferred'])) { $allOk = false; break; }
}
echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);