feat(update): add release policy checks and dashboard version verification

This commit is contained in:
Krystie
2026-05-04 18:05:00 -07:00
parent 0c658a26a8
commit f5fe32b999
3 changed files with 413 additions and 23 deletions

View File

@@ -14,8 +14,8 @@ const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { execSync } = require('child_process');
const zlib = require('zlib');
const platformPaths = require('./platform-paths');
const isWindows = platformPaths.isWindows;
@@ -31,6 +31,10 @@ const DEFAULTS = {
MAX_BACKUPS: 3,
HEALTH_TIMEOUT: 60000,
DOWNLOAD_TIMEOUT: 120000,
CHANNEL: 'stable',
INSTANCE_ID_FILE: platformPaths.isWindows
? path.join(platformPaths.caddyBase, 'instance-id')
: '/etc/dashcaddy/instance-id',
};
class SelfUpdater extends EventEmitter {
@@ -48,12 +52,15 @@ class SelfUpdater extends EventEmitter {
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
instanceIdFile: options.instanceIdFile || process.env.DASHCADDY_INSTANCE_ID_FILE || DEFAULTS.INSTANCE_ID_FILE,
};
this.status = 'idle'; // idle | checking | downloading | applying | waiting
this.checkTimer = null;
this.lastCheckTime = null;
this.lastCheckResult = null;
this.instanceId = this._loadOrCreateInstanceId();
// Ensure directories exist
this._ensureDirs();
@@ -80,7 +87,7 @@ class SelfUpdater extends EventEmitter {
}
}
// ── Version Info ──
// ── Version / Identity Info ──
getLocalVersion() {
try {
@@ -95,6 +102,18 @@ class SelfUpdater extends EventEmitter {
}
}
getInstanceInfo() {
return {
instanceId: this.instanceId,
channel: this.config.channel,
hostname: os.hostname(),
platform: process.platform,
arch: process.arch,
isWindows,
version: this.getLocalVersion(),
};
}
getStatus() {
return this.status;
}
@@ -122,10 +141,18 @@ class SelfUpdater extends EventEmitter {
}
const local = this.getLocalVersion();
const available = this._isNewer(local, remote);
const policy = this._evaluateReleasePolicy(local, remote);
const available = policy.eligible && policy.newer;
this.lastCheckTime = Date.now();
this.lastCheckResult = { available, local, remote, sourceUrl };
this.lastCheckResult = {
available,
local,
remote,
sourceUrl,
policy,
instance: this.getInstanceInfo(),
};
this.status = 'idle';
if (available) {
@@ -149,12 +176,17 @@ class SelfUpdater extends EventEmitter {
}
const local = this.getLocalVersion();
const policy = this._evaluateReleasePolicy(local, remoteInfo);
if (!policy.eligible) {
throw new Error(`Release not eligible for this instance: ${policy.reason}`);
}
const stagingDir = path.join(this.config.updatesDir, 'staging');
try {
// 1. Download (try primary, fallback to mirror)
this.status = 'downloading';
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version });
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version, policy });
const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
@@ -207,6 +239,8 @@ class SelfUpdater extends EventEmitter {
stagingDir: hostApiSrc,
apiSourceDir: this.config.apiSourceDir,
timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
};
await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'),
@@ -222,6 +256,8 @@ class SelfUpdater extends EventEmitter {
status: 'pending',
frontendUpdated: !!frontendSrc,
apiUpdated: true,
channel: this.config.channel,
instanceId: this.instanceId,
});
} else if (isWindows) {
// Windows: frontend updated, API needs manual restart
@@ -233,6 +269,8 @@ class SelfUpdater extends EventEmitter {
frontendUpdated: !!frontendSrc,
apiUpdated: false,
note: 'API update requires manual container restart on Windows',
channel: this.config.channel,
instanceId: this.instanceId,
});
this.status = 'idle';
}
@@ -246,6 +284,7 @@ class SelfUpdater extends EventEmitter {
toVersion: remoteInfo.version,
frontendUpdated: !!frontendSrc,
apiUpdated: !isWindows && !!apiSrc,
policy,
};
} catch (e) {
this.status = 'idle';
@@ -255,6 +294,8 @@ class SelfUpdater extends EventEmitter {
timestamp: new Date().toISOString(),
status: 'failed',
error: e.message,
channel: this.config.channel,
instanceId: this.instanceId,
});
throw e;
}
@@ -308,6 +349,8 @@ class SelfUpdater extends EventEmitter {
stagingDir: hostBackupDir,
apiSourceDir: this.config.apiSourceDir,
timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
};
this.status = 'waiting';
@@ -322,6 +365,8 @@ class SelfUpdater extends EventEmitter {
timestamp: new Date().toISOString(),
status: 'pending',
rollback: true,
channel: this.config.channel,
instanceId: this.instanceId,
});
}
@@ -362,20 +407,143 @@ class SelfUpdater extends EventEmitter {
}
}
_evaluateReleasePolicy(local, remote) {
const releaseChannel = remote?.channel || remote?.releaseChannel || 'stable';
const allowedChannels = Array.isArray(remote?.channels)
? remote.channels
: Array.isArray(remote?.eligibleChannels)
? remote.eligibleChannels
: [releaseChannel];
if (!allowedChannels.includes(this.config.channel)) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `channel mismatch (${this.config.channel} not in ${allowedChannels.join(', ')})`,
releaseChannel,
allowedChannels,
};
}
if (remote?.revoked === true) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: 'release revoked',
releaseChannel,
allowedChannels,
};
}
const minUpdaterVersion = remote?.minUpdaterVersion;
if (minUpdaterVersion && this._compareVersions(local.version, minUpdaterVersion) < 0) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `requires updater >= ${minUpdaterVersion}`,
releaseChannel,
allowedChannels,
};
}
const rollout = this._normalizeRollout(remote?.rollout);
if (rollout < 100) {
const bucket = this._getRolloutBucket(this.instanceId);
if (bucket >= rollout) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `outside rollout (${bucket} >= ${rollout})`,
releaseChannel,
allowedChannels,
rollout,
rolloutBucket: bucket,
};
}
}
const targets = remote?.targets;
if (targets && typeof targets === 'object') {
const platformKey = `${process.platform}-${process.arch}`;
const matchedTarget = targets[platformKey] || targets[process.platform] || targets.default;
if (!matchedTarget) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `no target for ${platformKey}`,
releaseChannel,
allowedChannels,
rollout,
};
}
}
return {
eligible: true,
newer: this._isNewer(local, remote),
reason: 'eligible',
releaseChannel,
allowedChannels,
rollout,
rolloutBucket: this._getRolloutBucket(this.instanceId),
};
}
_isNewer(local, remote) {
if (!remote || !remote.version) return false;
// Compare semver: split into [major, minor, patch]
const lv = (local.version || '0.0.0').split('.').map(Number);
const rv = remote.version.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((rv[i] || 0) > (lv[i] || 0)) return true;
if ((rv[i] || 0) < (lv[i] || 0)) return false;
}
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
if (versionCompare < 0) return true;
if (versionCompare > 0) return false;
// Same version — check commit hash
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
return false;
}
_compareVersions(a, b) {
const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const len = Math.max(av.length, bv.length, 3);
for (let i = 0; i < len; i++) {
const left = av[i] || 0;
const right = bv[i] || 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
_normalizeRollout(value) {
if (value == null) return 100;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 100;
return Math.max(0, Math.min(100, Math.floor(parsed)));
}
_getRolloutBucket(instanceId) {
const digest = crypto.createHash('sha256').update(String(instanceId || 'unknown')).digest();
return digest[0] % 100;
}
_loadOrCreateInstanceId() {
try {
if (fs.existsSync(this.config.instanceIdFile)) {
const existing = fs.readFileSync(this.config.instanceIdFile, 'utf8').trim();
if (existing) return existing;
}
} catch (_) {
// Fall through and regenerate
}
const instanceId = crypto.randomUUID();
try {
fs.mkdirSync(path.dirname(this.config.instanceIdFile), { recursive: true });
fs.writeFileSync(this.config.instanceIdFile, `${instanceId}\n`, 'utf8');
} catch (error) {
console.warn('[SelfUpdater] Failed to persist instance ID:', error.message);
}
return instanceId;
}
_addToHistory(entry) {
const history = this.getUpdateHistory();
history.unshift(entry);
@@ -527,6 +695,9 @@ const selfUpdater = new SelfUpdater({
hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
frontendDir: process.env.DASHCADDY_FRONTEND_DIR,
channel: process.env.DASHCADDY_UPDATE_CHANNEL,
instanceIdFile: process.env.DASHCADDY_INSTANCE_ID_FILE,
});
module.exports = selfUpdater;
module.exports.SelfUpdater = SelfUpdater;