feat: implement user-controlled update channels (stable/RC)

Add comprehensive update channel system allowing users to choose between:
- Stable: Production releases only (default, safe)
- RC: Release candidate versions for testing fixes and new features

Key Features:
- Safe defaults: All users start on stable channel unless explicitly changed
- Clear UI warnings: RC selection shows warning about potential bugs
- Robust validation: Invalid values auto-correct to stable with warnings
- Security: URL validation prevents malicious downloads
- Bulletproof error handling: Handles all edge cases gracefully

Technical Implementation:
- UPDATE_CHANNEL environment variable (stable/rc)
- Enhanced UpdateManager with channel-specific GitHub API calls
- Frontend UI with clear channel selection and warnings
- Configuration persistence and validation
- Comprehensive edge case handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com
2025-06-04 10:49:22 +01:00
parent fa0aa6dfdc
commit fc1929c205
4 changed files with 221 additions and 22 deletions
+17 -8
View File
@@ -48,6 +48,7 @@ class ConfigApi {
advanced: {
metricInterval: config.PULSE_METRIC_INTERVAL_MS,
discoveryInterval: config.PULSE_DISCOVERY_INTERVAL_MS,
updateChannel: config.UPDATE_CHANNEL || 'stable',
alerts: {
cpu: {
enabled: config.ALERT_CPU_ENABLED !== 'false',
@@ -271,6 +272,14 @@ class ConfigApi {
// Update existing config with new values
Object.entries(config).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
// Special validation for UPDATE_CHANNEL
if (key === 'UPDATE_CHANNEL') {
const validChannels = ['stable', 'rc'];
if (!validChannels.includes(value)) {
console.warn(`WARN: Invalid UPDATE_CHANNEL value "${value}" in config. Skipping.`);
return; // Skip this invalid value
}
}
existingConfig[key] = value;
}
});
@@ -595,8 +604,8 @@ class ConfigApi {
if (keys.length > 0 && keys.some(key => config[key])) {
lines.push(`# ${groupName}`);
keys.forEach(key => {
if (config[key] !== undefined && config[key] !== '') {
const value = config[key];
if (config[key] !== undefined && config[key] !== '' && config[key] !== null) {
const value = String(config[key]); // Ensure value is a string
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
}
@@ -621,8 +630,8 @@ class ConfigApi {
`PROXMOX_ALLOW_SELF_SIGNED_CERTS_${index}`
];
orderedKeys.forEach(key => {
if (config[key] !== undefined && config[key] !== '') {
const value = config[key];
if (config[key] !== undefined && config[key] !== '' && config[key] !== null) {
const value = String(config[key]); // Ensure value is a string
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
}
@@ -646,8 +655,8 @@ class ConfigApi {
`PBS_ALLOW_SELF_SIGNED_CERTS_${index}`
];
orderedKeys.forEach(key => {
if (config[key] !== undefined && config[key] !== '') {
const value = config[key];
if (config[key] !== undefined && config[key] !== '' && config[key] !== null) {
const value = String(config[key]); // Ensure value is a string
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
}
@@ -662,8 +671,8 @@ class ConfigApi {
if (keys.length > 0 && keys.some(key => config[key])) {
lines.push(`# ${groupName}`);
keys.forEach(key => {
if (config[key] !== undefined && config[key] !== '') {
const value = config[key];
if (config[key] !== undefined && config[key] !== '' && config[key] !== null) {
const value = String(config[key]); // Ensure value is a string
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
}
+19 -2
View File
@@ -32,6 +32,19 @@ class ConfigurationError extends Error {
}
}
// Function to get update channel preference
function getUpdateChannelPreference() {
const updateChannel = process.env.UPDATE_CHANNEL || 'stable';
const validChannels = ['stable', 'rc'];
if (!validChannels.includes(updateChannel)) {
console.warn(`WARN: Invalid UPDATE_CHANNEL value "${updateChannel}". Using default "stable".`);
return 'stable';
}
return updateChannel;
}
// Function to load PBS configuration
function loadPbsConfig(index = null) {
const suffix = index ? `_${index}` : '';
@@ -278,8 +291,12 @@ function loadConfiguration() {
}
// console.log('INFO: Configuration loaded successfully.');
// Load update channel preference
const updateChannel = getUpdateChannelPreference();
// Return the flag along with endpoints and pbsConfigs
return { endpoints, pbsConfigs, isConfigPlaceholder };
return { endpoints, pbsConfigs, isConfigPlaceholder, updateChannel };
}
module.exports = { loadConfiguration, ConfigurationError }; // Export the function and error class
module.exports = { loadConfiguration, getUpdateChannelPreference, ConfigurationError }; // Export the function and error class
+114 -11
View File
@@ -4,6 +4,7 @@ const fs = require('fs').promises;
const path = require('path');
const { exec } = require('child_process');
const { promisify } = require('util');
const { getUpdateChannelPreference } = require('./configLoader');
const execAsync = promisify(exec);
class UpdateManager {
@@ -13,6 +14,47 @@ class UpdateManager {
this.updateInProgress = false;
}
/**
* Check if current version is a release candidate
*/
isReleaseCandidate(version) {
// Only use currentVersion as default if no argument is passed at all
const versionToCheck = (arguments.length === 0) ? this.currentVersion : version;
if (!versionToCheck || typeof versionToCheck !== 'string') {
return false;
}
return versionToCheck.includes('-rc') || versionToCheck.includes('-alpha') || versionToCheck.includes('-beta');
}
/**
* Validate download URL for security
*/
isValidDownloadUrl(downloadUrl) {
if (!downloadUrl || typeof downloadUrl !== 'string') {
return false;
}
try {
const url = new URL(downloadUrl);
// Allow test mode URLs
if (process.env.UPDATE_TEST_MODE === 'true' &&
url.hostname === 'localhost' &&
url.pathname.includes('/api/test/mock-update.tar.gz')) {
return true;
}
// Only allow HTTPS GitHub release asset URLs
return url.protocol === 'https:' &&
url.hostname === 'github.com' &&
url.pathname.includes('/releases/download/') &&
url.pathname.includes(`/${this.githubRepo}/`);
} catch (error) {
return false;
}
}
/**
* Check for available updates
*/
@@ -20,17 +62,72 @@ class UpdateManager {
try {
console.log('[UpdateManager] Checking for updates...');
// Fetch latest release from GitHub
const response = await axios.get(
`https://api.github.com/repos/${this.githubRepo}/releases/latest`,
{
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'Pulse-Update-Checker'
},
timeout: 10000
const updateChannel = getUpdateChannelPreference();
let response;
let channelDescription = '';
if (updateChannel === 'stable') {
// Stable channel: only check latest stable release
channelDescription = 'stable releases only';
console.log('[UpdateManager] Checking for stable releases...');
response = await axios.get(
`https://api.github.com/repos/${this.githubRepo}/releases/latest`,
{
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'Pulse-Update-Checker'
},
timeout: 10000
}
);
} else {
// RC channel: check all releases for RC versions
channelDescription = 'RC releases only';
console.log('[UpdateManager] Checking for RC releases...');
response = await axios.get(
`https://api.github.com/repos/${this.githubRepo}/releases?per_page=10`,
{
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'Pulse-Update-Checker'
},
timeout: 10000
}
);
// Find the latest RC release that's newer than current
let latestRelease = null;
const releases = response.data;
for (const release of releases) {
const releaseVersion = release.tag_name.replace('v', '');
const releaseIsRC = this.isReleaseCandidate(releaseVersion);
if (releaseIsRC && semver.gt(releaseVersion, this.currentVersion)) {
latestRelease = release;
break;
}
}
);
if (!latestRelease) {
// No newer RC version found
const updateInfo = {
currentVersion: this.currentVersion,
latestVersion: this.currentVersion,
updateAvailable: false,
isDocker: this.isDockerEnvironment(),
releaseNotes: 'No newer RC version available',
releaseUrl: null,
publishedAt: null,
assets: [],
updateChannel: channelDescription
};
console.log(`[UpdateManager] No RC updates available: ${this.currentVersion}`);
return updateInfo;
}
response.data = latestRelease;
}
const latestVersion = response.data.tag_name.replace('v', '');
const updateAvailable = semver.gt(latestVersion, this.currentVersion);
@@ -43,6 +140,7 @@ class UpdateManager {
releaseNotes: response.data.body || 'No release notes available',
releaseUrl: response.data.html_url,
publishedAt: response.data.published_at,
updateChannel: channelDescription,
assets: response.data.assets.map(asset => ({
name: asset.name,
size: asset.size,
@@ -50,7 +148,7 @@ class UpdateManager {
}))
};
console.log(`[UpdateManager] Current version: ${this.currentVersion}, Latest version: ${latestVersion}, Docker: ${updateInfo.isDocker}`);
console.log(`[UpdateManager] Current version: ${this.currentVersion}, Latest version: ${latestVersion}, Channel: ${channelDescription}, Docker: ${updateInfo.isDocker}`);
return updateInfo;
} catch (error) {
@@ -64,6 +162,11 @@ class UpdateManager {
*/
async downloadUpdate(downloadUrl, progressCallback) {
try {
// Validate download URL for security
if (!this.isValidDownloadUrl(downloadUrl)) {
throw new Error('Invalid download URL. Only GitHub release assets are allowed.');
}
console.log('[UpdateManager] Downloading update from:', downloadUrl);
const tempDir = path.join(__dirname, '..', 'temp');
+71 -1
View File
@@ -183,6 +183,13 @@ PulseApp.ui.settings = (() => {
} else if (activeTab === 'system') {
// Auto-check for latest version when system tab is opened
checkLatestVersion();
// Initialize update channel warning visibility
setTimeout(() => {
const channelSelect = document.querySelector('select[name="UPDATE_CHANNEL"]');
if (channelSelect) {
onUpdateChannelChange(channelSelect.value);
}
}, 0);
} else if (activeTab === 'alerts') {
// Load threshold configurations when alerts tab is opened
loadThresholdConfigurations();
@@ -716,6 +723,45 @@ PulseApp.ui.settings = (() => {
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Software Updates</h3>
<!-- Update Channel Preference -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="mb-4">
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">Update Channel</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Choose which types of updates to receive
</p>
<select name="UPDATE_CHANNEL" onchange="PulseApp.ui.settings.onUpdateChannelChange(this.value)"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="stable" ${(advanced.updateChannel || 'stable') === 'stable' ? 'selected' : ''}>
Stable - Production releases (recommended)
</option>
<option value="rc" ${(advanced.updateChannel || 'stable') === 'rc' ? 'selected' : ''}>
Release Candidate - Test fixes and new features
</option>
</select>
<div id="update-channel-description" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<div class="space-y-1">
<div><strong>Stable:</strong> Thoroughly tested releases for production use</div>
<div><strong>RC:</strong> Pre-release versions for testing - may contain bugs</div>
</div>
</div>
<div id="rc-warning" class="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg hidden">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<div>
<h5 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Release Candidate Warning</h5>
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">
RC versions are pre-release software for testing fixes and new features.
They may contain bugs. Only select this if you want to help test and provide feedback.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Auto-update Setting -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="flex items-center justify-between">
@@ -764,6 +810,7 @@ PulseApp.ui.settings = (() => {
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1">
Latest Version: <span id="latest-version" class="font-mono font-semibold text-gray-500 dark:text-gray-400">Checking...</span>
</p>
<p id="update-channel-info" class="text-sm text-gray-500 dark:text-gray-400 mt-1"></p>
<p id="version-status" class="text-sm mt-1"></p>
</div>
<button type="button" onclick="PulseApp.ui.settings.checkForUpdates()"
@@ -1336,6 +1383,7 @@ PulseApp.ui.settings = (() => {
async function checkLatestVersion() {
const latestVersionElement = document.getElementById('latest-version');
const versionStatusElement = document.getElementById('version-status');
const updateChannelInfoElement = document.getElementById('update-channel-info');
if (!latestVersionElement) return;
@@ -1343,6 +1391,10 @@ PulseApp.ui.settings = (() => {
latestVersionElement.textContent = 'Checking...';
latestVersionElement.className = 'font-mono font-semibold text-gray-500 dark:text-gray-400';
if (updateChannelInfoElement) {
updateChannelInfoElement.textContent = '';
}
// Use the server's update check API to get proper update info
const response = await fetch('/api/updates/check');
const data = await response.json();
@@ -1358,6 +1410,11 @@ PulseApp.ui.settings = (() => {
currentConfig.version = data.currentVersion;
}
// Display update channel information
if (updateChannelInfoElement && data.updateChannel) {
updateChannelInfoElement.textContent = `Update channel: ${data.updateChannel}`;
}
if (data.updateAvailable) {
// Update available
latestVersionElement.className = 'font-mono font-semibold text-green-600 dark:text-green-400';
@@ -2637,6 +2694,18 @@ PulseApp.ui.settings = (() => {
}
// Public API
// Handle update channel selection change
function onUpdateChannelChange(value) {
const rcWarning = document.getElementById('rc-warning');
if (rcWarning) {
if (value === 'rc') {
rcWarning.classList.remove('hidden');
} else {
rcWarning.classList.add('hidden');
}
}
}
return {
init,
openModal,
@@ -2651,7 +2720,8 @@ PulseApp.ui.settings = (() => {
changeTheme,
runDiagnostics,
copyDiagnosticReport,
downloadDiagnosticReport
downloadDiagnosticReport,
onUpdateChannelChange
};
})();