Files
Varaverk/Plugin/unraid/include/scheduler.php
T
Gmer4Lfe be306cf86c Let one gate decide every AI surface, so switching AI off actually removes all of it
The Tools card adopted the AI scripts on the host check alone, and api/ai.php only
tested AI_ENABLED in front of ask, so a disabled subsystem still had rows to run and
an endpoint that answered.
2026-08-07 09:49:31 -04:00

779 lines
34 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The scheduler's engine. Owns schedule.json, regenerates the Varaverk cron file, builds
// the script library shown on the scheduler page, and reads each script's own header to
// describe it in the UI.
//
// OPERATIONAL MODEL
// Writes state that changes what the machine does on a timer. A bad write here does not
// break a page — it changes which jobs run, or stops them running at all.
//
// DESIGN PRINCIPLES
// The bash header is the description; the UI does not keep its own copy.
// vv_script_description() parses the PURPOSE (or DESCRIPTION) block out of the script
// itself. A script's documentation and its listing cannot drift apart, because they are
// the same text. This is the live consumer of the repo-wide header convention.
//
// schedule.json is per-host and never synced.
// What a node runs is a property of that node. Syncing it would hand a partner this
// host's job list, which is exactly wrong under a mutual-redundancy model.
//
// The cron file is regenerated, never edited in place.
// vv_cron_rebuild() emits the whole file from schedule.json. Incremental edits are how
// a cron file accumulates entries nobody can account for.
//
// Scripts are discovered from conf arrays and the filesystem.
// Orchestrator job lists come from master.conf; user scripts are any *.sh dropped in
// CUSTOM_SCRIPTS_DIR. Neither requires registration in a second place.
//
// OPERATIONAL SAFEGUARDS
// The cron file must stay in /boot.
// update_cron merges plugin *.cron files from there into /etc/cron.d/root. Relocating
// it silently stops every scheduled job — nothing errors, the jobs simply never fire.
//
// The legacy direct cron file is removed on rebuild.
// A file left over from before the update_cron migration would fire every job a second
// time. Rebuild deletes it rather than assuming it is gone.
//
// Custom scripts live outside the git repo.
// CUSTOM_SCRIPTS_DIR points at the User Scripts plugin's own storage, so a user's
// scripts are never touched by a pull and never committed by accident.
//
// Shell arguments are escaped where the schedule feeds a command line.
//
// EXPORTS
// Schedule vv_schedule_load(), vv_schedule_save(), vv_schedule_update(),
// vv_schedule_update_batch(), vv_cron_rebuild(), vv_script_suggested_cron()
// Library vv_script_library(), vv_tools_scripts(), vv_custom_scripts(),
// vv_rsync_standalone(), vv_orch_conf_arrays(), vv_job_tree(), vv_script_children()
// Headers vv_script_header(), vv_script_header_clean(), vv_script_description(),
// vv_readme_section(), vv_parse_user_script_template()
// Conf vv_conf_script_map(), vv_conf_flag_value(), vv_conf_flag_set(),
// vv_conf_toggle_script(), vv_parse_conf_array(), vv_parse_conf_array_full()
// Paths vv_job_flags(), vv_job_log_path(), vv_job_stat_path(), vv_folders_load()
//
// CONFIGURATION
// SCHEDULE_FILE SCRIPTS_DIR/schedule.json — per-host, never synced
// CRON_FILE /boot/config/plugins/varaverk/varaverk.cron — must stay in /boot
// CUSTOM_SCRIPTS_DIR user-authored scripts, outside the repo
// master.conf orchestrator job arrays drive the script tree
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Scheduler — manages schedule.json and the Unraid plugin cron file.
// schedule.json is per-host, never synced.
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/confform.php';
define('SCHEDULE_FILE', SCRIPTS_DIR . '/schedule.json');
define('CRON_FILE', '/boot/config/plugins/varaverk/varaverk.cron'); // must stay in /boot — update_cron scans there
function vv_pretty_label(string $slug): string {
return ucwords(str_replace('_', ' ', $slug));
}
function vv_schedule_load(): array {
if (!file_exists(SCHEDULE_FILE)) return [];
$data = json_decode(file_get_contents(SCHEDULE_FILE), true);
return is_array($data) ? $data : [];
}
function vv_schedule_save(array $schedule): bool {
$dir = dirname(SCHEDULE_FILE);
if (!is_dir($dir)) mkdir($dir, 0755, true);
return file_put_contents(SCHEDULE_FILE, json_encode($schedule, JSON_PRETTY_PRINT)) !== false;
}
function vv_schedule_update(string $id, bool $enabled, string $cron, bool $log_enabled = false): bool {
$schedule = vv_schedule_load();
$schedule[$id] = [
'id' => $id,
'enabled' => $enabled,
'cron' => $cron,
'log_enabled' => $log_enabled,
'updated' => date('c'),
];
if (!vv_schedule_save($schedule)) return false;
return vv_cron_rebuild($schedule);
}
function vv_schedule_update_batch(array $entries): bool {
$schedule = vv_schedule_load();
foreach ($entries as $e) {
$id = trim($e['id'] ?? '');
if (!$id) continue;
$schedule[$id] = [
'id' => $id,
'enabled' => (bool)($e['enabled'] ?? false),
'cron' => trim($e['cron'] ?? ''),
'log_enabled' => (bool)($e['log_enabled'] ?? false),
'updated' => date('c'),
];
}
if (!vv_schedule_save($schedule)) return false;
return vv_cron_rebuild($schedule);
}
function vv_job_flags(string $id): string {
$schedule = vv_schedule_load();
return !empty($schedule[$id]['log_enabled']) ? '--log' : '';
}
function vv_job_log_path(string $id): string {
return LOG_DIR . '/' . preg_replace('/\.sh$/', '.log', $id);
}
function vv_job_stat_path(string $id): string {
return LOG_DIR . '/' . preg_replace('/\.sh$/', '.json', $id);
}
function vv_cron_rebuild(array $schedule): bool {
if (!is_dir(LOG_DIR)) mkdir(LOG_DIR, 0755, true);
$runner = dirname(__DIR__) . '/run_job.sh';
$lines = ["# Varaverk — managed by plugin, do not edit manually"];
$lines[] = "# Regenerated: " . date('Y-m-d H:i:s');
$lines[] = "";
// Build child→orch map so we can suppress a child's independent cron when its orch is enabled.
$childToOrch = [];
$confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: '';
foreach (glob(SCRIPTS_DIR . '/Orchestrators/*.sh') ?: [] as $orchPath) {
$orchId = 'Orchestrators/' . basename($orchPath);
$content = file_get_contents($orchPath) ?: '';
preg_match_all('/\$[A-Z_]+\/(?:\.\.\/)?([A-Za-z][A-Za-z0-9_.\-]*\/[A-Za-z0-9_.\-]+\.sh)/', $content, $m1);
foreach ($m1[1] as $rel) $childToOrch[$rel] = $orchId;
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
foreach (array_unique($refs[1] ?? []) as $var) {
foreach (vv_parse_conf_array_full($confRaw, $var) as $item) $childToOrch[$item['path']] = $orchId;
}
}
$scriptsDir = SCRIPTS_DIR;
foreach ($schedule as $entry) {
if (empty($entry['enabled']) || empty($entry['cron']) || empty($entry['id'])) continue;
// Event-triggered jobs are handled by static event scripts, not cron.
if (str_starts_with($entry['cron'], 'array_')) continue;
$id = $entry['id'];
// When a child's orch is enabled it is the sole trigger — suppress independent cron.
if (isset($childToOrch[$id]) && !empty($schedule[$childToOrch[$id]]['enabled'])) continue;
// Custom Scripts live outside the repo (CUSTOM_SCRIPTS_DIR) — everything else resolves under SCRIPTS_DIR.
$script = str_starts_with($id, 'Custom/')
? CUSTOM_SCRIPTS_DIR . '/' . substr($id, strlen('Custom/'))
: "$scriptsDir/$id";
$flags = !empty($entry['log_enabled']) ? ' --log' : '';
$lines[] = "{$entry['cron']} bash \"$runner\" \"$id\" \"$script\"$flags";
}
$lines[] = "";
// Standalone rsync entries: fire when orch is disabled but location + cron are both configured.
$scriptsDir = SCRIPTS_DIR;
foreach ($schedule as $key => $entry) {
if (!str_starts_with((string)$key, '__rsync_')) continue;
$orchId = $entry['orch_id'] ?? '';
$location = $entry['location'] ?? '';
$cron = $entry['cron'] ?? '';
if (!$orchId || !$location || !$cron) continue;
// Skip if orch is still enabled
if (!empty($schedule[$orchId]['enabled'])) continue;
$rsyncScript = "$scriptsDir/Rsync/rsync.sh";
if (!file_exists($rsyncScript)) continue;
$locArg = escapeshellarg('--location=' . $location);
$logFlag = !empty($entry['log_enabled']) ? ' --log' : '';
$lines[] = "$cron bash \"$runner\" \"Rsync/rsync.sh\" \"$rsyncScript\" $locArg$logFlag";
}
$lines[] = "";
// Background writers — always injected, never user-configurable (excluded from scheduler UI).
$toolsDir = SCRIPTS_DIR . '/Plugin/unraid/Tools';
foreach ([
['* * * * *', 'api_cache_writer.sh'],
['0 */2 * * *', 'remote_arr_cache_writer.sh'],
] as [$cron, $script]) {
$path = "$toolsDir/$script";
if (file_exists($path)) $lines[] = "$cron bash \"$runner\" \"Plugin/unraid/Tools/$script\" \"$path\"";
}
$lines[] = "";
// Write to the plugin cron file; update_cron merges all plugin *.cron files into /etc/cron.d/root.
if (file_put_contents(CRON_FILE, implode("\n", $lines)) === false) return false;
exec('/usr/local/sbin/update_cron');
// Remove legacy direct cron file left from before update_cron migration — prevents duplicate job firing.
@unlink('/etc/cron.d/varaverk');
return true;
}
// Extract the suggested cron and label from a bash script header.
// Looks for: # Schedule: */7 * * * * (every 7 minutes via User Scripts plugin)
function vv_script_suggested_cron(string $path): array {
if (!file_exists($path)) return ['cron' => '', 'label' => ''];
$lines = array_slice(file($path) ?: [], 0, 30);
foreach ($lines as $raw) {
$raw = rtrim($raw);
if (!preg_match('/^#\s*Schedule:\s*(.+)$/i', $raw, $m)) continue;
$tail = trim($m[1]);
$parts = preg_split('/\s+/', $tail, 6);
$cron = implode(' ', array_slice($parts, 0, 5));
$label = isset($parts[5]) ? trim($parts[5], '() ') : '';
return ['cron' => $cron, 'label' => $label];
}
return ['cron' => '', 'label' => ''];
}
// Parse user_script_plug-in.sh into an array of script blocks.
// Each block: title, schedule, desc (array of lines), scripts (array of {rel, cron})
function vv_parse_user_script_template(): array {
$file = SCRIPTS_DIR . '/Plugin/unraid/user_script_plug-in.sh';
if (!file_exists($file)) return [];
$lines = file($file, FILE_IGNORE_NEW_LINES);
$prefix = rtrim(SCRIPTS_DIR, '/') . '/';
$blocks = [];
$cur = null;
foreach ($lines as $line) {
// Block header: # ── TITLE ──...
if (preg_match('/^# ── (.+?) ─/', $line, $m)) {
if ($cur) $blocks[] = $cur;
$cur = ['title' => trim($m[1]), 'schedule' => '', 'desc' => [], 'scripts' => []];
continue;
}
if (!$cur) continue;
// Schedule / Background — captured but not added to desc
if (preg_match('/^# (Schedule|Background):\s+(.+)$/i', $line, $m)) {
if (strtolower($m[1]) === 'schedule') $cur['schedule'] = trim($m[2]);
continue;
}
// Sunday block inline cron: # 0 6 * * 0 bash /path/script.sh [args]
if (preg_match('/^#\s+(\S+ +\S+ +\S+ +\S+ +\S+)\s+bash\s+(\S+\.sh)/', $line, $m)) {
$rel = str_replace($prefix, '', trim($m[2]));
$cur['scripts'][] = ['rel' => $rel, 'cron' => preg_replace('/\s+/', ' ', trim($m[1]))];
continue;
}
// Standard bash line: # bash /prefix/path/script.sh [args]
if (preg_match('/^# bash\s+(\S+\.sh)/', $line, $m)) {
$rel = str_replace($prefix, '', $m[1]);
// Only add primary command (dedupe by rel)
$rels = array_column($cur['scripts'], 'rel');
if (!in_array($rel, $rels)) {
$cur['scripts'][] = ['rel' => $rel, 'cron' => ''];
}
continue;
}
// Description line
if (preg_match('/^# ?(.*)$/', $line, $m)) {
$inner = $m[1];
if (!preg_match('/^[─━=\-]{3,}\s*$/', $inner) && !preg_match('/^█/', $inner)) {
$cur['desc'][] = $inner;
}
}
}
if ($cur) $blocks[] = $cur;
return $blocks;
}
// Extract a one-line description from a bash script header.
// Supports two patterns:
// 1. # PURPOSE (or # DESCRIPTION) block — returns first non-separator line after it
// 2. First meaningful comment line after the banner
function vv_script_description(string $path): string {
if (!file_exists($path)) return '';
$lines = array_slice(file($path) ?: [], 0, 50);
$purposeNext = false;
$first = '';
foreach ($lines as $raw) {
$raw = rtrim($raw);
if (str_starts_with($raw, '#!')) continue; // shebang
if (!str_starts_with($raw, '#')) continue; // non-comment
$inner = ltrim(substr($raw, 1)); // strip leading #
if ($inner === '') continue; // blank
if (preg_match('/^[\s=\-─━\*]+$/', $inner)) continue; // pure separator
if (preg_match('/^={3,}/', $inner)) continue; // banner (=== Title ===)
if (preg_match('/^\s*(PURPOSE|DESCRIPTION|Description)\s*$/i', $inner)) {
$purposeNext = true;
continue;
}
if ($purposeNext) {
return mb_substr(trim($inner), 0, 200);
}
if (!$first) $first = mb_substr(trim($inner), 0, 200);
}
return $first;
}
function vv_tools_scripts(): array {
// Background writers managed automatically — not user-facing tools
static $EXCLUDE = ['api_cache_writer.sh', 'remote_arr_cache_writer.sh'];
// Scripts that live outside Tools/ but are run by hand often enough to belong on the card.
// Named individually rather than by adopting their folder: System_Essentials also holds
// conf_sync, rsync_stop and the rest of the shutdown chain, and putting those in front of the
// operator as ordinary tools misrepresents what they are.
//
// server_reboot.sh stays where it is because it is not a standalone utility — it is the head
// of the clean-shutdown sequence, calling array_stopping.sh, mover_stop.sh and
// user_scripts_stop.sh in order, and the System_Essentials documentation describes it as that
// chain. Moving the file to match the UI would break the grouping that explains it.
$ADOPTED = ['System_Essentials/server_reboot.sh'];
// The AI tools are adopted only where the index and the model actually live, and only while
// the subsystem is switched on. Elsewhere they are two rows that can only ever fail — the
// retrieval index is not synced between hosts, Ollama runs on one of them, and both scripts
// refuse on their own unless AI_ENABLED is true. vv_ai_ui_on() is the same gate the AI tab
// and the assistant dock use, so AI off means no AI anywhere in the UI, not most of it.
if (vv_ai_ui_on()) {
array_push($ADOPTED, 'AI/ai_index.sh', 'AI/ai_query.sh');
}
$schedule = vv_schedule_load();
$scripts = [];
$entryFor = function(string $path, string $rel) use ($schedule): array {
$entry = $schedule[$rel] ?? [];
return [
'id' => $rel,
'label' => vv_pretty_label(basename($path, '.sh')),
'desc' => vv_script_description($path),
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
];
};
$collect = function(string $dir, string $relPrefix) use ($EXCLUDE, $entryFor, &$scripts): void {
foreach (glob("$dir/*.sh") ?: [] as $path) {
$base = basename($path);
if (in_array($base, $EXCLUDE, true)) continue;
$scripts[] = $entryFor($path, $relPrefix . $base);
}
};
// General tools
$collect(SCRIPTS_DIR . '/Tools', 'Tools/');
// Platform adapter tools (Plugin/<platform>/Tools/)
foreach (glob(SCRIPTS_DIR . '/Plugin/*/Tools') ?: [] as $toolsDir) {
$platform = basename(dirname($toolsDir));
$collect($toolsDir, "Plugin/$platform/Tools/");
}
// Adopted individually. Skipped silently when absent so a host without the script — or a
// sparse checkout that never pulled it — shows one fewer tool rather than a broken row.
foreach ($ADOPTED as $rel) {
$path = SCRIPTS_DIR . '/' . $rel;
if (is_file($path)) $scripts[] = $entryFor($path, $rel);
}
usort($scripts, fn($a, $b) => strcmp($a['label'], $b['label']));
return $scripts;
}
// Lists every Custom Script for the scheduler page. Discovery is glob-based, not a
// registry — any *.sh file dropped directly into CUSTOM_SCRIPTS_DIR (or a platform
// adapter's own Custom/ folder) shows up here, whether or not it was created via the
// page's "+ Create Script" editor or has a schedule.json entry yet.
function vv_custom_scripts(): array {
$schedule = vv_schedule_load();
$scripts = [];
$collect = function(string $dir, string $relPrefix) use ($schedule, &$scripts): void {
foreach (glob("$dir/*.sh") ?: [] as $path) {
$rel = $relPrefix . basename($path);
$entry = $schedule[$rel] ?? [];
$scripts[] = [
'id' => $rel,
'label' => vv_pretty_label(basename($path, '.sh')),
'desc' => vv_script_description($path),
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
];
}
};
$collect(CUSTOM_SCRIPTS_DIR, 'Custom/');
// Platform adapter custom scripts (Plugin/<platform>/Custom/)
foreach (glob(SCRIPTS_DIR . '/Plugin/*/Custom') ?: [] as $customDir) {
$platform = basename(dirname($customDir));
$collect($customDir, "Plugin/$platform/Custom/");
}
return $scripts;
}
// Load rsync standalone config (location + cron) for a flag name from schedule.json.
function vv_rsync_standalone(string $flagName): array {
$s = vv_schedule_load();
$r = $s['__rsync_' . $flagName] ?? [];
return [
'location' => (string)($r['location'] ?? ''),
'cron' => (string)($r['cron'] ?? ''),
];
}
// Extract *_SCRIPTS array variable names that an orchestrator iterates over.
function vv_orch_conf_arrays(string $orchPath): array {
$content = file_get_contents($orchPath) ?: '';
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
return array_unique($refs[1] ?? []);
}
// Return .sh scripts that exist in SCRIPTS_DIR but are not referenced in any
// master.conf *_SCRIPTS array and are not shown in any other scheduler card.
function vv_script_library(): array {
$scriptsDir = SCRIPTS_DIR;
$confMap = vv_conf_script_map();
$orchIds = [];
foreach (glob("$scriptsDir/Orchestrators/*.sh") ?: [] as $p) {
$orchIds[] = 'Orchestrators/' . basename($p);
}
// Scripts already shown in their own cards are not "unlisted"
$schedule = vv_schedule_load();
$cardIds = array_flip(array_merge(
array_column(vv_tools_scripts(), 'id'),
array_column(vv_custom_scripts(), 'id')
));
// UI-only subdirs under Plugin/<platform>/ — no runnable scripts
$pluginUiDirs = ['api', 'include', 'pages', 'css', 'js', 'icons', 'event'];
$exclude = ['.git', 'Orchestrators', 'Custom', 'Configurations'];
$library = [];
try {
$ri = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($scriptsDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
$base = rtrim($scriptsDir, '/') . '/';
foreach ($ri as $rf) {
if (!$rf->isFile() || strtolower($rf->getExtension()) !== 'sh') continue;
$rel = ltrim(str_replace($base, '', $rf->getPathname()), '/');
$parts = explode('/', $rel);
if (in_array($parts[0], $exclude)) continue;
if ($parts[0] === 'Plugin') {
// Require Plugin/<platform>/<category>/<script>.sh — skip root-level adapter files
if (count($parts) < 4) continue;
// Skip UI-only category dirs
if (in_array($parts[2], $pluginUiDirs)) continue;
} elseif (count($parts) < 2) {
continue;
}
if (in_array($rel, $orchIds) || isset($confMap[$rel]) || isset($cardIds[$rel]) || isset($schedule[$rel])) continue;
$library[] = ['id' => $rel, 'label' => vv_pretty_label(basename($rel, '.sh'))];
}
} catch (Exception $e) {}
usort($library, fn($a, $b) => strcmp($a['id'], $b['id']));
return $library;
}
// Load custom-script folder assignments from schedule.json (__folders key).
function vv_folders_load(): array {
$s = vv_schedule_load();
$f = $s['__folders'] ?? [];
return is_array($f) ? $f : [];
}
// Walk the scripts repo and return the job tree.
// Type is derived from the saved cron value: array_start / array_stop → 'event', else 'orchestrator'.
// Well-known event orchs get their cron seeded from $eventDefaults when not yet in schedule.json.
// Any orch or custom script can carry array_start / array_stop as its cron — the event scripts fire all of them.
function vv_job_tree(): array {
$scriptsDir = SCRIPTS_DIR;
$schedule = vv_schedule_load();
// First-run seeds only — applied when schedule.json has no entry for these IDs yet.
$eventDefaults = [
'Orchestrators/array_started.sh' => 'array_start',
'Orchestrators/array_stopping.sh' => 'array_stop',
];
$orchs = [];
foreach (glob("$scriptsDir/Orchestrators/*.sh") ?: [] as $path) {
$id = 'Orchestrators/' . basename($path);
$default = $eventDefaults[$id] ?? '';
$entry = $schedule[$id] ?? ['enabled' => false, 'cron' => $default];
$cron = $entry['cron'] ?? $default;
$isEvent = str_starts_with($cron, 'array_');
$suggested = $isEvent ? ['cron' => '', 'label' => ''] : vv_script_suggested_cron($path);
$orchs[] = [
'id' => $id,
'label' => vv_pretty_label(basename($path, '.sh')),
'desc' => vv_script_description($path),
'type' => $isEvent ? 'event' : 'orchestrator',
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $cron,
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
'suggested_cron' => $suggested['cron'],
'suggested_label' => $suggested['label'],
'children' => vv_script_children($path, $schedule),
'conf_arrays' => vv_orch_conf_arrays($path),
];
}
// Events first (array_start before array_stop), then alphabetical by label.
usort($orchs, function($a, $b) {
$ae = $a['type'] === 'event' ? 0 : 1;
$be = $b['type'] === 'event' ? 0 : 1;
if ($ae !== $be) return $ae - $be;
if ($ae === 0) {
$as = str_contains($a['cron'], 'start') ? 0 : 1;
$bs = str_contains($b['cron'], 'start') ? 0 : 1;
if ($as !== $bs) return $as - $bs;
}
return strcmp($a['label'], $b['label']);
});
return $orchs;
}
// Parse a bash array from master.conf content and return its script paths.
// Handles entries with inline args ("script.sh --flag") and skips commented lines (#"...").
function vv_parse_conf_array(string $conf, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\((.*?)^\s*\)/ms', $conf, $m)) {
return [];
}
$scripts = [];
preg_match_all('/^\s*(?!#)"([^"]+)"/m', $m[1], $entries);
foreach ($entries[1] as $entry) {
$parts = preg_split('/\s+/', trim($entry));
$path = $parts[0] ?? '';
if (substr($path, -3) === '.sh') $scripts[] = $path;
}
return $scripts;
}
// Like vv_parse_conf_array but includes commented entries.
// Returns array of ['path' => string, 'enabled' => bool].
function vv_parse_conf_array_full(string $conf, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\((.*?)^\s*\)/ms', $conf, $m)) {
return [];
}
$results = [];
foreach (explode("\n", $m[1]) as $line) {
if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
$commented = trim($e[1]) !== '';
$parts = preg_split('/\s+/', trim($e[2]));
$path = $parts[0] ?? '';
if (substr($path, -3) !== '.sh') continue;
$results[] = ['path' => $path, 'enabled' => !$commented];
}
return $results;
}
// Build a map of script rel-path → conf status by scanning all *_SCRIPTS arrays in master.conf.
// Cached per-request so multiple callers only read the file once.
function vv_conf_script_map(): array {
static $cache = null;
if ($cache !== null) return $cache;
$confPath = CONF_DIR . '/master.conf';
if (!file_exists($confPath)) return $cache = [];
$lines = file($confPath, FILE_IGNORE_NEW_LINES) ?: [];
$map = [];
$inArray = false;
$arrayVar = '';
foreach ($lines as $line) {
if (preg_match('/^\s*([A-Z_]+_SCRIPTS)\s*=\s*\(/', $line, $am)) { $inArray = true; $arrayVar = $am[1]; }
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if (!$inArray) continue;
if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
$commented = trim($e[1]) !== '';
$parts = preg_split('/\s+/', trim($e[2]));
$path = $parts[0] ?? '';
if (substr($path, -3) !== '.sh') continue;
if (!isset($map[$path])) $map[$path] = ['array' => $arrayVar, 'enabled' => !$commented, 'managed' => true];
}
return $cache = $map;
}
// Read a boolean flag value (e.g. INTERMEDIATE_RSYNC_ENABLED) from master.conf.
function vv_conf_flag_value(string $name): bool {
$conf = file_get_contents(CONF_DIR . '/master.conf') ?: '';
if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=\s*(true|false)\s*(?:#.*)?$/m', $conf, $m)) {
return $m[1] === 'true';
}
return false;
}
// Write a boolean flag value to master.conf.
function vv_conf_flag_set(string $name, bool $value): bool {
$confPath = CONF_DIR . '/master.conf';
$content = file_get_contents($confPath);
if ($content === false) return false;
$val = $value ? 'true' : 'false';
$new = preg_replace(
'/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m',
'${1}' . $val . '${3}',
$content, -1, $count
);
if (!$count) return false;
// tmp+rename — every script sources master.conf, so a truncated write here is a
// system-wide outage, not a lost toggle.
return vv_write_conf_raw('master.conf', $new);
}
// Comment or uncomment a script's line in the first master.conf array that contains it.
function vv_conf_toggle_script(string $rel, bool $enable): bool {
$confPath = CONF_DIR . '/master.conf';
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
if (!$lines) return false;
$changed = false;
$inArray = false;
$relEsc = preg_quote($rel, '/');
foreach ($lines as &$line) {
if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if (!$inArray) continue;
if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue;
$isCommented = (bool)preg_match('/^\s*#/', $line);
if ($enable && $isCommented) {
$line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line);
$changed = true;
} elseif (!$enable && !$isCommented) {
$line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line);
$changed = true;
}
break;
}
unset($line);
if (!$changed) return true;
// tmp+rename — every script sources master.conf, so a truncated write here is a
// system-wide outage, not a lost toggle.
return vv_write_conf_raw('master.conf', implode('', $lines));
}
// Parse an orchestrator script to find which child scripts it calls.
// Two strategies, merged and deduped:
// 1. Static paths: $SCRIPT_DIR/../Category/script.sh or $SCRIPTS_ROOT/Category/script.sh
// 2. master.conf arrays: detects ${VARNAME[@]} iteration and reads the array from master.conf
// Root-level files (load_config.sh etc.) excluded — must be in a subdirectory.
// Each candidate validated against the filesystem.
function vv_script_children(string $orchPath, array $schedule): array {
$scriptsDir = SCRIPTS_DIR;
$content = file_get_contents($orchPath) ?: '';
$children = [];
$seen = [];
$confMap = vv_conf_script_map();
// Detect which tier rsync flag this orch controls (e.g. "INTERMEDIATE" → INTERMEDIATE_RSYNC_ENABLED)
$rsyncFlagName = null;
if (preg_match('/check_rsync_enabled\s+"([A-Z]+)"/', $content, $rm)) {
$rsyncFlagName = $rm[1] . '_RSYNC_ENABLED';
}
$addChild = function(string $rel) use ($scriptsDir, $schedule, $confMap, &$children, &$seen) {
if (isset($seen[$rel]) || !file_exists("$scriptsDir/$rel")) return;
$seen[$rel] = true;
$entry = $schedule[$rel] ?? ['enabled' => false, 'cron' => ''];
$conf = $confMap[$rel] ?? ['array' => null, 'enabled' => null, 'managed' => false];
$suggested = vv_script_suggested_cron("$scriptsDir/$rel");
$children[] = [
'id' => $rel,
'label' => vv_pretty_label(basename($rel, '.sh')),
'desc' => vv_script_description("$scriptsDir/$rel"),
'type' => 'script',
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
'conf_managed' => $conf['managed'],
'conf_enabled' => $conf['enabled'], // null if not in any *_SCRIPTS array
'conf_array' => $conf['array'],
'suggested_cron' => $suggested['cron'],
'suggested_label' => $suggested['label'],
];
};
// Strategy 1: static variable paths ($VAR/../Category/script.sh or $VAR/Category/script.sh)
preg_match_all(
'/\$[A-Z_]+\/(?:\.\.\/)?([A-Za-z][A-Za-z0-9_.\-]*\/[A-Za-z0-9_.\-]+\.sh)/',
$content, $m
);
foreach ($m[1] as $rel) $addChild($rel);
// Strategy 2: master.conf arrays — includes commented (disabled) entries so they appear in the UI
preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
if (!empty($refs[1])) {
$confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: '';
foreach (array_unique($refs[1]) as $varName) {
foreach (vv_parse_conf_array_full($confRaw, $varName) as $item) $addChild($item['path']);
}
}
// Annotate Rsync/rsync.sh as a conf_flag child if this orch controls a rsync tier flag
if ($rsyncFlagName) {
foreach ($children as &$c) {
if ($c['id'] === 'Rsync/rsync.sh') {
$c['type'] = 'conf_flag';
$c['flag_name'] = $rsyncFlagName;
$c['flag_value'] = vv_conf_flag_value($rsyncFlagName);
break;
}
}
unset($c);
}
return $children;
}
// Extract the comment header block from a bash script (shebang + all leading comment lines).
// Returns raw lines with # markers intact.
function vv_script_header(string $path): string {
if (!file_exists($path)) return '';
$lines = array_slice(file($path) ?: [], 0, 80);
$out = [];
foreach ($lines as $line) {
$t = rtrim($line);
if (str_starts_with($t, '#') || ($out === [] && str_starts_with($t, '#!'))) {
$out[] = $t;
} elseif ($t === '' && !empty($out)) {
$out[] = $t; // allow blank lines within header
} else {
break;
}
}
// Trim trailing blank lines
while (!empty($out) && trim(end($out)) === '') array_pop($out);
return implode("\n", $out);
}
// Strip the leading # marker from each line of a script header for cleaner display.
// Also drops the shebang line (#!/bin/bash) since it's not informative in this context.
function vv_script_header_clean(string $path): string {
$raw = vv_script_header($path);
if (!$raw) return '';
$lines = explode("\n", $raw);
$out = [];
foreach ($lines as $line) {
if (str_starts_with($line, '#!')) continue; // shebang — not useful in header display
$out[] = preg_replace('/^#\s?/', '', $line); // strip # and optional space
}
while (!empty($out) && trim(end($out)) === '') array_pop($out);
return implode("\n", $out);
}
// Read a named section from a markdown file.
// Calls $matcher(heading, isIntro) where isIntro=true for content before the first heading.
// Returns the first matching section body, capped at $maxChars.
function vv_readme_section(string $readmePath, callable $matcher, int $maxChars = 3000): string {
if (!file_exists($readmePath)) return '';
$content = file_get_contents($readmePath) ?: '';
$parts = preg_split('/^(#{1,4}[^\n]*)/m', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
$heading = '';
$isIntro = true;
foreach ($parts as $i => $part) {
if ($i % 2 === 1) {
$heading = trim(preg_replace('/^#{1,4}\s*/', '', $part));
$isIntro = false;
continue;
}
$body = trim($part);
if ($body === '') continue;
if ($matcher($heading, $isIntro)) {
return strlen($body) > $maxChars ? substr($body, 0, $maxChars) . "\n[…]" : $body;
}
}
return '';
}