diff --git a/README.md b/README.md index 03af66b..df6b7bc 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ ## 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` 保持不变。 现有 runtime 每次启动时检查 `profiles/web/package.json`,不另加容器、不联网追踪最新版。仅将 dependencies/devDependencies/optionalDependencies 中同名 `file:/usr/local/lib/dsh-mgr/<包名>-<版本>.tgz` 引用对齐镜像内置清单,并补齐相关缺失的 pnpm overrides。保留自定义安装来源、自定义 overrides、bundles、已删除的插件和其他配置。目标归档缺失、无效配置或不安全的符号链接会阻止启动。 diff --git a/image/entrypoint.sh b/image/entrypoint.sh index eff7a1a..d281556 100644 --- a/image/entrypoint.sh +++ b/image/entrypoint.sh @@ -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 install_env=(npm_config_offline=true) 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 else status=$? diff --git a/image/entrypoint.test.mjs b/image/entrypoint.test.mjs index bf754fa..1ec37a9 100644 --- a/image/entrypoint.test.mjs +++ b/image/entrypoint.test.mjs @@ -1,18 +1,20 @@ import { test } from 'node:test'; 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 { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; 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-')); try { if (status !== 'empty') { mkdirSync(join(dir, 'profiles/web'), { recursive: true, mode: 0o700 }); writeFileSync(join(dir, 'profiles/web/package.json'), '{}', { mode: 0o600 }); + if (pending) writeFileSync(join(dir, 'profiles/web/.dsh-lockfile-pending'), 'pending'); } const scripts = { node: `#!/bin/bash @@ -28,7 +30,7 @@ exec "$@" printf 'dsh %s\n' "$*" >> "$TRACE" test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91 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" fi exit 0 @@ -42,7 +44,7 @@ sleep 0.1 writeFileSync(join(dir, name), script, { mode: 0o700 }); } 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, }); assert.ifError(result.error); @@ -55,6 +57,7 @@ sleep 0.1 return; } 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') { assert.match(output, /reconciliation complete/); assert.match(trace, /dsh web/); @@ -66,6 +69,7 @@ sleep 0.1 rmSync(dir, { recursive: true, force: true }); } }); + } } test('runtime pins pnpm and uses nonroot with tini process-group forwarding', () => { diff --git a/image/init-profile.mjs b/image/init-profile.mjs index f7755e6..138e5e5 100644 --- a/image/init-profile.mjs +++ b/image/init-profile.mjs @@ -1,7 +1,7 @@ import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; 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) { if (!home) throw new Error('DSH_HOME required'); const profile = join(home, 'profiles/web'); @@ -27,6 +27,7 @@ export function initProfile(home, options) { const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); if (['dependencies', 'devDependencies', 'optionalDependencies'].some(section => ['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 }); } } diff --git a/image/lockfile-upgrade-smoke.mjs b/image/lockfile-upgrade-smoke.mjs new file mode 100644 index 0000000..b623e7d --- /dev/null +++ b/image/lockfile-upgrade-smoke.mjs @@ -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]); +} diff --git a/image/reconcile-profile.mjs b/image/reconcile-profile.mjs index 387f3f2..13b29bb 100644 --- a/image/reconcile-profile.mjs +++ b/image/reconcile-profile.mjs @@ -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', }); 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 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 (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 stat = statIfPresent(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); } finally { closeSync(fd); } assertSafeProfile(profile); + // Persist before changing the manifest, including across failed installs/restarts. + markLockfilePending(profile); renameSync(temporaryPath, path); temporaryCreated = false; syncDirectory(); diff --git a/image/reconcile-profile.test.mjs b/image/reconcile-profile.test.mjs index 4e1d7c0..6469c5d 100644 --- a/image/reconcile-profile.test.mjs +++ b/image/reconcile-profile.test.mjs @@ -49,7 +49,8 @@ test('migrates all sections, backs up exact bytes, replaces atomically with 0600 assert.deepEqual(f.run(), { changed: false }); assert.equal(statSync(f.path).ino, before.ino); 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); assert.notEqual(f.run().backupPath, first.backupPath); assert.equal(readFileSync(first.backupPath, 'utf8'), f.original); @@ -105,8 +106,9 @@ const durableWriteOrder = [ 'open:backup', 'write:backup', 'fsync:backup', 'close:backup', 'open:directory-1', 'fsync:directory-1', 'close:directory-1', 'open:temporary', 'write:temporary', 'fsync:temporary', 'close:temporary', - 'rename:manifest', '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 => { @@ -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 => { - 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']) { const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } }); const target = join(f.root, 'outside');