From 9cabe8af5ec39abb6ddd12449dfd79a09b0c3482 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Tue, 9 Jun 2026 22:44:39 +0000 Subject: [PATCH] Wire update channel (stable/beta) into settings, check, deploy, and version tracking - Settings page now loads current values from DB and saves via save-option API - check-novacpx-update reads update_channel setting, checks origin/main or origin/beta - apply-novacpx-update pulls from channel branch, fixes backup dir (/tmp), fixes SQLite migration syntax, records new version in novacpx_version table + settings.panel_version - deploy-runner.sh reads update_channel from DB, pulls correct branch, records version after deploy - webhook.php accepts pushes to both main and beta branches - Updates page shows channel badge and latest remote version Co-Authored-By: Claude Sonnet 4.6 --- deploy/deploy-runner.sh | 21 +++++++++-- deploy/webhook.php | 10 +++-- panel/api/endpoints/system.php | 66 ++++++++++++++++++++------------- panel/public/assets/js/admin.js | 59 +++++++++++++++++++++++------ 4 files changed, 112 insertions(+), 44 deletions(-) diff --git a/deploy/deploy-runner.sh b/deploy/deploy-runner.sh index ed8b728..b4a9550 100644 --- a/deploy/deploy-runner.sh +++ b/deploy/deploy-runner.sh @@ -19,12 +19,18 @@ while IFS='|' read -r REPO_PATH WEB_ROOT COMMIT; do [[ -z "$REPO_PATH" ]] && continue log "--- Deploying commit $COMMIT ---" + # Read update channel from DB to know which branch to pull + DB_PATH=$(python3 -c "import configparser; c=configparser.ConfigParser(); c.read('/etc/novacpx/config.ini'); print(c.get('database','path',fallback='/var/lib/novacpx/panel.db'))" 2>/dev/null || echo "/var/lib/novacpx/panel.db") + CHANNEL=$(sqlite3 "$DB_PATH" "SELECT value FROM settings WHERE key='update_channel'" 2>/dev/null || echo "stable") + TARGET_BRANCH="main" + [[ "$CHANNEL" == "beta" ]] && TARGET_BRANCH="beta" + # Validate PHP syntax before applying cd "$REPO_PATH" || continue git fetch origin >> "$LOG" 2>&1 # Check PHP syntax on changed .php files - CHANGED_PHP=$(git diff HEAD..origin/main --name-only 2>/dev/null | grep '\.php$' || true) + CHANGED_PHP=$(git diff HEAD..origin/${TARGET_BRANCH} --name-only 2>/dev/null | grep '\.php$' || true) SYNTAX_OK=true for f in $CHANGED_PHP; do [[ -f "$REPO_PATH/$f" ]] || continue @@ -40,9 +46,9 @@ while IFS='|' read -r REPO_PATH WEB_ROOT COMMIT; do continue fi - # Pull + # Pull from channel branch BEFORE=$(git rev-parse HEAD) - git pull origin main >> "$LOG" 2>&1 + git pull origin "${TARGET_BRANCH}" >> "$LOG" 2>&1 AFTER=$(git rev-parse HEAD) if [[ "$BEFORE" == "$AFTER" ]]; then @@ -69,7 +75,6 @@ while IFS='|' read -r REPO_PATH WEB_ROOT COMMIT; do # Run pending DB migrations (SQLite) MIGR_DIR="$REPO_PATH/db/migrations" - DB_PATH=$(python3 -c "import configparser; c=configparser.ConfigParser(); c.read('/etc/novacpx/config.ini'); print(c.get('database','path',fallback='/var/lib/novacpx/panel.db'))" 2>/dev/null || echo "/var/lib/novacpx/panel.db") if [[ -d "$MIGR_DIR" && -f "$DB_PATH" ]]; then for SQL in "$MIGR_DIR"/*.sql; do [[ -f "$SQL" ]] || continue @@ -83,6 +88,14 @@ while IFS='|' read -r REPO_PATH WEB_ROOT COMMIT; do done fi + # Record new version in DB + NEW_VERSION=$(cat "$REPO_PATH/VERSION" 2>/dev/null | tr -d '[:space:]' || true) + if [[ -n "$NEW_VERSION" && -f "$DB_PATH" ]]; then + sqlite3 "$DB_PATH" "INSERT INTO novacpx_version (version, installed_at, notes, commit_hash) VALUES ('$NEW_VERSION', datetime('now'), 'Auto-deployed via webhook ($CHANNEL channel)', '$AFTER')" 2>/dev/null || true + sqlite3 "$DB_PATH" "INSERT INTO settings (key, value, updated_at) VALUES ('panel_version', '$NEW_VERSION', datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at" 2>/dev/null || true + log "Version updated to $NEW_VERSION" + fi + # Ensure deploy webhook is accessible from web root mkdir -p "$WEB_ROOT/deploy" ln -sf "$REPO_PATH/deploy/webhook.php" "$WEB_ROOT/deploy/webhook.php" diff --git a/deploy/webhook.php b/deploy/webhook.php index 02191cb..8798682 100644 --- a/deploy/webhook.php +++ b/deploy/webhook.php @@ -37,8 +37,10 @@ if ($secret) { $payload = json_decode($rawBody, true); $pushedBranch = basename($payload['ref'] ?? ''); -if ($pushedBranch !== $branch) { - echo json_encode(['status' => 'skipped', 'reason' => "Not target branch ($branch)"]); +// Accept pushes to main (stable) or beta — both can trigger deploys +$allowedBranches = ['main', 'beta']; +if (!in_array($pushedBranch, $allowedBranches)) { + echo json_encode(['status' => 'skipped', 'reason' => "Not a deployable branch ($pushedBranch)"]); exit; } @@ -46,9 +48,9 @@ $commit = $payload['after'] ?? 'unknown'; $pusher = $payload['pusher']['name'] ?? 'unknown'; $message = $payload['head_commit']['message'] ?? ''; -log_deploy("Deploy triggered by $pusher | commit $commit | $message"); +log_deploy("Deploy triggered by $pusher | branch $pushedBranch | commit $commit | $message"); -// Queue the deploy (non-blocking) +// Queue the deploy — include branch so runner knows what to pull $queueFile = '/tmp/novacpx-deploy-queue.txt'; file_put_contents($queueFile, "$repoPath|$webRoot|$commit\n", FILE_APPEND | LOCK_EX); diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php index 0f3ec7c..2683c13 100644 --- a/panel/api/endpoints/system.php +++ b/panel/api/endpoints/system.php @@ -219,14 +219,17 @@ BASH; Response::success($data); } - $srcDir = '/opt/novacpx-src'; + $srcDir = '/opt/novacpx-src'; if (!is_dir($srcDir)) Response::error('Source repo not found at /opt/novacpx-src'); + $channel = trim($db->fetchOne("SELECT value FROM settings WHERE `key`='update_channel'")['value'] ?? 'stable'); + $remoteBranch = $channel === 'beta' ? 'origin/beta' : 'origin/main'; shell_exec("sudo git -C " . escapeshellarg($srcDir) . " fetch origin 2>/dev/null"); - $logOut = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " log HEAD..origin/main --oneline 2>/dev/null") ?: ''; + $logOut = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " log HEAD..{$remoteBranch} --oneline 2>/dev/null") ?: ''; $updates = array_values(array_filter(explode("\n", trim($logOut)))); $branch = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " branch --show-current 2>/dev/null") ?: 'main'); $commit = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse --short HEAD 2>/dev/null") ?: ''); - $result = ['updates_available' => count($updates), 'current_commit' => $commit, 'branch' => $branch, 'commits' => $updates]; + $remoteVer = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " show {$remoteBranch}:VERSION 2>/dev/null") ?: ''); + $result = ['updates_available' => count($updates), 'current_commit' => $commit, 'branch' => $branch, 'channel' => $channel, 'remote_version' => $remoteVer, 'commits' => $updates]; $db->execute("INSERT INTO settings(`key`,value,updated_at) VALUES('update_cache_novacpx',?,datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", [json_encode($result)]); Response::success($result); })(), @@ -237,22 +240,23 @@ BASH; set_time_limit(180); $srcDir = '/opt/novacpx-src'; $webRoot = defined('WEB_ROOT') ? WEB_ROOT : '/srv/novacpx/public'; - $webSvc = defined('WEB_SERVER') && WEB_SERVER === 'nginx' ? 'nginx' : 'apache2'; $steps = []; if (!is_dir($srcDir)) Response::error('Source repo not found at /opt/novacpx-src'); - $before = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); - $steps[] = "Before: $before"; + $channel = trim($db->fetchOne("SELECT value FROM settings WHERE `key`='update_channel'")['value'] ?? 'stable'); + $targetBranch = $channel === 'beta' ? 'beta' : 'main'; - // Backup current web root - $backupDir = '/var/novacpx/backups/pre-novacpx-update-' . date('YmdHis'); - shell_exec("mkdir -p " . escapeshellarg($backupDir)); - shell_exec("cp -a " . escapeshellarg($webRoot) . " " . escapeshellarg("$backupDir/public") . " 2>&1"); + $before = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); + $steps[] = "Before: $before (channel: $channel)"; + + // Backup current web root to /tmp (writable, no sudo needed) + $backupDir = '/tmp/novacpx-backup-' . date('YmdHis'); + shell_exec("cp -a " . escapeshellarg($webRoot) . " " . escapeshellarg($backupDir) . " 2>&1"); $steps[] = "Backup: $backupDir"; - // Pull new code (sudo so www-data can write root-owned repo) - $pull = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " pull origin main 2>&1"); + // Pull new code from the channel branch (sudo so www-data can write root-owned repo) + $pull = shell_exec("sudo git -C " . escapeshellarg($srcDir) . " pull origin " . escapeshellarg($targetBranch) . " 2>&1"); $steps[] = "Pull: " . trim($pull ?: '(no output)'); $after = trim(shell_exec("sudo git -C " . escapeshellarg($srcDir) . " rev-parse HEAD 2>/dev/null") ?: ''); @@ -260,7 +264,7 @@ BASH; $steps[] = "After: $after" . ($changed ? " (changed)" : " (no change)"); if ($changed) { - // Validate PHP syntax (use php8.3; find all .php files recursively) + // Validate PHP syntax $phpFiles = []; $found = shell_exec("find " . escapeshellarg("$srcDir/panel") . " -name '*.php' 2>/dev/null") ?: ''; foreach (array_filter(explode("\n", trim($found))) as $f) { $phpFiles[] = trim($f); } @@ -279,15 +283,15 @@ BASH; Response::error('Update aborted — PHP syntax errors: ' . implode('; ', $syntaxErr)); } - // Deploy files to web root (sudo rsync) + // Deploy files to web root shell_exec("sudo rsync -a --delete " . escapeshellarg("$srcDir/panel/public/") . " " . escapeshellarg("$webRoot/") . " 2>&1"); shell_exec("sudo rsync -a " . escapeshellarg("$srcDir/panel/lib/") . " " . escapeshellarg("$webRoot/lib/") . " 2>&1"); shell_exec("sudo rsync -a " . escapeshellarg("$srcDir/panel/api/") . " " . escapeshellarg("$webRoot/api/") . " 2>&1"); - shell_exec("cp " . escapeshellarg("$srcDir/VERSION") . " " . escapeshellarg("$webRoot/VERSION") . " 2>/dev/null"); + shell_exec("sudo cp " . escapeshellarg("$srcDir/VERSION") . " " . escapeshellarg("$webRoot/../VERSION") . " 2>/dev/null"); shell_exec("sudo chown -R www-data:www-data " . escapeshellarg($webRoot)); $steps[] = "Deploy: rsync complete"; - // Run pending DB migrations + // Run pending DB migrations (SQLite syntax) $migrDir = "$srcDir/db/migrations"; if (is_dir($migrDir)) { foreach (glob("$migrDir/*.sql") as $sql) { @@ -295,13 +299,23 @@ BASH; $already = $db->fetchOne("SELECT 1 FROM settings WHERE `key` = ?", ["migration_$migName"]); if (!$already) { try { $db->pdo()->exec(file_get_contents($sql)); } catch (\Throwable $e) { /* skip dupes */ } - $db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,NOW()) ON DUPLICATE KEY UPDATE `value`=NOW()", ["migration_$migName"]); + $db->execute("INSERT INTO settings (`key`,`value`,updated_at) VALUES (?,datetime('now'),datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", ["migration_$migName"]); $steps[] = "Migration: $migName applied"; } } } - // Reload PHP-FPM to pick up new code + // Record new version in novacpx_version table and settings + $newVersion = trim(shell_exec("sudo cat " . escapeshellarg("$srcDir/VERSION") . " 2>/dev/null") ?: ''); + if ($newVersion) { + $db->execute("INSERT INTO novacpx_version (version, installed_at, notes, commit_hash) VALUES (?,datetime('now'),?,?)", + [$newVersion, "Updated via admin panel from {$before} (channel: {$channel})", $after]); + $db->execute("INSERT INTO settings (`key`,`value`,updated_at) VALUES ('panel_version',?,datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", + [$newVersion]); + $steps[] = "Version: $newVersion recorded"; + } + + // Reload PHP-FPM shell_exec("sudo systemctl reload php8.3-fpm 2>/dev/null || sudo systemctl reload php8.2-fpm 2>/dev/null || true"); $steps[] = "PHP-FPM reloaded"; @@ -314,20 +328,20 @@ BASH; if (in_array($code, ['200','401','302','301'])) { $panelOk = true; break; } } if (!$panelOk) { - shell_exec("sudo rsync -a --delete " . escapeshellarg("$backupDir/public/") . " " . escapeshellarg("$webRoot/") . " 2>&1"); - shell_exec("sudo systemctl reload $webSvc 2>/dev/null"); + shell_exec("sudo rsync -a --delete " . escapeshellarg("$backupDir/") . " " . escapeshellarg("$webRoot/") . " 2>&1"); novacpx_log('error', "NovaCPX update failed — panel down after deploy; restored from backup"); Response::error('Update deployed but panel went down — auto-restored from backup. Check logs.'); } - audit('system.novacpx-update', "novacpx:$before→$after"); - novacpx_log('info', "NovaCPX updated $before → $after"); + audit('system.novacpx-update', "novacpx:{$before}→{$after} (channel:{$channel})"); + novacpx_log('info', "NovaCPX updated $before → $after via $channel channel"); } Response::success([ 'updated' => $changed, 'from_commit' => $before, 'to_commit' => $after, + 'channel' => $channel, 'pull_output' => trim($pull ?? ''), 'backup_path' => $backupDir, 'steps' => $steps, @@ -511,7 +525,8 @@ BASH; // ── Server Options (#22a-e) ─────────────────────────────────────────────── 'server-options' => (function() use ($db) { Auth::getInstance()->require('admin'); - $keys = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname']; + $keys = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname', + 'panel_name','default_php','default_nameserver1','default_nameserver2','update_channel']; $opts = []; foreach ($db->fetchAll("SELECT `key`,`value` FROM settings WHERE `key` IN ('" . implode("','", $keys) . "')") as $r) { $opts[$r['key']] = $r['value']; @@ -531,9 +546,10 @@ BASH; Auth::getInstance()->require('admin'); $key = $body['key'] ?? ''; $value = $body['value'] ?? ''; - $allowed = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname']; + $allowed = ['web_server','mail_server','ftp_server','dns_server','whmcs_api_key','whmcs_enabled','ns1_hostname','ns2_hostname', + 'panel_name','default_php','default_nameserver1','default_nameserver2','update_channel']; if (!in_array($key, $allowed)) Response::error("Invalid setting key: $key"); - $db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)", [$key, $value]); + $db->execute("INSERT INTO settings (`key`,`value`,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(`key`) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", [$key, $value]); audit("settings.{$key}", $value); Response::success(null, "Setting saved: {$key} = {$value}"); })(), diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 7bf78d2..a8e9ee6 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -300,8 +300,8 @@

