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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:44:39 +00:00
parent d53eb309eb
commit 9cabe8af5e
4 changed files with 112 additions and 44 deletions
+17 -4
View File
@@ -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"
+6 -4
View File
@@ -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);
+40 -24
View File
@@ -221,12 +221,15 @@ BASH;
$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}");
})(),
+48 -11
View File
@@ -300,8 +300,8 @@
<div class="card-body">
<div class="grid-4 mb-3">
<div><p class="text-muted text-sm">Installed</p><p class="font-bold">${v.installed_version || ''}</p></div>
<div><p class="text-muted text-sm">Commit</p><code>${ncpx.current_commit || v.git_commit || ''}</code></div>
<div><p class="text-muted text-sm">Branch</p><code>${ncpx.branch || 'main'}</code></div>
<div><p class="text-muted text-sm">Latest (${ncpx.channel || 'stable'})</p><p class="font-bold">${ncpx.remote_version || (ncpxCount > 0 ? 'available' : v.installed_version || '')}</p></div>
<div><p class="text-muted text-sm">Channel</p>${Nova.badge(ncpx.channel || 'stable', ncpx.channel === 'beta' ? 'yellow' : 'green')}</div>
<div><p class="text-muted text-sm">PHP</p><code>${v.php_version || ''}</code></div>
</div>
@@ -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 =>
`<option value="${v}" ${v === cur.default_php ? 'selected' : ''}>${v}</option>`).join('');
const chanOpts = [
['stable', 'Stable — major releases (main branch)'],
['beta', 'Beta — minor &amp; patch releases (beta branch)'],
].map(([v, l]) => `<option value="${v}" ${v === cur.channel ? 'selected' : ''}>${l}</option>`).join('');
return `
<div class="card">
<div class="card-header"><span class="card-title">Panel Settings</span></div>
<div class="card-body">
<form id="settings-form">
<form id="settings-form" onsubmit="event.preventDefault();adminSaveSettings()">
<div class="grid-2">
<div class="form-group"><label>Panel Name</label><input type="text" name="panel_name" value="NovaCPX"></div>
<div class="form-group"><label>Panel Name</label><input type="text" id="sf-panel-name" value="${Nova.escHtml(cur.panel_name)}"></div>
<div class="form-group"><label>Default PHP Version</label>
<select name="default_php">
${['7.4','8.1','8.2','8.3'].map(v => `<option value="${v}" ${v==='8.3'?'selected':''}>${v}</option>`).join('')}
</select>
<select id="sf-default-php">${phpOpts}</select>
</div>
<div class="form-group"><label>Primary Nameserver</label><input type="text" id="sf-ns1" value="${Nova.escHtml(cur.ns1)}" placeholder="ns1.example.com"></div>
<div class="form-group"><label>Secondary Nameserver</label><input type="text" id="sf-ns2" value="${Nova.escHtml(cur.ns2)}" placeholder="ns2.example.com"></div>
<div class="form-group" style="grid-column:1/-1">
<label>Update Channel</label>
<select id="sf-channel">${chanOpts}</select>
<div class="form-hint" style="margin-top:.35rem;font-size:.77rem;color:var(--text-muted)">
<strong>Stable</strong> receives major releases pushed to <code>main</code>.
<strong>Beta</strong> tracks the <code>beta</code> branch for minor &amp; patch releases.
</div>
<div class="form-group"><label>Primary Nameserver</label><input type="text" name="default_nameserver1" value="ns1.example.com"></div>
<div class="form-group"><label>Secondary Nameserver</label><input type="text" name="default_nameserver2" value="ns2.example.com"></div>
<div class="form-group"><label>Update Channel</label>
<select name="update_channel"><option value="stable">Stable</option><option value="beta">Beta</option></select>
</div>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
@@ -920,6 +938,25 @@
</div>`;
}
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');