fix(runtime): synchronize lockfile after builtin plugin migration

This commit is contained in:
2026-10-18 11:44:15 +08:00
parent 2e31e74972
commit b2d97dfda2
7 changed files with 64 additions and 10 deletions
+4
View File
@@ -2,6 +2,10 @@
## runtime-7.9:DSH next 与启动配置迁移 ## runtime-7.9:DSH next 与启动配置迁移
锁文件同步修复:内置依赖迁移或补建 workspace 配置前,先持久化 `.dsh-lockfile-pending` 标记。仅带标记的启动安装显式传入 `--no-frozen-lockfile`,同步 `pnpm-lock.yaml`;成功后清除标记,后续启动恢复 CI 默认冻结检查。安装失败或超时保留标记并阻止应用启动,下次启动继续同步,不自动恢复旧 manifest。已有 manifest 备份机制保持不变。若旧版失败迁移已经留下“新 manifest+旧锁文件”且没有标记,应先恢复匹配的旧 profile 备份再升级;不要直接删除锁文件。
本次修复验证:45 项自动化测试通过;`node image/lockfile-upgrade-smoke.mjs` 使用本地 `dsh-upgrade-test:runtime-7.8` 与 `dsh-web:runtime-7.9-amd64` 镜像、独立临时卷、禁网非 root 容器,挂载当前修复脚本,验证真实旧锁文件迁移、随后冻结安装成功及重启锁文件哈希不变。脚本结束删除测试卷。本次未重跑面板 HTTP 升级接口或首页验收,未发布新镜像。
DSH 固定为 `0.2.1-rc.2`(本次明确选择 npm `next`,查询时 `latest` 仍为 `0.2.0-rc.2`),mobile 升级为 `3.1.0`,Chat Manager 升级为明确声明支持新宿主的 `1.5.16-dsh-mgr.1`。free-search 升级为 `0.9.2`,workspace `1.0.0`、preview-download `1.0.1` 保持不变。 DSH 固定为 `0.2.1-rc.2`(本次明确选择 npm `next`,查询时 `latest` 仍为 `0.2.0-rc.2`),mobile 升级为 `3.1.0`,Chat Manager 升级为明确声明支持新宿主的 `1.5.16-dsh-mgr.1`。free-search 升级为 `0.9.2`,workspace `1.0.0`、preview-download `1.0.1` 保持不变。
现有 runtime 每次启动时检查 `profiles/web/package.json`,不另加容器、不联网追踪最新版。仅将 dependencies/devDependencies/optionalDependencies 中同名 `file:/usr/local/lib/dsh-mgr/<包名>-<版本>.tgz` 引用对齐镜像内置清单,并补齐相关缺失的 pnpm overrides。保留自定义安装来源、自定义 overrides、bundles、已删除的插件和其他配置。目标归档缺失、无效配置或不安全的符号链接会阻止启动。 现有 runtime 每次启动时检查 `profiles/web/package.json`,不另加容器、不联网追踪最新版。仅将 dependencies/devDependencies/optionalDependencies 中同名 `file:/usr/local/lib/dsh-mgr/<包名>-<版本>.tgz` 引用对齐镜像内置清单,并补齐相关缺失的 pnpm overrides。保留自定义安装来源、自定义 overrides、bundles、已删除的插件和其他配置。目标归档缺失、无效配置或不安全的符号链接会阻止启动。
+9 -1
View File
@@ -11,7 +11,15 @@ if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then
if node -e 'import("/usr/local/lib/dsh-mgr/reconcile-profile.mjs").then(({BUILTIN_DEPENDENCIES:pins,BUILTIN_OVERRIDES:overrides})=>{const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); process.exit([m.dependencies,m.devDependencies,m.optionalDependencies].every(section=>Object.entries(section||{}).every(([n,v])=>pins[n]===v))&&Object.entries(m.pnpm?.overrides||{}).every(([n,v])=>overrides[n]===v)?0:1)})'; then if node -e 'import("/usr/local/lib/dsh-mgr/reconcile-profile.mjs").then(({BUILTIN_DEPENDENCIES:pins,BUILTIN_OVERRIDES:overrides})=>{const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); process.exit([m.dependencies,m.devDependencies,m.optionalDependencies].every(section=>Object.entries(section||{}).every(([n,v])=>pins[n]===v))&&Object.entries(m.pnpm?.overrides||{}).every(([n,v])=>overrides[n]===v)?0:1)})'; then
install_env=(npm_config_offline=true) install_env=(npm_config_offline=true)
fi fi
if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then # Only migration installs may refresh the lockfile. Keep the marker on failure
# so a restart retries even when package.json already contains the new pins.
pending_lockfile="${DSH_HOME}/profiles/web/.dsh-lockfile-pending"
install_args=()
if [[ -f "$pending_lockfile" ]]; then
install_args+=(--no-frozen-lockfile)
fi
if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts "${install_args[@]}"; then
rm -f -- "$pending_lockfile"
echo "dsh-mgr: plugin prestart reconciliation complete" >&2 echo "dsh-mgr: plugin prestart reconciliation complete" >&2
else else
status=$? status=$?
+8 -4
View File
@@ -1,18 +1,20 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
for (const status of ['0', '23', '124', '137', 'empty']) { for (const status of ['0', '23', '124', '137', 'empty']) {
test('runtime prestart status ' + status, () => { for (const pending of [false, true]) {
test('runtime prestart status ' + status + ', pending migration ' + pending, () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-prestart-')); const dir = mkdtempSync(join(tmpdir(), 'dsh-prestart-'));
try { try {
if (status !== 'empty') { if (status !== 'empty') {
mkdirSync(join(dir, 'profiles/web'), { recursive: true, mode: 0o700 }); mkdirSync(join(dir, 'profiles/web'), { recursive: true, mode: 0o700 });
writeFileSync(join(dir, 'profiles/web/package.json'), '{}', { mode: 0o600 }); writeFileSync(join(dir, 'profiles/web/package.json'), '{}', { mode: 0o600 });
if (pending) writeFileSync(join(dir, 'profiles/web/.dsh-lockfile-pending'), 'pending');
} }
const scripts = { const scripts = {
node: `#!/bin/bash node: `#!/bin/bash
@@ -28,7 +30,7 @@ exec "$@"
printf 'dsh %s\n' "$*" >> "$TRACE" printf 'dsh %s\n' "$*" >> "$TRACE"
test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91 test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91
if [ "$1" = plugin ]; then if [ "$1" = plugin ]; then
test "$*" = "plugin --profile web install --ignore-scripts" || exit 92 test "$*" = "plugin --profile web install --ignore-scripts$EXPECTED_ARGS" || exit 92
exit "$PLUGIN_STATUS" exit "$PLUGIN_STATUS"
fi fi
exit 0 exit 0
@@ -42,7 +44,7 @@ sleep 0.1
writeFileSync(join(dir, name), script, { mode: 0o700 }); writeFileSync(join(dir, name), script, { mode: 0o700 });
} }
const result = spawnSync('bash', [fileURLToPath(new URL('./entrypoint.sh', import.meta.url)), 'web', '--no-open', '--port', '3080'], { const result = spawnSync('bash', [fileURLToPath(new URL('./entrypoint.sh', import.meta.url)), 'web', '--no-open', '--port', '3080'], {
env: { ...process.env, PATH: dir + ':' + process.env.PATH, DSH_HOME: dir, TRACE: join(dir, 'trace'), PLUGIN_STATUS: status }, env: { ...process.env, PATH: dir + ':' + process.env.PATH, DSH_HOME: dir, TRACE: join(dir, 'trace'), PLUGIN_STATUS: status, EXPECTED_ARGS: pending ? ' --no-frozen-lockfile' : '' },
encoding: 'utf8', timeout: 5000, encoding: 'utf8', timeout: 5000,
}); });
assert.ifError(result.error); assert.ifError(result.error);
@@ -55,6 +57,7 @@ sleep 0.1
return; return;
} }
assert.match(trace, /dsh plugin --profile web install --ignore-scripts/); assert.match(trace, /dsh plugin --profile web install --ignore-scripts/);
assert.equal(existsSync(join(dir, 'profiles/web/.dsh-lockfile-pending')), pending && status !== '0');
if (status === '0') { if (status === '0') {
assert.match(output, /reconciliation complete/); assert.match(output, /reconciliation complete/);
assert.match(trace, /dsh web/); assert.match(trace, /dsh web/);
@@ -66,6 +69,7 @@ sleep 0.1
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
} }
}); });
}
} }
test('runtime pins pnpm and uses nonroot with tini process-group forwarding', () => { test('runtime pins pnpm and uses nonroot with tini process-group forwarding', () => {
+2 -1
View File
@@ -1,7 +1,7 @@
import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'; import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES, assertSafeProfile, reconcileProfile } from './reconcile-profile.mjs'; import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES, assertSafeProfile, reconcileProfile, markLockfilePending } from './reconcile-profile.mjs';
export function initProfile(home, options) { export function initProfile(home, options) {
if (!home) throw new Error('DSH_HOME required'); if (!home) throw new Error('DSH_HOME required');
const profile = join(home, 'profiles/web'); const profile = join(home, 'profiles/web');
@@ -27,6 +27,7 @@ export function initProfile(home, options) {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (['dependencies', 'devDependencies', 'optionalDependencies'].some(section => if (['dependencies', 'devDependencies', 'optionalDependencies'].some(section =>
['dsh-chat-manager', 'dsh-web-mobile', 'dsh-free-search'].some(name => manifest[section]?.[name] === BUILTIN_DEPENDENCIES[name]))) { ['dsh-chat-manager', 'dsh-web-mobile', 'dsh-free-search'].some(name => manifest[section]?.[name] === BUILTIN_DEPENDENCIES[name]))) {
markLockfilePending(profile);
writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 }); writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 });
} }
} }
+23
View File
@@ -0,0 +1,23 @@
// Local Docker integration: no published ports or existing workspaces are used.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
const volume = `dsh-lockfile-smoke-${randomUUID()}`;
const docker = args => execFileSync('docker', args, { encoding: 'utf8', timeout: 180000, maxBuffer: 8 * 1024 * 1024 });
const run = (image, args, mounts = []) => docker(['run', '--rm', '--network', 'none', '-v', `${volume}:/workspace`, ...mounts, '--entrypoint', 'bash', image, '-ec', args]);
docker(['volume', 'create', volume]);
try {
run('dsh-upgrade-test:runtime-7.8', 'node /usr/local/lib/dsh-mgr/init-profile.mjs; CI=true npm_config_offline=true dsh plugin --profile web install --ignore-scripts; test -s /workspace/profiles/web/pnpm-lock.yaml');
const mounts = ['init-profile.mjs', 'reconcile-profile.mjs'].flatMap(name => ['-v', `${fileURLToPath(new URL(name, import.meta.url))}:/usr/local/lib/dsh-mgr/${name}:ro`]);
mounts.push('-v', `${fileURLToPath(new URL('entrypoint.sh', import.meta.url))}:/usr/local/bin/dsh-entrypoint:ro`);
const output = run('dsh-web:runtime-7.9-amd64', 'bash /usr/local/bin/dsh-entrypoint --version; test ! -e /workspace/profiles/web/.dsh-lockfile-pending; CI=true npm_config_offline=true dsh plugin --profile web install --ignore-scripts', mounts);
assert.doesNotMatch(output, /ERR_PNPM_OUTDATED_LOCKFILE/);
const again = run('dsh-web:runtime-7.9-amd64', 'sha256sum /workspace/profiles/web/pnpm-lock.yaml; bash /usr/local/bin/dsh-entrypoint --version; sha256sum /workspace/profiles/web/pnpm-lock.yaml', mounts);
const hashes = again.match(/^[a-f0-9]{64} .*pnpm-lock.yaml$/gm);
assert.equal(hashes?.length, 2, again);
assert.equal(hashes[0], hashes[1]);
console.log('PASS: real 7.8 lockfile → 7.9 migration, frozen reinstall, unchanged lockfile on restart');
} finally {
docker(['volume', 'rm', volume]);
}
+13 -1
View File
@@ -16,6 +16,16 @@ export const BUILTIN_OVERRIDES = Object.freeze({
'@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz', '@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz',
}); });
const sections = ['dependencies', 'devDependencies', 'optionalDependencies']; const sections = ['dependencies', 'devDependencies', 'optionalDependencies'];
export const LOCKFILE_PENDING = '.dsh-lockfile-pending';
export function markLockfilePending(profile) {
assertSafeProfile(profile);
const fd = openSync(join(profile, LOCKFILE_PENDING), 'w', 0o600);
try { writeFileSync(fd, 'builtin profile migration\n'); fsyncSync(fd); }
finally { closeSync(fd); }
const directoryFd = openSync(profile, 'r');
try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
}
const overrideOwners = ['dsh-chat-manager', 'dsh-free-search', 'dsh-free-search', 'dsh-free-search']; const overrideOwners = ['dsh-chat-manager', 'dsh-free-search', 'dsh-free-search', 'dsh-free-search'];
const semver = '(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?'; const semver = '(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?';
@@ -72,7 +82,7 @@ export function assertSafeProfile(profile) {
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`unsafe profile directory: ${dir}`); if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`unsafe profile directory: ${dir}`);
if (dirname(dir) === dir) break; if (dirname(dir) === dir) break;
} }
for (const name of ['package.json', 'pnpm-workspace.yaml']) { for (const name of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', LOCKFILE_PENDING]) {
const path = join(profile, name); const path = join(profile, name);
const stat = statIfPresent(path); const stat = statIfPresent(path);
if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw new Error(`unsafe profile file: ${path}`); if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw new Error(`unsafe profile file: ${path}`);
@@ -111,6 +121,8 @@ export function reconcileProfile(profile, { archiveDirectory = '/usr/local/lib/d
fsyncSync(fd); fsyncSync(fd);
} finally { closeSync(fd); } } finally { closeSync(fd); }
assertSafeProfile(profile); assertSafeProfile(profile);
// Persist before changing the manifest, including across failed installs/restarts.
markLockfilePending(profile);
renameSync(temporaryPath, path); renameSync(temporaryPath, path);
temporaryCreated = false; temporaryCreated = false;
syncDirectory(); syncDirectory();
+5 -3
View File
@@ -49,7 +49,8 @@ test('migrates all sections, backs up exact bytes, replaces atomically with 0600
assert.deepEqual(f.run(), { changed: false }); assert.deepEqual(f.run(), { changed: false });
assert.equal(statSync(f.path).ino, before.ino); assert.equal(statSync(f.path).ino, before.ino);
assert.equal(statSync(f.path).mtimeMs, before.mtimeMs); assert.equal(statSync(f.path).mtimeMs, before.mtimeMs);
assert.equal(readdirSync(f.profile).length, 2); assert.equal(readdirSync(f.profile).length, 3);
assert.equal(readFileSync(join(f.profile, '.dsh-lockfile-pending'), 'utf8'), 'builtin profile migration\n');
writeFileSync(f.path, f.original); writeFileSync(f.path, f.original);
assert.notEqual(f.run().backupPath, first.backupPath); assert.notEqual(f.run().backupPath, first.backupPath);
assert.equal(readFileSync(first.backupPath, 'utf8'), f.original); assert.equal(readFileSync(first.backupPath, 'utf8'), f.original);
@@ -105,8 +106,9 @@ const durableWriteOrder = [
'open:backup', 'write:backup', 'fsync:backup', 'close:backup', 'open:backup', 'write:backup', 'fsync:backup', 'close:backup',
'open:directory-1', 'fsync:directory-1', 'close:directory-1', 'open:directory-1', 'fsync:directory-1', 'close:directory-1',
'open:temporary', 'write:temporary', 'fsync:temporary', 'close:temporary', 'open:temporary', 'write:temporary', 'fsync:temporary', 'close:temporary',
'rename:manifest',
'open:directory-2', 'fsync:directory-2', 'close:directory-2', 'open:directory-2', 'fsync:directory-2', 'close:directory-2',
'rename:manifest',
'open:directory-3', 'fsync:directory-3', 'close:directory-3',
]; ];
test('syncs backup and directory before replacement, then syncs the renamed manifest directory', t => { test('syncs backup and directory before replacement, then syncs the renamed manifest directory', t => {
@@ -201,7 +203,7 @@ test('invalid JSON and malformed sections fail without changing original', t =>
}); });
test('rejects manifest/workspace symlinks, dangling links and directories', t => { test('rejects manifest/workspace symlinks, dangling links and directories', t => {
for (const name of ['package.json', 'pnpm-workspace.yaml']) { for (const name of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', '.dsh-lockfile-pending']) {
for (const kind of ['symlink', 'dangling', 'directory']) { for (const kind of ['symlink', 'dangling', 'directory']) {
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } }); const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
const target = join(f.root, 'outside'); const target = join(f.root, 'outside');