817 lines
38 KiB
PHP
817 lines
38 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'],
|
|
// Every minute, and it gates itself on UPTIME_PROBE_ENABLED rather than being removed from
|
|
// here when switched off — a cron entry that appears and disappears is harder to reason
|
|
// about than one that always exists and sometimes exits immediately.
|
|
['* * * * *', 'uptime_probe.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) { vv_log_error('include/scheduler.php', 'script library walk failed: ' . $e->getMessage()); }
|
|
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.
|
|
// Goes through vv_conf_edit() for the lock, the pre-write backup, the syntax check and the audit
|
|
// line. This used to call vv_write_conf_raw() directly, which gave it tmp+rename atomicity and
|
|
// nothing else — no backup, and no check that the file still sourced afterwards.
|
|
function vv_conf_flag_set(string $name, bool $value): bool {
|
|
if (!vv_conf_key_valid($name)) return false;
|
|
$val = $value ? 'true' : 'false';
|
|
|
|
return vv_conf_edit('master.conf', function (string $content) use ($name, $val): ?string {
|
|
$new = preg_replace(
|
|
'/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m',
|
|
'${1}' . $val . '${3}',
|
|
$content, -1, $count
|
|
);
|
|
// A name that matches no true/false line is a caller error, not an already-correct
|
|
// state — unlike the membership toggle below, where absence genuinely means nothing
|
|
// to do. Returning null keeps the write from happening and logs reason=no-match.
|
|
return $count ? $new : null;
|
|
}, [$name => $val]);
|
|
}
|
|
|
|
// Comment or uncomment a script's line in one master.conf array.
|
|
//
|
|
// $array names which one. It is optional only so existing single-array callers keep working;
|
|
// without it this toggles the FIRST array containing the script, which is wrong whenever a script
|
|
// is listed in more than one — and several are, deliberately. docker_update.sh runs bare in daily,
|
|
// --weekly in weekly and --remainder in monthly, so turning it off in monthly silently disabled
|
|
// the daily run instead and left monthly on: the exact inverse of what was asked for, with a
|
|
// success reported. Callers that know their array must pass it.
|
|
//
|
|
// Goes through vv_conf_edit() for the lock, the pre-write backup, the syntax check and the audit
|
|
// line — see vv_conf_flag_set() above for what that replaced. There is no key to verify here,
|
|
// so the audit subject is the script id and a clean source is the whole assertion.
|
|
function vv_conf_toggle_script(string $rel, bool $enable, ?string $array = null): bool {
|
|
return vv_conf_edit('master.conf', function (string $content) use ($rel, $enable, $array): ?string {
|
|
$lines = preg_split('/(?<=\n)/', $content) ?: [];
|
|
$changed = false;
|
|
$inArray = false;
|
|
$relEsc = preg_quote($rel, '/');
|
|
foreach ($lines as &$line) {
|
|
if (preg_match('/^\s*([A-Z_]+_SCRIPTS)\s*=\s*\(/', $line, $am)) {
|
|
// Only the named array is entered when one was named. Everything else is skipped
|
|
// wholesale rather than matched and rejected per line, so a script that appears in
|
|
// three arrays cannot be reached in the two it was not clicked in.
|
|
$inArray = ($array === null || $am[1] === $array);
|
|
}
|
|
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);
|
|
// A script in no array has nothing to toggle and the conf already reads the way the
|
|
// caller asked. Returning the content unchanged reports success without a write.
|
|
return $changed ? implode('', $lines) : $content;
|
|
}, [], [$rel]);
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
|
|
// $fromArray is the array this orchestrator actually iterates. It matters because a script
|
|
// may be listed in several with different arguments — docker_update.sh runs bare in daily,
|
|
// --weekly in weekly and --remainder in monthly — and vv_conf_script_map() keeps only the
|
|
// first it meets, so the map would label the monthly child DAILY_MAINTENANCE_SCRIPTS. Every
|
|
// consumer of conf_array then acts on the wrong line: the toggle disabled daily when monthly
|
|
// was clicked, and the drag reorder read the wrong list.
|
|
$addChild = function(string $rel, ?string $fromArray = null, ?bool $fromEnabled = null)
|
|
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),
|
|
// All three come from the array this orchestrator actually iterates whenever we know
|
|
// it, and only fall back to the map for children found as static paths, which are not
|
|
// array members at all. Taking any one of them from the map instead is the same bug:
|
|
// the map keeps the first array a script appears in, so a script in three lists would
|
|
// report one list's name, one list's state, and act on one list's line — whichever
|
|
// master.conf happens to declare first, in every orchestrator that shows it.
|
|
'conf_managed' => $fromArray !== null ? true : $conf['managed'],
|
|
'conf_enabled' => $fromArray !== null ? $fromEnabled : $conf['enabled'],
|
|
'conf_array' => $fromArray ?? $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'], $varName, $item['enabled']);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 '';
|
|
}
|