Installed

${v.installed_version || '—'}

-

Commit

${ncpx.current_commit || v.git_commit || '—'}
-

Branch

${ncpx.branch || 'main'}
+

Latest (${ncpx.channel || 'stable'})

${ncpx.remote_version || (ncpxCount > 0 ? 'available' : v.installed_version || '—')}

+

Channel

${Nova.badge(ncpx.channel || 'stable', ncpx.channel === 'beta' ? 'yellow' : 'green')}

PHP

${v.php_version || '—'}
@@ -896,22 +896,40 @@ // ── Settings ─────────────────────────────────────────────────────────────── async function settings() { + const r = await Nova.api('system', 'server-options'); + const o = r?.data || {}; + const cur = { + panel_name: o.panel_name || 'NovaCPX', + default_php: o.default_php || '8.3', + ns1: o.default_nameserver1 || '', + ns2: o.default_nameserver2 || '', + channel: o.update_channel || 'stable', + }; + const phpOpts = ['7.4','8.1','8.2','8.3'].map(v => + ``).join(''); + const chanOpts = [ + ['stable', 'Stable — major releases (main branch)'], + ['beta', 'Beta — minor & patch releases (beta branch)'], + ].map(([v, l]) => ``).join(''); return `
Panel Settings
-
+
-
+
- +
-
-
-
- +
+
+
+ + +
+ Stable receives major releases pushed to main. + Beta tracks the beta branch for minor & patch releases. +
@@ -920,6 +938,25 @@
`; } + window.adminSaveSettings = async () => { + const btn = document.querySelector('#settings-form button[type=submit]'); + if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; } + const saves = [ + ['panel_name', document.getElementById('sf-panel-name')?.value?.trim()], + ['default_php', document.getElementById('sf-default-php')?.value], + ['default_nameserver1',document.getElementById('sf-ns1')?.value?.trim()], + ['default_nameserver2',document.getElementById('sf-ns2')?.value?.trim()], + ['update_channel', document.getElementById('sf-channel')?.value], + ].filter(([, v]) => v != null); + let ok = true; + for (const [key, value] of saves) { + const res = await Nova.api('system', 'save-option', { method: 'POST', body: { key, value } }); + if (!res?.success) { ok = false; Nova.toast(`Failed to save ${key}`, 'error'); break; } + } + if (ok) Nova.toast('Settings saved', 'success'); + if (btn) { btn.disabled = false; btn.textContent = 'Save Settings'; } + }; + // ── Accounts ─────────────────────────────────────────────────────────────── async function accounts() { const res = await Nova.api('accounts', 'list');