440 lines
20 KiB
JavaScript
440 lines
20 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import test from 'node:test'
|
|
import { UserPluginInventory } from '../../control-plane/modules/plugin-manager/user-inventory.mjs'
|
|
import { UserPluginJournal, userPluginJournalInternals } from '../../control-plane/modules/plugin-manager/user-journal.mjs'
|
|
import { UserPluginSnapshots, userPluginSnapshotInternals } from '../../control-plane/modules/plugin-manager/user-snapshots.mjs'
|
|
import { UserPluginSelectionStore, userPluginStateInternals } from '../../control-plane/modules/plugin-manager/user-state.mjs'
|
|
import { UserPluginTransactionManager, userPluginTransactionInternals } from '../../control-plane/modules/plugin-manager/user-transaction.mjs'
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-user-plugin-transaction-'))
|
|
const dshHome = join(root, 'dsh')
|
|
const profileRoot = join(dshHome, 'profiles/web')
|
|
const stateRoot = join(root, 'platform/state/management')
|
|
const selectionPath = join(stateRoot, 'user-plugins.json')
|
|
const journalPath = join(stateRoot, 'user-plugin-transaction.json')
|
|
const snapshotsRoot = join(root, 'platform/store/snapshots/user-plugins')
|
|
await mkdir(join(profileRoot, 'node_modules'), { recursive: true })
|
|
await writeFile(join(profileRoot, 'package.json'), JSON.stringify({
|
|
name: 'dsh-profile-web',
|
|
dependencies: { alpha: '1.0.0', beta: '1.0.0', gamma: '1.0.0' },
|
|
dsh: { profile: { bundles: ['alpha', 'beta'] } },
|
|
}))
|
|
await writeFile(join(profileRoot, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
|
|
for (const name of ['alpha', 'beta', 'gamma']) {
|
|
const installed = join(profileRoot, 'node_modules', name)
|
|
await mkdir(installed)
|
|
await writeFile(join(installed, 'package.json'), JSON.stringify({
|
|
name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
|
|
}))
|
|
await writeFile(join(installed, 'cordis.patch.yml'), '[]\n')
|
|
}
|
|
const inventory = new UserPluginInventory({ dshHome, selectionPath })
|
|
const selectionStore = new UserPluginSelectionStore(selectionPath)
|
|
const journal = new UserPluginJournal(journalPath)
|
|
const snapshots = new UserPluginSnapshots({ root: snapshotsRoot, profileRoot })
|
|
return { root, dshHome, profileRoot, selectionPath, journal, snapshots, inventory, selectionStore }
|
|
}
|
|
|
|
function manager(value, overrides = {}) {
|
|
const calls = []
|
|
return {
|
|
calls,
|
|
value: new UserPluginTransactionManager({
|
|
inventory: value.inventory,
|
|
selectionStore: value.selectionStore,
|
|
snapshots: value.snapshots,
|
|
journal: value.journal,
|
|
pauseDsh: async () => { calls.push('pause') },
|
|
restartDsh: async () => { calls.push('restart') },
|
|
runPlugin: async details => { calls.push(['remove', details]) },
|
|
report: async message => { calls.push(['report', message]) },
|
|
...overrides,
|
|
}),
|
|
}
|
|
}
|
|
|
|
test('runs DSH plugin removal with writable user configuration directories', () => {
|
|
const previous = {
|
|
HOME: process.env.HOME,
|
|
npm_config_userconfig: process.env.npm_config_userconfig,
|
|
NPM_CONFIG_CACHE: process.env.NPM_CONFIG_CACHE,
|
|
PNPM_HOME: process.env.PNPM_HOME,
|
|
npm_config_prefix: process.env.npm_config_prefix,
|
|
}
|
|
process.env.HOME = '/root'
|
|
process.env.npm_config_userconfig = '/root/.npmrc'
|
|
process.env.NPM_CONFIG_CACHE = '/root/.npm'
|
|
process.env.PNPM_HOME = '/root/.local/share/pnpm'
|
|
process.env.npm_config_prefix = '/root/.local'
|
|
try {
|
|
const environment = userPluginTransactionInternals.pluginEnvironment('/data/dsh')
|
|
assert.equal(environment.HOME, '/home/node')
|
|
assert.equal(environment.XDG_CACHE_HOME, '/home/node/.cache')
|
|
assert.equal(environment.XDG_CONFIG_HOME, '/home/node/.config')
|
|
assert.equal(environment.XDG_DATA_HOME, '/home/node/.local/share')
|
|
assert.equal(environment.XDG_STATE_HOME, '/home/node/.local/state')
|
|
assert.equal(environment.DSH_HOME, '/data/dsh')
|
|
assert.equal(environment.npm_config_userconfig, undefined)
|
|
assert.equal(environment.NPM_CONFIG_CACHE, undefined)
|
|
assert.equal(environment.PNPM_HOME, undefined)
|
|
assert.equal(environment.npm_config_prefix, undefined)
|
|
} finally {
|
|
for (const [name, value] of Object.entries(previous)) {
|
|
if (value === undefined) delete process.env[name]
|
|
else process.env[name] = value
|
|
}
|
|
}
|
|
})
|
|
|
|
test('snapshots and restores the complete Web Profile with hidden files, modes, and symlinks', async () => {
|
|
const value = await fixture()
|
|
await writeFile(join(value.profileRoot, '.hidden'), 'hidden\n')
|
|
await chmod(join(value.profileRoot, '.hidden'), 0o640)
|
|
await symlink('package.json', join(value.profileRoot, 'manifest-link'))
|
|
await value.snapshots.create('snapshot-one')
|
|
await rm(join(value.profileRoot, '.hidden'))
|
|
await writeFile(join(value.profileRoot, 'package.json'), '{}\n')
|
|
await value.snapshots.restore('snapshot-one')
|
|
assert.equal(await readFile(join(value.profileRoot, '.hidden'), 'utf8'), 'hidden\n')
|
|
assert.equal((await lstat(join(value.profileRoot, '.hidden'))).mode & 0o777, 0o640)
|
|
assert.equal(await readlink(join(value.profileRoot, 'manifest-link')), 'package.json')
|
|
assert.match(await readFile(join(value.profileRoot, 'package.json'), 'utf8'), /dsh-profile-web/)
|
|
await writeFile(join(value.snapshots.path('snapshot-one'), 'profile.tar'), 'corrupt')
|
|
await assert.rejects(value.snapshots.inspect('snapshot-one'), /does not match/)
|
|
})
|
|
|
|
test('creates full Profile snapshots without compression overhead', async () => {
|
|
const value = await fixture()
|
|
await value.snapshots.create('snapshot-uncompressed')
|
|
assert.equal((await lstat(join(value.snapshots.path('snapshot-uncompressed'), 'profile.tar'))).isFile(), true)
|
|
await assert.rejects(lstat(join(value.snapshots.path('snapshot-uncompressed'), 'profile.tar.gz')), { code: 'ENOENT' })
|
|
})
|
|
|
|
test('atomically applies ordered enable and disable actions around one DSH pause', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
const transaction = manager(value)
|
|
const result = await transaction.value.apply({
|
|
taskId: 'task-enable-disable',
|
|
revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }, { name: 'gamma', action: 'enable' }],
|
|
})
|
|
assert.equal(result.status, 'success')
|
|
const manifest = JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8'))
|
|
assert.deepEqual(manifest.dsh.profile.bundles, ['beta', 'gamma'])
|
|
assert.deepEqual((await value.selectionStore.read()).disabled, [{ name: 'alpha', index: 0 }])
|
|
assert.deepEqual(result.inventory.plugins.map(plugin => [plugin.name, plugin.enabled]), [
|
|
['alpha', false], ['beta', true], ['gamma', true],
|
|
])
|
|
assert.equal((await value.journal.read()).phase, 'completed')
|
|
await assert.rejects(lstat(value.snapshots.path('task-enable-disable')), { code: 'ENOENT' })
|
|
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), ['pause', 'restart'])
|
|
})
|
|
|
|
test('records every disabled plugin position from the original Bundle order', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
const transaction = manager(value)
|
|
await transaction.value.apply({
|
|
taskId: 'task-disable-order', revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }, { name: 'beta', action: 'disable' }],
|
|
})
|
|
assert.deepEqual((await value.selectionStore.read()).disabled, [
|
|
{ name: 'alpha', index: 0 }, { name: 'beta', index: 1 },
|
|
])
|
|
})
|
|
|
|
test('restores the full Profile when an exact uninstall command fails before commit', async () => {
|
|
const value = await fixture()
|
|
const beforeBytes = await readFile(join(value.profileRoot, 'package.json'))
|
|
const before = await value.inventory.read()
|
|
const transaction = manager(value, {
|
|
runPlugin: async ({ name }) => {
|
|
await writeFile(join(value.profileRoot, 'package.json'), '{}\n')
|
|
throw new Error(`pnpm failed for ${name}`)
|
|
},
|
|
})
|
|
await assert.rejects(transaction.value.apply({
|
|
taskId: 'task-uninstall-failure',
|
|
revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'uninstall' }],
|
|
}), /pnpm failed/)
|
|
assert.deepEqual(await readFile(join(value.profileRoot, 'package.json')), beforeBytes)
|
|
assert.equal((await value.journal.read()).phase, 'failed')
|
|
assert.equal((await value.journal.read()).recoveryResult, 'success')
|
|
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), ['pause', 'restart'])
|
|
})
|
|
|
|
test('uninstalls one exact package name without exposing package-manager arguments', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
const removals = []
|
|
const transaction = manager(value, {
|
|
runPlugin: async details => {
|
|
removals.push(details)
|
|
const manifestPath = join(value.profileRoot, 'package.json')
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
delete manifest.dependencies[details.name]
|
|
manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter(name => name !== details.name)
|
|
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
await writeFile(join(value.profileRoot, 'pnpm-lock.yaml'), 'lockfileVersion: 9\npackages: {}\n')
|
|
await rm(join(value.profileRoot, 'node_modules', details.name), { recursive: true, force: true })
|
|
},
|
|
})
|
|
const result = await transaction.value.apply({
|
|
taskId: 'task-uninstall-success',
|
|
revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'uninstall' }],
|
|
})
|
|
assert.deepEqual(removals, [{ dshHome: value.dshHome, profile: 'web', name: 'alpha' }])
|
|
assert.equal(result.inventory.plugins.some(plugin => plugin.name === 'alpha'), false)
|
|
})
|
|
|
|
test('reapplies disabled Bundle selection after DSH plugin removal reconciles the Profile', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
const transaction = manager(value, {
|
|
runPlugin: async details => {
|
|
const manifestPath = join(value.profileRoot, 'package.json')
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
delete manifest.dependencies[details.name]
|
|
manifest.dsh.profile.bundles = ['beta']
|
|
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
await rm(join(value.profileRoot, 'node_modules', details.name), { recursive: true, force: true })
|
|
},
|
|
})
|
|
const result = await transaction.value.apply({
|
|
taskId: 'task-disable-and-uninstall',
|
|
revision: before.revision,
|
|
actions: [{ name: 'beta', action: 'disable' }, { name: 'alpha', action: 'uninstall' }],
|
|
})
|
|
const manifest = JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8'))
|
|
assert.deepEqual(manifest.dsh.profile.bundles, [])
|
|
assert.equal(result.inventory.plugins.find(plugin => plugin.name === 'beta')?.enabled, false)
|
|
assert.equal(result.inventory.plugins.some(plugin => plugin.name === 'alpha'), false)
|
|
})
|
|
|
|
test('restarts unchanged DSH when the mandatory Profile snapshot cannot be created', async () => {
|
|
const value = await fixture()
|
|
const beforeBytes = await readFile(join(value.profileRoot, 'package.json'))
|
|
const before = await value.inventory.read()
|
|
const transaction = manager(value, {
|
|
snapshots: {
|
|
create: async () => { throw new Error('snapshot disk full') },
|
|
remove: async () => {},
|
|
},
|
|
})
|
|
await assert.rejects(transaction.value.apply({
|
|
taskId: 'task-snapshot-failure',
|
|
revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }],
|
|
}), /snapshot disk full/)
|
|
assert.deepEqual(await readFile(join(value.profileRoot, 'package.json')), beforeBytes)
|
|
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), ['pause', 'restart'])
|
|
const journal = await value.journal.read()
|
|
assert.equal(journal.phase, 'failed')
|
|
assert.equal(journal.recoveryResult, 'success')
|
|
})
|
|
|
|
test('retains a committed plugin change when DSH still fails to restart', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
const transaction = manager(value, { restartDsh: async () => { throw new Error('DSH remains broken') } })
|
|
const result = await transaction.value.apply({
|
|
taskId: 'task-restart-failure',
|
|
revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }],
|
|
})
|
|
assert.equal(result.status, 'success')
|
|
assert.equal(result.strategy, 'next-start')
|
|
assert.equal(result.activationError, 'DSH remains broken')
|
|
const manifest = JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8'))
|
|
assert.deepEqual(manifest.dsh.profile.bundles, ['beta'])
|
|
const journal = await value.journal.read()
|
|
assert.equal(journal.phase, 'completed')
|
|
assert.equal(journal.recoveryResult, null)
|
|
await assert.rejects(lstat(value.snapshots.path('task-restart-failure')), { code: 'ENOENT' })
|
|
})
|
|
|
|
test('applies persistent plugin changes without touching DSH when it is unavailable', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
const transaction = manager(value)
|
|
const result = await transaction.value.apply({
|
|
taskId: 'task-next-start',
|
|
revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }],
|
|
strategy: 'next-start',
|
|
})
|
|
assert.equal(result.status, 'success')
|
|
assert.equal(result.strategy, 'next-start')
|
|
assert.equal(result.activationError, null)
|
|
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), [])
|
|
assert.deepEqual(JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8')).dsh.profile.bundles, ['beta'])
|
|
assert.equal((await value.journal.read()).phase, 'completed')
|
|
})
|
|
|
|
test('recovers an interrupted pre-commit mutation from its persisted snapshot', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
await value.journal.begin({
|
|
taskId: 'task-interrupted', revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }],
|
|
selection: await value.selectionStore.snapshot(),
|
|
})
|
|
await value.journal.transition('paused')
|
|
await value.snapshots.create('task-interrupted')
|
|
await value.journal.transition('snapshotted', { snapshotId: 'task-interrupted' })
|
|
await value.journal.transition('mutating')
|
|
await writeFile(join(value.profileRoot, 'package.json'), '{}\n')
|
|
const transaction = manager(value)
|
|
const recovered = await transaction.value.recover()
|
|
assert.equal(recovered.phase, 'failed')
|
|
assert.equal(recovered.recoveryResult, 'success')
|
|
assert.match(await readFile(join(value.profileRoot, 'package.json'), 'utf8'), /dsh-profile-web/)
|
|
await assert.rejects(lstat(value.selectionPath), { code: 'ENOENT' })
|
|
assert.deepEqual(transaction.calls.filter(call => typeof call === 'string'), ['pause', 'restart'])
|
|
})
|
|
|
|
test('restores an interrupted pre-commit Profile before Bootstrap starts DSH', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
await value.journal.begin({
|
|
taskId: 'task-prestart-restore', revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }],
|
|
selection: await value.selectionStore.snapshot(),
|
|
})
|
|
await value.journal.transition('paused')
|
|
await value.snapshots.create('task-prestart-restore')
|
|
await value.journal.transition('snapshotted', { snapshotId: 'task-prestart-restore' })
|
|
await value.journal.transition('mutating')
|
|
await writeFile(join(value.profileRoot, 'package.json'), '{}\n')
|
|
const transaction = manager(value)
|
|
const recovered = await transaction.value.recoverBeforeDshStart()
|
|
assert.equal(recovered.phase, 'failed')
|
|
assert.equal(recovered.recoveryResult, 'success')
|
|
assert.match(await readFile(join(value.profileRoot, 'package.json'), 'utf8'), /dsh-profile-web/)
|
|
assert.deepEqual(transaction.calls, [])
|
|
})
|
|
|
|
test('lets Bootstrap start a committed Profile once and then finalizes the journal', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
await value.journal.begin({
|
|
taskId: 'task-prestart-committed', revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }],
|
|
selection: await value.selectionStore.snapshot(),
|
|
})
|
|
await value.journal.transition('paused')
|
|
await value.snapshots.create('task-prestart-committed')
|
|
await value.journal.transition('snapshotted', { snapshotId: 'task-prestart-committed' })
|
|
await value.journal.transition('mutating')
|
|
await value.journal.transition('committed')
|
|
const transaction = manager(value)
|
|
const pending = await transaction.value.recoverBeforeDshStart()
|
|
assert.equal(pending.phase, 'restarting')
|
|
assert.deepEqual(transaction.calls, [])
|
|
const completed = await transaction.value.completeDshStartup({ healthy: true })
|
|
assert.equal(completed.phase, 'completed')
|
|
await assert.rejects(lstat(value.snapshots.path('task-prestart-committed')), { code: 'ENOENT' })
|
|
})
|
|
|
|
test('restores platform selection state when final Profile validation fails', async () => {
|
|
const value = await fixture()
|
|
await value.selectionStore.write({ schema: 1, disabled: [{ name: 'gamma', index: 2 }] })
|
|
const initial = await value.inventory.read()
|
|
let reads = 0
|
|
const inventory = Object.create(value.inventory)
|
|
inventory.read = async () => {
|
|
reads += 1
|
|
if (reads === 2) throw new Error('result validation failed')
|
|
return value.inventory.read()
|
|
}
|
|
const transaction = manager({ ...value, inventory })
|
|
await assert.rejects(transaction.value.apply({
|
|
taskId: 'task-selection-restore',
|
|
revision: initial.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }],
|
|
}), /result validation failed/)
|
|
assert.deepEqual((await value.selectionStore.read()).disabled, [{ name: 'gamma', index: 2 }])
|
|
const manifest = JSON.parse(await readFile(join(value.profileRoot, 'package.json'), 'utf8'))
|
|
assert.deepEqual(manifest.dsh.profile.bundles, ['alpha', 'beta'])
|
|
})
|
|
|
|
test('rejects stale revisions and reserved-name enablement before pausing DSH', async () => {
|
|
const value = await fixture()
|
|
const transaction = manager(value)
|
|
await assert.rejects(transaction.value.apply({
|
|
revision: `sha256:${'0'.repeat(64)}`,
|
|
actions: [{ name: 'alpha', action: 'disable' }],
|
|
}), error => error.code === 'REVISION_CONFLICT')
|
|
assert.deepEqual(transaction.calls, [])
|
|
|
|
const reserved = new UserPluginInventory({
|
|
dshHome: value.dshHome,
|
|
selectionPath: value.selectionPath,
|
|
systemPluginNames: async () => ['alpha'],
|
|
})
|
|
const reservedManager = manager({ ...value, inventory: reserved })
|
|
const current = await reserved.read()
|
|
await assert.rejects(reservedManager.value.apply({
|
|
revision: current.revision,
|
|
actions: [{ name: 'alpha', action: 'enable' }],
|
|
}), /cannot be enabled/)
|
|
assert.deepEqual(reservedManager.calls, [])
|
|
})
|
|
|
|
test('rejects extension fields in every persisted User Plugin state document', () => {
|
|
assert.throws(() => userPluginStateInternals.parse({ schema: 1, disabled: [], extra: true }), /invalid/)
|
|
assert.throws(() => userPluginSnapshotInternals.parseManifest({
|
|
schema: 1,
|
|
id: 'snapshot',
|
|
source: '/data/dsh/profiles/web',
|
|
createdAt: new Date(0).toISOString(),
|
|
archiveSha256: 'a'.repeat(64),
|
|
archiveSize: 1,
|
|
extra: true,
|
|
}), /invalid/)
|
|
assert.throws(() => userPluginJournalInternals.parse({
|
|
schema: 1,
|
|
taskId: 'task',
|
|
revision: `sha256:${'a'.repeat(64)}`,
|
|
actions: [{ name: 'plugin', action: 'disable' }],
|
|
selectionPresent: false,
|
|
previousDisabled: [],
|
|
phase: 'validated',
|
|
snapshotId: null,
|
|
error: null,
|
|
recoveryResult: null,
|
|
updatedAt: new Date(0).toISOString(),
|
|
extra: true,
|
|
}), /invalid/)
|
|
})
|
|
|
|
test('does not turn successful recovery into failure when snapshot cleanup fails', async () => {
|
|
const value = await fixture()
|
|
const before = await value.inventory.read()
|
|
const reports = []
|
|
const snapshots = {
|
|
...value.snapshots,
|
|
create: (...args) => value.snapshots.create(...args),
|
|
restore: (...args) => value.snapshots.restore(...args),
|
|
remove: async () => { throw new Error('cleanup failed') },
|
|
}
|
|
const transaction = manager(value, {
|
|
snapshots,
|
|
report: async message => { reports.push(message) },
|
|
})
|
|
const result = await transaction.value.apply({
|
|
taskId: 'task-cleanup-warning', revision: before.revision,
|
|
actions: [{ name: 'alpha', action: 'disable' }],
|
|
})
|
|
assert.equal(result.status, 'success')
|
|
assert.equal((await value.journal.read()).phase, 'completed')
|
|
assert.equal(reports.includes('user-plugin.snapshot.cleanup.failed'), true)
|
|
})
|