Files
Varaverk/Plugin/unraid/api/checklist.php
T
Gmer4Lfe 4f95cc6d13 Deliver master.conf to a node that has no Varaverk on it yet, which is the node that needs it
Phase 1 exists to hand the partner its identity before the install, but the push refused any
host without varaverk.cfg — so the conf could only ever reach a node that no longer needed it
to be told who it was. It now resolves the remote's conf directory across both install layouts
and creates the internal one when neither exists.
2026-08-16 16:14:23 -04:00

238 lines
12 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] ?? '');
$items[] = [
'id' => 'identity',
'label' => 'Server identity',
'ok' => !empty($host1),
'detail' => $host1 ? "HOST1: $host1" : 'HOST1 blank 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,
];
}
$allOk = !in_array(false, array_column($items, 'ok'), true);
echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);