112 lines
5.5 KiB
JavaScript
112 lines
5.5 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, symlink, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import test from 'node:test'
|
|
import { PersistentStateSnapshots } from '../../control-plane/modules/updater/lib/snapshots.mjs'
|
|
import { SnapshotClient } from '../../control-plane/modules/updater/lib/snapshot-client.mjs'
|
|
import { UserPluginSnapshots } from '../../control-plane/modules/plugin-manager/user-snapshots.mjs'
|
|
import { LocalApiClient } from '../../control-plane/modules/updater/lib/client.mjs'
|
|
import { createSnapshotServer, listenSnapshots } from '../stage0/lib/snapshot-server.mjs'
|
|
|
|
async function fixture(options = {}) {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-snapshots-'))
|
|
const sourceRoot = join(root, 'home')
|
|
await mkdir(join(sourceRoot, 'nested'), { recursive: true })
|
|
return {
|
|
root,
|
|
sourceRoot,
|
|
snapshots: new PersistentStateSnapshots({ root: join(root, 'snapshots'), sourceRoot, ...options }),
|
|
}
|
|
}
|
|
|
|
const state = {
|
|
id: 'before-rc8',
|
|
runtimeId: '0.1.0-rc.7-1',
|
|
environmentVersion: '1.0.0',
|
|
dshVersion: '0.1.0-rc.7',
|
|
}
|
|
|
|
test('snapshots and restores complete DSH_HOME contents and permissions', async () => {
|
|
const { sourceRoot, snapshots } = await fixture({ now: () => new Date('2026-08-19T00:00:00.000Z') })
|
|
await writeFile(join(sourceRoot, '.config'), 'before')
|
|
await writeFile(join(sourceRoot, 'nested', 'database'), 'rows')
|
|
await chmod(join(sourceRoot, 'nested', 'database'), 0o640)
|
|
await symlink('nested/database', join(sourceRoot, 'database-link'))
|
|
const createProgress = []
|
|
const snapshot = await snapshots.create({ ...state, onProgress: value => { createProgress.push(value) } })
|
|
assert.equal(snapshot.createdAt, '2026-08-19T00:00:00.000Z')
|
|
assert.equal(createProgress.at(-1).processedItems, createProgress.at(-1).totalItems)
|
|
|
|
await writeFile(join(sourceRoot, '.config'), 'after')
|
|
await writeFile(join(sourceRoot, 'new-data'), 'remove me')
|
|
const restoreProgress = []
|
|
await snapshots.restore(state.id, { onProgress: value => { restoreProgress.push(value) } })
|
|
assert.equal(await readFile(join(sourceRoot, '.config'), 'utf8'), 'before')
|
|
assert.equal(await readFile(join(sourceRoot, 'nested', 'database'), 'utf8'), 'rows')
|
|
assert.equal((await lstat(join(sourceRoot, 'nested', 'database'))).mode & 0o777, 0o640)
|
|
assert.equal(await readlink(join(sourceRoot, 'database-link')), 'nested/database')
|
|
await assert.rejects(readFile(join(sourceRoot, 'new-data')), { code: 'ENOENT' })
|
|
assert.equal(restoreProgress.at(-1).processedItems, restoreProgress.at(-1).totalItems)
|
|
assert.equal((await snapshots.list())[0].id, state.id)
|
|
})
|
|
|
|
test('verifies a snapshot before clearing current DSH_HOME', async () => {
|
|
const { sourceRoot, snapshots } = await fixture()
|
|
await writeFile(join(sourceRoot, 'database'), 'before')
|
|
const snapshot = await snapshots.create(state)
|
|
await writeFile(join(snapshot.path, 'data.tar.gz'), 'corrupt')
|
|
await writeFile(join(sourceRoot, 'database'), 'current')
|
|
await assert.rejects(snapshots.restore(state.id), { code: 'TRUST_ARTIFACT_MISMATCH' })
|
|
assert.equal(await readFile(join(sourceRoot, 'database'), 'utf8'), 'current')
|
|
})
|
|
|
|
test('does not publish a partial snapshot when archive creation fails', async () => {
|
|
const { snapshots } = await fixture({ runImpl: async () => { throw new Error('no space left') } })
|
|
await assert.rejects(snapshots.create(state), /no space/)
|
|
assert.deepEqual(await snapshots.list(), [])
|
|
})
|
|
|
|
test('removing an absent snapshot is idempotent while corrupt snapshots remain errors', async () => {
|
|
const { snapshots } = await fixture()
|
|
assert.equal(await snapshots.remove('already-removed'), false)
|
|
const snapshot = await snapshots.create(state)
|
|
await writeFile(join(snapshot.path, 'data.tar.gz'), 'corrupt')
|
|
await assert.rejects(snapshots.remove(state.id), { code: 'TRUST_ARTIFACT_MISMATCH' })
|
|
})
|
|
|
|
test('Stage-0 serves only fixed DSH and Web Profile snapshot scopes', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-snapshot-server-'))
|
|
const dshHome = join(root, 'dsh')
|
|
const profileRoot = join(dshHome, 'profiles', 'web')
|
|
await mkdir(profileRoot, { recursive: true })
|
|
await writeFile(join(dshHome, 'settings.yaml'), 'before')
|
|
await writeFile(join(profileRoot, 'package.json'), '{"before":true}')
|
|
const server = createSnapshotServer({
|
|
dshSnapshots: new PersistentStateSnapshots({ root: join(root, 'snapshots'), sourceRoot: dshHome }),
|
|
userPluginSnapshots: new UserPluginSnapshots({ root: join(root, 'user-plugin-snapshots'), profileRoot }),
|
|
})
|
|
const socket = join(root, 'snapshot.sock')
|
|
await listenSnapshots(server, socket)
|
|
const api = new LocalApiClient(socket)
|
|
const dsh = new SnapshotClient(api, 'dsh')
|
|
const userPlugin = new SnapshotClient(api, 'user-plugin')
|
|
try {
|
|
const created = await dsh.create(state)
|
|
assert.equal(created.id, state.id)
|
|
assert.equal(created.path, undefined)
|
|
await writeFile(join(dshHome, 'settings.yaml'), 'after')
|
|
await dsh.restore(state.id)
|
|
assert.equal(await readFile(join(dshHome, 'settings.yaml'), 'utf8'), 'before')
|
|
|
|
const plugin = await userPlugin.create('profile-before-change')
|
|
assert.equal(plugin.id, 'profile-before-change')
|
|
assert.equal(plugin.path, undefined)
|
|
await writeFile(join(profileRoot, 'package.json'), '{"after":true}')
|
|
await userPlugin.restore('profile-before-change')
|
|
assert.equal(await readFile(join(profileRoot, 'package.json'), 'utf8'), '{"before":true}')
|
|
} finally {
|
|
await new Promise(resolve => server.close(resolve))
|
|
}
|
|
})
|