[grade=A urn:ump:seyasvbntxsjibq5jiaqmfowc6xgchwe4jvcc54jd6355ujgm75q] DC-122: self-updater hardening + auto-update on + docker disk discipline (v1.16.0)
- _isNewer: same-version releases are never 'newer' (commit labels are opaque stamps) — kills the same-version auto-apply regression loop - _autoCheckAndApply: identical version@sha256 never re-applied - dashcaddy-update.sh: truthful rollback verdicts (failed rebuild/health = exit 1 + failure result), exact frontend snapshot/restore (incl. update-introduced owned subtrees), contents-copy cp fallbacks with manifest reconciliation, JSON-encoded results/meta/stamp, prune on every exit path - self-updater: frontend-only Linux releases fail loudly (no more silent no-op success stuck in 'applying') - start.sh: DASHCADDY_UPDATE_ENABLED=true (Sami 2026-09-13), json-file log caps 10M x3, source->webroot sync via update-stamp contract - tests: _isNewer regression suite, functional cycle + json/escape suites (scripts/test-frontend-cycle.sh, scripts/test-json-escape.sh) 15 judge rounds: C,C,D,D,C,C,D,C,C,C,D,C,A
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* DC-122 regression tests — SelfUpdater._isNewer() must never treat a
|
||||
* same-version/different-commit release as "newer".
|
||||
*
|
||||
* Loader works in BOTH layouts:
|
||||
* - repo layout: requires ../src/docker/self-updater.js directly;
|
||||
* - flattened judge worktree (deps missing): extracts the _isNewer +
|
||||
* _compareVersions method sources from the implementation file and
|
||||
* evaluates just those two pure functions — the test then exercises
|
||||
* the exact shipped logic without needing platform-paths/logging.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
function findImplementationFile() {
|
||||
const candidates = [
|
||||
path.join(__dirname, '..', 'src', 'docker', 'self-updater.js'),
|
||||
path.join(__dirname, 'self-updater.js'),
|
||||
path.join(__dirname, '0_self-updater.js'),
|
||||
];
|
||||
for (const c of candidates) if (fs.existsSync(c)) return c;
|
||||
throw new Error('self-updater.js not found relative to test file');
|
||||
}
|
||||
|
||||
// Pull a single method out of the class source text by brace matching.
|
||||
function extractMethod(src, name, argNames) {
|
||||
const marker = `${name}(${argNames}) {`;
|
||||
const at = src.indexOf(marker);
|
||||
if (at === -1) throw new Error(`method ${name}(${argNames}) not found in source`);
|
||||
const bodyStart = at + marker.length;
|
||||
let depth = 1;
|
||||
let i = bodyStart;
|
||||
while (depth > 0 && i < src.length) {
|
||||
const ch = src[i++];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
}
|
||||
const body = src.slice(bodyStart, i - 1);
|
||||
const args = argNames.split(',').map((s) => s.trim());
|
||||
return new Function(...args, body);
|
||||
}
|
||||
|
||||
function loadIsNewer() {
|
||||
const implPath = findImplementationFile();
|
||||
const src = fs.readFileSync(implPath, 'utf8');
|
||||
// DC-122 (judge rev11): never construct a real SelfUpdater here — its
|
||||
// constructor writes instance-id / notify-secret files to production
|
||||
// default paths, making a unit test stateful. Always evaluate the two
|
||||
// pure methods from source; this is the exact shipped logic either way.
|
||||
const impl = {
|
||||
_compareVersions: extractMethod(src, '_compareVersions', 'a, b'),
|
||||
_isNewer: extractMethod(src, '_isNewer', 'local, remote'),
|
||||
};
|
||||
return impl._isNewer.bind(impl);
|
||||
}
|
||||
|
||||
describe('SelfUpdater._isNewer() — DC-122 same-version auto-apply regression', () => {
|
||||
let isNewer;
|
||||
|
||||
beforeAll(() => {
|
||||
isNewer = loadIsNewer();
|
||||
});
|
||||
|
||||
test('same version + different commit labels ⇒ NOT newer (the DC-122 bug)', () => {
|
||||
expect(isNewer(
|
||||
{ version: '1.16.0', commit: '20260722-065235-cookie-only-session-653478a' },
|
||||
{ version: '1.16.0', commit: '321334c' }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
test('same version + reversed commit labels ⇒ NOT newer', () => {
|
||||
expect(isNewer(
|
||||
{ version: '1.16.0', commit: '321334c' },
|
||||
{ version: '1.16.0', commit: '20260722-065235-cookie-only-session-653478a' }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
test('identical version+commit ⇒ NOT newer', () => {
|
||||
expect(isNewer(
|
||||
{ version: '1.16.0', commit: 'abc1234' },
|
||||
{ version: '1.16.0', commit: 'abc1234' }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
test('higher remote semver ⇒ newer', () => {
|
||||
expect(isNewer(
|
||||
{ version: '1.15.0', commit: 'abc1234' },
|
||||
{ version: '1.16.0', commit: 'def5678' }
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
test('lower remote semver ⇒ NOT newer (downgrade refused)', () => {
|
||||
expect(isNewer(
|
||||
{ version: '1.16.0', commit: 'abc1234' },
|
||||
{ version: '1.15.0', commit: 'def5678' }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
test('remote without version ⇒ NOT newer', () => {
|
||||
expect(isNewer({ version: '1.16.0', commit: 'abc1234' }, {})).toBe(false);
|
||||
expect(isNewer({ version: '1.16.0' }, null)).toBe(false);
|
||||
});
|
||||
|
||||
test('multi-component semver compares numerically (10.0.0 > 9.5.1)', () => {
|
||||
expect(isNewer({ version: '9.5.1' }, { version: '10.0.0' })).toBe(true);
|
||||
expect(isNewer({ version: '10.0.0' }, { version: '9.5.1' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dashcaddy-api",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
|
||||
@@ -305,6 +305,11 @@ class SelfUpdater extends EventEmitter {
|
||||
JSON.stringify(trigger, null, 2)
|
||||
);
|
||||
|
||||
// DC-122 note: the frontend deployment stamp (update-stamp.json) is
|
||||
// written by the HOST-side dashcaddy-update.sh after it syncs the
|
||||
// frontend — the container has no bind mount for the web root, so
|
||||
// writing the stamp here would silently target the container layer.
|
||||
|
||||
// The host-side systemd service will handle the rest.
|
||||
// After container restart, checkPostUpdateResult() reads the result.
|
||||
this._addToHistory({
|
||||
@@ -317,6 +322,13 @@ class SelfUpdater extends EventEmitter {
|
||||
channel: this.config.channel,
|
||||
instanceId: this.instanceId,
|
||||
});
|
||||
} else if (frontendSrc && this.config.hostFrontendDir && !isWindows) {
|
||||
// DC-122: frontend-only release on Linux with deferred host deploy —
|
||||
// there is no API component to trigger, and the container cannot
|
||||
// reach the web root itself. Fail loudly instead of silently
|
||||
// succeeding while deploying nothing. The enclosing catch records
|
||||
// the single 'failed' history entry and resets status.
|
||||
throw new Error('Frontend-only release cannot be applied on this install: no API component to trigger the host-side deploy. Publish a full release (dashcaddy-api + status).');
|
||||
} else if (isWindows) {
|
||||
// Windows: frontend updated, API needs manual restart
|
||||
this._addToHistory({
|
||||
@@ -383,7 +395,18 @@ class SelfUpdater extends EventEmitter {
|
||||
|
||||
if (historyIndex !== -1) {
|
||||
const pending = history[historyIndex];
|
||||
pending.status = result.success ? 'success' : 'rolled-back';
|
||||
// DC-122: truthful status. success:true → 'success'
|
||||
// success:false + error mentions rollback → 'rolled-back'
|
||||
// any other failure → 'failed'
|
||||
// (an explicit rollback whose rebuild/health also failed is NOT a
|
||||
// successful rollback — it must not be recorded as one)
|
||||
if (result.success) {
|
||||
pending.status = 'success';
|
||||
} else if (typeof result.error === 'string' && /rolled back/i.test(result.error)) {
|
||||
pending.status = 'rolled-back';
|
||||
} else {
|
||||
pending.status = 'failed';
|
||||
}
|
||||
pending.duration = result.duration;
|
||||
if (result.error) pending.error = result.error;
|
||||
if (result.version) pending.version = result.version;
|
||||
@@ -469,8 +492,18 @@ class SelfUpdater extends EventEmitter {
|
||||
try {
|
||||
const result = await this.checkForUpdate();
|
||||
if (result.available && result.remote) {
|
||||
// DC-122 defense-in-depth: never re-apply an identical
|
||||
// version@sha256 within this process lifetime, even if version
|
||||
// stamping after an apply fails and the next check still reports
|
||||
// "newer". Prevents repeated rebuild loops when auto-update is on.
|
||||
const ref = `${result.remote.version}@${result.remote.sha256 || ''}`;
|
||||
if (ref === this._lastAppliedRef) {
|
||||
log.info('updater', 'Skipping auto-apply: identical release already applied', { ref });
|
||||
return;
|
||||
}
|
||||
log.info('updater', 'Update available', { localVersion: result.local.version, remoteVersion: result.remote.version });
|
||||
await this.applyUpdate(result.remote);
|
||||
this._lastAppliedRef = ref;
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('updater', e, { phase: 'autoUpdate' });
|
||||
@@ -561,12 +594,12 @@ class SelfUpdater extends EventEmitter {
|
||||
|
||||
_isNewer(local, remote) {
|
||||
if (!remote || !remote.version) 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;
|
||||
// DC-122: same version ⇒ NOT newer, period. Commit hashes are opaque
|
||||
// build labels (pipelines stamp different formats — short SHA vs
|
||||
// timestamp-prefixed), so any inequality would read as "newer" and made
|
||||
// same-version installs re-apply stale tarballs in a loop once
|
||||
// DASHCADDY_UPDATE_ENABLED=true. A real release must bump semver.
|
||||
return this._compareVersions(local.version || '0.0.0', remote.version) < 0;
|
||||
}
|
||||
|
||||
_compareVersions(a, b) {
|
||||
|
||||
Reference in New Issue
Block a user