234 lines
13 KiB
PHP
234 lines
13 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Checks how the settings UI will draw every conf field, and proves the inference rules against
|
|
// the conventions the conf files actually use. Two modes in one tool: fixed assertions over
|
|
// synthetic snippets, and a report of what this host's real master.conf infers to.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Hand-run, reads only, writes nothing — the same shape as Tools/ai_log_check.sh. Run it after
|
|
// touching _vv_conf_widget(), after adding a conf convention, or when a setting draws as the
|
|
// wrong control and the question is whether the rule or the comment above it is at fault.
|
|
//
|
|
// php Tools/conf_widget_check.php assertions, then the live summary
|
|
// php Tools/conf_widget_check.php --list every live field and its inferred control
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Assertions run against snippets, never against the live conf.
|
|
// The live conf is the thing being described, so it cannot also be the thing that proves
|
|
// the description right — an inference rule that silently stopped matching would keep
|
|
// passing as the conf drifted to suit it. The snippets are frozen copies of each
|
|
// convention as written, so a rule change that breaks one shows up here rather than as a
|
|
// wrong control on a page.
|
|
//
|
|
// An inference is a drawing decision, not a validation.
|
|
// Consistent with confform.php, none of this validates. A number field carrying min and
|
|
// max is a courtesy to whoever is typing, not a promise the value is sensible — the
|
|
// consuming script still owns that question.
|
|
//
|
|
// The live pass reports, it does not assert.
|
|
// What this host's master.conf infers to is a description of that file, not a verdict on
|
|
// it. Turning the live summary into pass/fail would make an unusual but legitimate
|
|
// setting look like a defect.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Read-only. Parses conf and reports; writes no conf, no store and no page.
|
|
//
|
|
// Exits non-zero only when a snippet assertion fails, so it can gate a commit without a real
|
|
// conf's contents ever being able to break the build.
|
|
//
|
|
// Never renders. It reports which control would be drawn; the drawing stays in confform.php,
|
|
// so this cannot disagree with the page by construction.
|
|
//
|
|
// RUNTIME MODES
|
|
// php Tools/conf_widget_check.php assertions, then the live summary
|
|
// php Tools/conf_widget_check.php --list every live field and its inferred control
|
|
//
|
|
// Hand-run. Not scheduled and in no orchestrator — run it after touching _vv_conf_widget(),
|
|
// after adding a conf convention, or when a setting draws as the wrong control.
|
|
//
|
|
// DEPENDS ON
|
|
// include/confform.php _vv_conf_parse_field_range(), vv_conf_key_is_secret()
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
require_once __DIR__ . '/../include/confform.php';
|
|
|
|
$pass = 0; $fail = 0; $failed = [];
|
|
function check(string $name, $got, $want): void {
|
|
global $pass, $fail, $failed;
|
|
if ($got === $want) { $pass++; return; }
|
|
$fail++;
|
|
$failed[] = sprintf("%-42s got %s want %s", $name, json_encode($got), json_encode($want));
|
|
}
|
|
|
|
// Parse one snippet and return its fields keyed by name.
|
|
function vv_wc_fields(string $snippet): array {
|
|
$lines = explode("\n", $snippet);
|
|
$out = [];
|
|
foreach (_vv_conf_parse_field_range($lines, 0, count($lines), 'master.conf') as $f) {
|
|
$out[$f['key']] = $f;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// ── Booleans and numbers ─────────────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
AI_ENABLED=true
|
|
AI_OFF=false
|
|
AI_QUOTED_BOOL="true"
|
|
AI_CONNECT_TIMEOUT=5 # seconds — probe when resolving which node has Ollama
|
|
AI_CHAT_HISTORY_MAX=10 # conversations kept — clamped to 1-50
|
|
AI_PLAIN_NUMBER=32
|
|
AI_NEGATIVE=-1
|
|
CONF);
|
|
check('bool true', $f['AI_ENABLED']['widget'], 'bool');
|
|
check('bool false', $f['AI_OFF']['widget'], 'bool');
|
|
check('bool quoted', $f['AI_QUOTED_BOOL']['widget'], 'bool');
|
|
check('int', $f['AI_CONNECT_TIMEOUT']['widget'], 'int');
|
|
check('int unit', $f['AI_CONNECT_TIMEOUT']['unit'] ?? null, 'seconds');
|
|
check('int without a unit', $f['AI_PLAIN_NUMBER']['unit'] ?? null, null);
|
|
check('int range min', $f['AI_CHAT_HISTORY_MAX']['min'] ?? null, 1);
|
|
check('int range max', $f['AI_CHAT_HISTORY_MAX']['max'] ?? null, 50);
|
|
check('int range keeps unit', $f['AI_CHAT_HISTORY_MAX']['unit'] ?? null, 'conversations');
|
|
check('negative int', $f['AI_NEGATIVE']['widget'], 'int');
|
|
|
|
// ── File modes must never become spinners ────────────────────────────────────────────────────
|
|
// A spinner invites arrowing 755 to 756, and a number input normalises a leading zero away.
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
PERMISSIONS_DIR_MODE="755" # directories
|
|
PERMISSIONS_FILE_MODE="664" # files
|
|
TRANSCODE_CHMOD="755"
|
|
LEADING_ZERO_MODE="0755"
|
|
TRANSCODE_MANAGER_MODE="smart"
|
|
SOME_TIMEOUT_MODE=30 # seconds
|
|
CONF);
|
|
check('dir mode is text', $f['PERMISSIONS_DIR_MODE']['widget'], 'text');
|
|
check('file mode is text', $f['PERMISSIONS_FILE_MODE']['widget'], 'text');
|
|
check('chmod is text', $f['TRANSCODE_CHMOD']['widget'], 'text');
|
|
check('octal mode is text', $f['LEADING_ZERO_MODE']['widget'], 'text');
|
|
check('worded mode is text', $f['TRANSCODE_MANAGER_MODE']['widget'], 'text');
|
|
// The guard is about octal, not about the word MODE — a real duration is still a number.
|
|
check('non-octal MODE is int', $f['SOME_TIMEOUT_MODE']['widget'], 'int');
|
|
|
|
// ── Enum, quoted-line convention ─────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
# Controls behaviour when versions differ.
|
|
# "warn" — log warning and continue
|
|
# "abort" — refuse to continue (strict)
|
|
UNRAID_VERSION_MISMATCH_ACTION="warn"
|
|
CONF);
|
|
check('quoted-line enum', $f['UNRAID_VERSION_MISMATCH_ACTION']['widget'], 'enum');
|
|
check('enum choices', array_column($f['UNRAID_VERSION_MISMATCH_ACTION']['choices'], 'value'),
|
|
['warn', 'abort']);
|
|
check('enum keeps its hints', $f['UNRAID_VERSION_MISMATCH_ACTION']['choices'][1]['hint'],
|
|
'refuse to continue (strict)');
|
|
|
|
// One quoted comment line is a quotation, not a choice list of one.
|
|
$f = vv_wc_fields("# \"off\" means something here\n SOMETHING=\"off\"");
|
|
check('lone quoted line', $f['SOMETHING']['widget'], 'text');
|
|
|
|
// A value outside its own documented list is surfaced, never dropped to tidy the list — dropping
|
|
// it would change the setting the moment the form saved.
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
# "warn" — log and continue
|
|
# "abort" — refuse
|
|
DRIFTED="something_else"
|
|
CONF);
|
|
check('drifted value stays enum', $f['DRIFTED']['widget'], 'enum');
|
|
check('drifted value offered', $f['DRIFTED']['choices'][0]['value'], 'something_else');
|
|
check('drifted list intact', count($f['DRIFTED']['choices']), 3);
|
|
|
|
// ── Enum, inline pipe convention ─────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
DIGEST_PROFILE="weekly" # always | smart | weekly
|
|
SONARR_MONITOR="all" # monitor mode on add: all | future | first | none
|
|
PROSE_WITH_PIPE="x" # pipe the output through grep | sort
|
|
CONF);
|
|
check('inline enum', $f['DIGEST_PROFILE']['widget'], 'enum');
|
|
check('inline enum choices', array_column($f['DIGEST_PROFILE']['choices'], 'value'),
|
|
['always', 'smart', 'weekly']);
|
|
check('inline enum after colon',$f['SONARR_MONITOR']['widget'], 'enum');
|
|
// The value is not among them, so this is prose that happens to contain a pipe.
|
|
check('prose with a pipe', $f['PROSE_WITH_PIPE']['widget'], 'text');
|
|
|
|
// ── Secrets ──────────────────────────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
HOST1_NPM_PASS="hunter2"
|
|
HOST1_SONARR_API_KEY="abcdef"
|
|
WEBHOOK_SECRET="s3cr3t"
|
|
AI_TOKEN_DB="${AI_DATA_DIR}/ai_token_history.db"
|
|
AI_TOKEN_RETAIN_ROWS=20000
|
|
AI_TOKEN_SYNC_ENABLED=true
|
|
CONF);
|
|
check('password masked', $f['HOST1_NPM_PASS']['widget'], 'secret');
|
|
check('api key masked', $f['HOST1_SONARR_API_KEY']['widget'], 'secret');
|
|
check('secret masked', $f['WEBHOOK_SECRET']['widget'], 'secret');
|
|
// LLM token accounting is not a credential. Masking these was the bug this exception fixes.
|
|
check('AI_TOKEN_DB visible', $f['AI_TOKEN_DB']['widget'], 'path');
|
|
check('AI_TOKEN_RETAIN visible', $f['AI_TOKEN_RETAIN_ROWS']['widget'], 'int');
|
|
check('AI_TOKEN_SYNC visible', $f['AI_TOKEN_SYNC_ENABLED']['widget'], 'bool');
|
|
|
|
// ── Paths, text, arrays ──────────────────────────────────────────────────────────────────────
|
|
$f = vv_wc_fields(<<<'CONF'
|
|
AI_INDEX_DB="${AI_DATA_DIR}/ai_index.db"
|
|
SOME_ABS="/mnt/user/appdata"
|
|
AI_MODEL="qwen3:14b"
|
|
AI_PROFILES="*"
|
|
AI_CONF_WRITE_KEYS=()
|
|
LIST_MULTI=(
|
|
one
|
|
two
|
|
)
|
|
CONF);
|
|
check('templated path', $f['AI_INDEX_DB']['widget'], 'path');
|
|
check('absolute path', $f['SOME_ABS']['widget'], 'path');
|
|
check('plain text', $f['AI_MODEL']['widget'], 'text');
|
|
check('wildcard text', $f['AI_PROFILES']['widget'], 'text');
|
|
check('single-line array', $f['AI_CONF_WRITE_KEYS']['widget'], 'lines');
|
|
check('multi-line array', $f['LIST_MULTI']['widget'], 'lines');
|
|
// The scratch field the parser uses to reach the comment lines must not ride out to the browser.
|
|
check('no _lines leak', isset($f['AI_MODEL']['_lines']), false);
|
|
|
|
printf("assertions: %d passed, %d failed\n", $pass, $fail);
|
|
foreach ($failed as $l) echo " FAIL $l\n";
|
|
|
|
// ── What this host's conf actually infers to ─────────────────────────────────────────────────
|
|
$path = CONF_DIR . '/master.conf';
|
|
if (!is_readable($path)) {
|
|
echo "\nmaster.conf not readable at $path — skipping the live summary\n";
|
|
exit($fail ? 1 : 0);
|
|
}
|
|
$lines = explode("\n", (string) file_get_contents($path));
|
|
$hdrs = [];
|
|
foreach ($lines as $i => $l) {
|
|
if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $l, $m)) $hdrs[] = [$i, trim($m[1])];
|
|
}
|
|
|
|
$list = in_array('--list', $argv, true);
|
|
$tally = []; $n = 0; $enums = []; $ranges = [];
|
|
foreach ($hdrs as $k => [$i, $name]) {
|
|
$end = $hdrs[$k + 1][0] ?? count($lines);
|
|
$rows = _vv_conf_parse_field_range($lines, $i + 1, $end, 'master.conf');
|
|
if ($list && $rows) printf("\n── %s\n", $name);
|
|
foreach ($rows as $x) {
|
|
$n++;
|
|
$w = $x['widget'] ?? '?';
|
|
$tally[$w] = ($tally[$w] ?? 0) + 1;
|
|
if ($w === 'enum') $enums[] = $x['key'] . ' = ' . implode(' | ', array_column($x['choices'], 'value'));
|
|
if (isset($x['min'])) $ranges[] = $x['key'] . ' ' . $x['min'] . '-' . $x['max'];
|
|
if ($list) {
|
|
$e = [];
|
|
if (isset($x['unit'])) $e[] = $x['unit'];
|
|
if (isset($x['min'])) $e[] = $x['min'] . '-' . $x['max'];
|
|
printf(" %-34s %-7s %s\n", $x['key'], $w, $e ? '[' . implode(' ', $e) . ']' : '');
|
|
}
|
|
}
|
|
}
|
|
|
|
printf("\nlive master.conf: %d fields across %d sections\n", $n, count($hdrs));
|
|
ksort($tally);
|
|
foreach ($tally as $k => $v) printf(" %-8s %d\n", $k, $v);
|
|
if ($enums) { echo "\nchoice lists found:\n"; foreach ($enums as $e) echo " $e\n"; }
|
|
if ($ranges) { echo "\nbounded numbers found:\n"; foreach ($ranges as $e) echo " $e\n"; }
|
|
|
|
exit($fail ? 1 : 0);
|