feat(update): add release policy checks and dashboard version verification
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user