dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x

This commit is contained in:
hermes committed 2026-10-01 19:21:19 +00:00
commit e6dcef9bf5
302 files changed
+75178

No files matched your search

@@ -0,0 +1,441 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { lstat, mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { artifactForReference, parseEnvironmentManifest } from '../../../platform/lib/contracts.mjs'
import { hashTree } from '../../../platform/lib/tree-hash.mjs'
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/
const SELECTION_SCHEMA = 1
export const PROTECTED_SYSTEM_PLUGIN_IDS = Object.freeze(['platform-management'])
function assertPluginId(pluginId) {
if (typeof pluginId !== 'string' || !PLUGIN_ID_PATTERN.test(pluginId)) {
throw new Error('System Plugin ID is invalid')
}
return pluginId
}
function run(command, args) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => {
if (code === 0) resolveRun(Buffer.concat(stdout).toString('utf8'))
else reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`))
})
})
}
async function extractPackage(archive, destination) {
const listing = (await run('tar', ['-tzf', archive])).split('\n').filter(Boolean)
if (listing.length === 0) throw new Error('System Plugin archive is empty')
for (const name of listing) {
if (!name.startsWith('package/') || name.startsWith('/') || name.split('/').includes('..')) {
throw new Error(`System Plugin archive path is unsafe: ${name}`)
}
}
await mkdir(destination, { recursive: true })
await run('tar', ['-xzf', archive, '--strip-components=1', '--no-same-owner', '--no-same-permissions', '-C', destination])
}
function validatePackage(value, pluginId) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`System Plugin ${pluginId} package.json must be an object`)
}
const expectedName = `@dsh-docker/${pluginId}`
if (value.name !== expectedName) throw new Error(`System Plugin ${pluginId} package name must be ${expectedName}`)
if (typeof value.main !== 'string' || value.main.length === 0
|| value.main.startsWith('/') || value.main.includes('\\') || value.main.split('/').includes('..')) {
throw new Error(`System Plugin ${pluginId} package main must be a safe relative path`)
}
return expectedName
}
function validatePatch(value, pluginId, packageName) {
if (!Array.isArray(value)) throw new Error(`System Plugin ${pluginId} cordis.patch.json must be an array`)
return value.map((entry, index) => {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`System Plugin ${pluginId} patch ${String(index)} must be an object`)
}
if (Object.keys(entry).length !== 1 || !Array.isArray(entry.insert) || entry.insert.length === 0) {
throw new Error(`System Plugin ${pluginId} patches must contain only a non-empty insert list`)
}
for (const row of entry.insert) {
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
throw new Error(`System Plugin ${pluginId} inserted rows must be objects`)
}
if (typeof row.id !== 'string' || !row.id.startsWith(`dsh-docker.${pluginId}.`)) {
throw new Error(`System Plugin ${pluginId} patch IDs must use its dsh-docker namespace`)
}
if (typeof row.name !== 'string' || row.name.length === 0) {
throw new Error(`System Plugin ${pluginId} inserted rows must name a package`)
}
if (row.name !== packageName) {
throw new Error(`System Plugin ${pluginId} inserted rows must load its own package`)
}
}
return {
insert: entry.insert.map(row => ({ ...row })),
}
})
}
async function replaceLink(root, name, version) {
const temporary = join(root, `.${name}.${randomUUID()}.tmp`)
await symlink(join('versions', version), temporary)
await rename(temporary, join(root, name))
}
export async function reconcileSystemPlugins({ root, environmentVersion, plugins, artifactPath }) {
const managedRoot = resolve(root)
const versions = join(managedRoot, 'versions')
const destination = join(versions, environmentVersion)
const staging = join(versions, `.${environmentVersion}.${randomUUID()}.tmp`)
await mkdir(staging, { recursive: true })
const patches = []
try {
for (const plugin of plugins) {
const packageRoot = join(staging, 'packages', plugin.id)
await extractPackage(artifactPath(plugin), packageRoot)
const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
const packageName = validatePackage(metadata, plugin.id)
const patch = JSON.parse(await readFile(join(packageRoot, 'cordis.patch.json'), 'utf8'))
patches.push(...validatePatch(patch, plugin.id, packageName))
}
await writeFile(join(staging, 'cordis.patch.yml'), canonicalJson(patches), { flag: 'wx' })
await rename(staging, destination)
const current = await import('node:fs/promises').then(({ readlink }) => readlink(join(managedRoot, 'current')).catch(error => error.code === 'ENOENT' ? undefined : Promise.reject(error)))
if (current !== undefined) await replaceLink(managedRoot, 'previous', current.split('/').at(-1))
await replaceLink(managedRoot, 'current', environmentVersion)
return destination
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async function readOverlay(root) {
const patch = JSON.parse(await readFile(join(root, 'cordis.patch.yml'), 'utf8'))
if (!Array.isArray(patch)) throw new Error('System Plugin overlay patch must be an array')
return patch
}
function defaultSelection(pluginIds, protectedIds) {
return Object.fromEntries(pluginIds.map(id => [id, {
installed: true,
enabled: true,
protected: protectedIds.has(id),
}]))
}
function validateSelectionDocument(value, pluginIds, protectedIds) {
if (value === null || typeof value !== 'object' || Array.isArray(value) || value.schema !== SELECTION_SCHEMA
|| value.plugins === null || typeof value.plugins !== 'object' || Array.isArray(value.plugins)) {
throw new Error('System Plugin selection state is invalid')
}
const selection = defaultSelection(pluginIds, protectedIds)
for (const id of pluginIds) {
const saved = value.plugins[id]
if (saved === undefined) continue
if (saved === null || typeof saved !== 'object' || Array.isArray(saved)
|| typeof saved.installed !== 'boolean' || typeof saved.enabled !== 'boolean') {
throw new Error(`System Plugin ${id} selection state is invalid`)
}
if (saved.enabled && !saved.installed) {
throw new Error(`System Plugin ${id} cannot be enabled while it is not installed`)
}
selection[id] = { installed: saved.installed, enabled: saved.enabled, protected: protectedIds.has(id) }
}
return selection
}
export class SystemPluginSelectionStore {
constructor(path, { protectedIds = PROTECTED_SYSTEM_PLUGIN_IDS } = {}) {
this.path = resolve(path)
this.protectedIds = new Set(protectedIds)
}
async read(pluginIds) {
const ids = pluginIds.map(assertPluginId)
let value
try {
value = JSON.parse(await readFile(this.path, 'utf8'))
} catch (error) {
if (error?.code === 'ENOENT') return defaultSelection(ids, this.protectedIds)
throw error
}
return validateSelectionDocument(value, ids, this.protectedIds)
}
async write(pluginIds, selection) {
const ids = pluginIds.map(assertPluginId)
const normalized = validateSelectionDocument({ schema: SELECTION_SCHEMA, plugins: selection }, ids, this.protectedIds)
const document = {
schema: SELECTION_SCHEMA,
plugins: Object.fromEntries(ids.map(id => [id, {
installed: normalized[id].installed,
enabled: normalized[id].enabled,
}])),
}
await mkdir(dirname(this.path), { recursive: true })
const temporary = `${this.path}.${randomUUID()}.tmp`
try {
await writeFile(temporary, `${canonicalJson(document)}\n`, { flag: 'wx', mode: 0o600 })
await rename(temporary, this.path)
} finally {
await rm(temporary, { force: true })
}
return normalized
}
async configure(pluginIds, pluginId, action) {
const ids = pluginIds.map(assertPluginId)
assertPluginId(pluginId)
if (!ids.includes(pluginId)) throw new Error(`System Plugin ${pluginId} is not provided by the current Environment`)
if (this.protectedIds.has(pluginId)) throw new Error(`System Plugin ${pluginId} is managed by the platform and cannot be changed`)
return this.configureSelection(ids, pluginId, action)
}
async recover(pluginIds, pluginId, action) {
const ids = pluginIds.map(assertPluginId)
assertPluginId(pluginId)
if (!ids.includes(pluginId)) throw new Error(`System Plugin ${pluginId} is not provided by the current Environment`)
if (!this.protectedIds.has(pluginId)) throw new Error(`System Plugin ${pluginId} is not a platform recovery target`)
return this.configureSelection(ids, pluginId, action)
}
async configureSelection(ids, pluginId, action) {
if (!['install', 'uninstall', 'enable', 'disable'].includes(action)) {
throw new Error('System Plugin action is invalid')
}
const selection = await this.read(ids)
const current = selection[pluginId]
if (action === 'install') selection[pluginId] = { ...current, installed: true, enabled: true }
else if (action === 'uninstall') selection[pluginId] = { ...current, installed: false, enabled: false }
else if (action === 'enable') {
if (!current.installed) throw new Error(`System Plugin ${pluginId} must be installed before it can be enabled`)
selection[pluginId] = { ...current, enabled: true }
} else selection[pluginId] = { ...current, enabled: false }
return this.write(ids, selection)
}
}
async function pluginCatalog(environmentRoot) {
const root = resolve(environmentRoot)
const manifest = parseEnvironmentManifest(await readFile(join(root, 'environment.manifest.json')))
return {
root,
plugins: manifest.systemPlugins.map(reference => ({
id: reference.id,
descriptor: artifactForReference(manifest, reference),
})),
}
}
async function readActiveSelection(root, plugins) {
if (root === undefined) return undefined
const overlay = await readOverlay(resolve(root))
const enabledPackages = new Set(overlay.flatMap(entry => (
Array.isArray(entry?.insert) ? entry.insert.map(row => row?.name).filter(Boolean) : []
)))
return Object.fromEntries(await Promise.all(plugins.map(async ({ id }) => {
const installed = await lstat(join(resolve(root), 'packages', id)).then(
() => true,
error => error?.code === 'ENOENT' ? false : Promise.reject(error),
)
return [id, { installed, enabled: installed && enabledPackages.has(`@dsh-docker/${id}`) }]
})))
}
export async function listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore, activeRoot }) {
const catalog = await pluginCatalog(environmentRoot)
const selection = await selectionStore.read(catalog.plugins.map(plugin => plugin.id))
const active = await readActiveSelection(activeRoot, catalog.plugins)
return Object.freeze(await Promise.all(catalog.plugins.map(async ({ id, descriptor }) => {
const metadata = JSON.parse(await readFile(join(resolve(sourceRoot), 'packages', id, 'package.json'), 'utf8'))
validatePackage(metadata, id)
let description = null
const value = metadata.dshDocker?.description
if (value !== undefined) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| typeof value.zh !== 'string' || typeof value.en !== 'string'
|| value.zh.length === 0 || value.en.length === 0 || value.zh.length > 240 || value.en.length > 240) {
throw new Error(`System Plugin ${id} description is invalid`)
}
description = Object.freeze({ zh: value.zh, en: value.en })
}
const activeInstalled = active?.[id].installed ?? selection[id].installed
const activeEnabled = active?.[id].enabled ?? selection[id].enabled
return Object.freeze({
id,
artifactId: descriptor.id,
sha256: descriptor.sha256,
description,
installed: selection[id].installed,
enabled: selection[id].enabled,
activeInstalled,
activeEnabled,
pendingRestart: activeInstalled !== selection[id].installed || activeEnabled !== selection[id].enabled,
protected: selection[id].protected,
reason: null,
})
})))
}
export async function discardSystemPluginSelection({ environmentRoot, sourceRoot, selectionStore, activeRoot }) {
const catalog = await pluginCatalog(environmentRoot)
const pluginIds = catalog.plugins.map(plugin => plugin.id)
const active = await readActiveSelection(activeRoot, catalog.plugins)
if (active === undefined) throw new Error('active System Plugin view is unavailable')
await selectionStore.write(pluginIds, active)
return listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore, activeRoot })
}
export async function materializeSystemPluginSelection({
environmentRoot,
sourceRoot,
outputRoot,
selectionStore,
}) {
const catalog = await pluginCatalog(environmentRoot)
const pluginIds = catalog.plugins.map(plugin => plugin.id)
const selection = await selectionStore.read(pluginIds)
const root = resolve(outputRoot)
const staging = join(root, `.selection.${randomUUID()}.tmp`)
const destination = join(root, `selection-${randomUUID()}`)
const patches = []
await mkdir(join(staging, 'packages'), { recursive: true })
try {
for (const { id } of catalog.plugins) {
if (!selection[id].installed) continue
const packageRoot = join(resolve(sourceRoot), 'packages', id)
const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
const packageName = validatePackage(metadata, id)
const patch = JSON.parse(await readFile(join(packageRoot, 'cordis.patch.json'), 'utf8'))
const validatedPatch = validatePatch(patch, id, packageName)
await symlink(packageRoot, join(staging, 'packages', id), 'dir')
if (selection[id].enabled) patches.push(...validatedPatch)
}
await writeFile(join(staging, 'cordis.patch.yml'), canonicalJson(patches), { flag: 'wx' })
await rename(staging, destination)
return Object.freeze({ path: destination, plugins: await listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore }) })
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
export async function pruneSystemPluginSelectionViews({ outputRoot, keepPath }) {
const root = resolve(outputRoot)
const keep = resolve(keepPath)
for (const entry of await readdir(root, { withFileTypes: true })) {
if (!entry.name.startsWith('selection-')) continue
const candidate = join(root, entry.name)
if (candidate !== keep) await rm(candidate, { recursive: true, force: true })
}
}
export async function listBundledSystemPlugins({ environmentRoot, viewRoot }) {
const manifest = parseEnvironmentManifest(await readFile(join(resolve(environmentRoot), 'environment.manifest.json')))
const overlay = await readOverlay(resolve(viewRoot)).catch(() => [])
const moduleNames = new Set(overlay.flatMap(entry => (
Array.isArray(entry?.insert) ? entry.insert.map(row => row?.name).filter(Boolean) : []
)))
const result = []
for (const reference of manifest.systemPlugins) {
const descriptor = artifactForReference(manifest, reference)
let installed = false
let reason = null
try {
const metadata = JSON.parse(await readFile(join(resolve(viewRoot), 'packages', reference.id, 'package.json'), 'utf8'))
const packageName = validatePackage(metadata, reference.id)
installed = moduleNames.has(packageName)
if (!installed) reason = 'plugin loader entry is absent'
} catch (error) {
reason = error instanceof Error ? error.message : 'plugin package is unavailable'
}
result.push(Object.freeze({
id: reference.id,
artifactId: descriptor.id,
sha256: descriptor.sha256,
installed,
reason,
}))
}
return Object.freeze(result)
}
export async function rebuildBundledSystemPluginView({
environmentRoot,
outputRoot,
expectedSha256,
requestedPluginId,
}) {
const source = resolve(environmentRoot)
const manifest = parseEnvironmentManifest(await readFile(join(source, 'environment.manifest.json')))
if (!manifest.systemPlugins.some(plugin => plugin.id === requestedPluginId)) {
throw new Error(`System Plugin ${requestedPluginId} is not bundled by the current Environment`)
}
const root = resolve(outputRoot)
const destination = join(root, expectedSha256)
try {
const details = await lstat(destination)
if (!details.isDirectory() || details.isSymbolicLink() || await hashTree(destination) !== expectedSha256) {
await rm(destination, { recursive: true, force: true })
} else {
return destination
}
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
const buildRoot = join(root, `.build.${randomUUID()}.tmp`)
await mkdir(root, { recursive: true })
try {
const built = await reconcileSystemPlugins({
root: buildRoot,
environmentVersion: expectedSha256,
plugins: manifest.systemPlugins,
artifactPath: reference => join(source, 'artifacts', artifactForReference(manifest, reference).id),
})
if (await hashTree(built) !== expectedSha256) {
throw new Error('rebuilt System Plugin view differs from the current Deployment Record')
}
await rename(built, destination)
return destination
} finally {
await rm(buildRoot, { recursive: true, force: true })
}
}
export async function linkSystemPluginScope({
dshHome,
viewRoot = '/run/dsh-platform/views/system-plugins',
}) {
const modulesRoot = join(resolve(dshHome), 'profiles', 'node_modules')
const scopeLink = join(modulesRoot, '@dsh-docker')
const target = join(resolve(viewRoot), 'packages')
await mkdir(modulesRoot, { recursive: true })
const existing = await lstat(scopeLink).catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error))
if (existing !== undefined) {
if (!existing.isSymbolicLink()) {
throw new Error(`System Plugin scope ${scopeLink} exists and is not a symlink`)
}
if (await readlink(scopeLink) === target) return scopeLink
await rm(scopeLink)
}
const temporary = join(modulesRoot, `.@dsh-docker.${randomUUID()}.tmp`)
try {
await symlink(target, temporary, 'dir')
await rename(temporary, scopeLink)
} finally {
await rm(temporary, { force: true })
}
return scopeLink
}
@@ -0,0 +1,203 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]{0,126}\/[a-z0-9][a-z0-9._-]{0,126}|[a-z0-9][a-z0-9._-]{0,213})$/i
const SELECTION_SCHEMA = 1
function packagePath(root, packageName) {
if (!PACKAGE_NAME_PATTERN.test(packageName)) return undefined
return join(root, 'node_modules', ...packageName.split('/'), 'package.json')
}
async function optionalBytes(path) {
return readFile(path).then(
bytes => ({ present: true, bytes }),
error => error?.code === 'ENOENT' ? { present: false, bytes: Buffer.alloc(0) } : Promise.reject(error),
)
}
function parseObject(bytes, label) {
let value
try {
value = JSON.parse(bytes.toString('utf8'))
} catch (error) {
throw new Error(`${label} is not valid JSON`, { cause: error })
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must contain a JSON object`)
}
return value
}
function profileFields(manifest) {
const dependencies = manifest.dependencies ?? {}
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
throw new Error('Web Profile dependencies must be an object')
}
for (const [name, spec] of Object.entries(dependencies)) {
if (typeof spec !== 'string' || spec.length === 0) {
throw new Error(`Web Profile dependency ${name} must have a non-empty string spec`)
}
}
const bundles = manifest.dsh?.profile?.bundles ?? []
if (!Array.isArray(bundles) || bundles.some(name => typeof name !== 'string')) {
throw new Error('Web Profile dsh.profile.bundles must be a string array')
}
return { dependencies, bundles }
}
function disabledPositions(value) {
if (value === undefined) return new Map()
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| value.schema !== SELECTION_SCHEMA || !Array.isArray(value.disabled)) {
throw new Error('User Plugin selection state is invalid')
}
const positions = new Map()
for (const entry of value.disabled) {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|| !PACKAGE_NAME_PATTERN.test(entry.name) || !Number.isSafeInteger(entry.index) || entry.index < 0) {
throw new Error('User Plugin disabled-order entry is invalid')
}
if (positions.has(entry.name)) throw new Error(`User Plugin ${entry.name} has duplicate disabled-order entries`)
positions.set(entry.name, entry.index)
}
return positions
}
function sourceForSpec(spec) {
if (/^(?:file|link):/i.test(spec) || /^(?:\.{1,2}\/|\/)/.test(spec)) return 'file'
if (/^(?:git(?:\+[^:]+)?|github|gitlab|bitbucket):/i.test(spec) || /\.git(?:#|$)/i.test(spec)) return 'git'
if (/^https?:/i.test(spec)) return 'url'
if (/^npm:/i.test(spec) || /^(?:[~^<>=*]|\d|v\d)/i.test(spec) || spec === 'latest' || spec === 'next') return 'registry'
return 'other'
}
function updateRevision(hash, label, file) {
hash.update(`${label}:${file.present ? '1' : '0'}:${String(file.bytes.length)}:`)
hash.update(file.bytes)
hash.update('\0')
}
function metadataFailure(error) {
if (error?.code === 'ENOENT') return 'installed package metadata is missing'
if (error instanceof SyntaxError) return 'installed package metadata is not valid JSON'
return error instanceof Error ? error.message : String(error)
}
async function inspectDependency(profileRoot, name, spec, enabledBundles, disabled, reservedNames) {
const path = packagePath(profileRoot, name)
let metadata
let metadataError = null
if (path === undefined) {
metadataError = 'dependency name is invalid'
} else {
try {
metadata = JSON.parse(await readFile(path, 'utf8'))
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
throw new Error('installed package metadata must be an object')
}
if (metadata.name !== name) throw new Error(`installed package name is ${String(metadata.name)}`)
} catch (error) {
metadataError = metadataFailure(error)
}
}
if (metadataError === null && metadata.version !== undefined
&& (typeof metadata.version !== 'string' || metadata.version.length === 0)) {
metadataError = 'installed package version is invalid'
}
const bundleDeclaration = metadata?.dsh?.bundle
const patch = bundleDeclaration?.patch
const isBundle = typeof patch === 'string' && patch.length > 0
const reservedNameConflict = reservedNames.has(name)
if (metadataError === null && !isBundle
&& (bundleDeclaration !== undefined || enabledBundles.has(name))) {
metadataError = 'installed package Bundle metadata is invalid'
}
if (!isBundle && metadataError === null && !reservedNameConflict) return undefined
return Object.freeze({
name,
spec,
source: sourceForSpec(spec),
version: typeof metadata?.version === 'string' ? metadata.version : null,
description: typeof metadata?.description === 'string' && metadata.description.trim() !== '' ? metadata.description.trim() : null,
enabled: !reservedNameConflict && enabledBundles.has(name),
previousIndex: disabled.get(name) ?? null,
damaged: metadataError !== null,
metadataError,
reservedNameConflict,
})
}
function loadedIdentity(plugin) {
return JSON.stringify([
plugin.name, plugin.spec, plugin.version, plugin.enabled, plugin.damaged,
plugin.metadataError, plugin.reservedNameConflict,
])
}
export function markUserPluginRestartState(current, loaded) {
if (loaded === undefined) return current
const loadedPlugins = new Map(loaded.plugins.map(plugin => [plugin.name, loadedIdentity(plugin)]))
const plugins = current.plugins.map(plugin => Object.freeze({
...plugin,
pendingRestart: loadedPlugins.get(plugin.name) !== loadedIdentity(plugin),
}))
const currentNames = new Set(current.plugins.map(plugin => plugin.name))
const removedLoadedPlugin = loaded.plugins.some(plugin => !currentNames.has(plugin.name))
return Object.freeze({
...current,
plugins: Object.freeze(plugins),
restartRequired: removedLoadedPlugin || plugins.some(plugin => plugin.pendingRestart),
})
}
export class UserPluginInventory {
constructor({ dshHome = '/data/dsh', profile = 'web', selectionPath, systemPluginNames = async () => [] } = {}) {
if (profile !== 'web') throw new Error('Only the web DSH Profile is supported')
this.profile = profile
this.profileRoot = join(resolve(dshHome), 'profiles', profile)
this.selectionPath = selectionPath === undefined
? join(resolve(dshHome), '..', 'platform', 'state', 'management', 'user-plugins.json')
: resolve(selectionPath)
this.systemPluginNames = systemPluginNames
}
async read() {
const manifestPath = join(this.profileRoot, 'package.json')
const lockfilePath = join(this.profileRoot, 'pnpm-lock.yaml')
const [manifestBytes, lockfile, selectionFile, names] = await Promise.all([
readFile(manifestPath),
optionalBytes(lockfilePath),
optionalBytes(this.selectionPath),
this.systemPluginNames(),
])
const manifest = parseObject(manifestBytes, 'Web Profile package.json')
const { dependencies, bundles } = profileFields(manifest)
const selection = !selectionFile.present
? undefined
: parseObject(selectionFile.bytes, 'User Plugin selection state')
const disabled = disabledPositions(selection)
const reservedNames = new Set(names)
if ([...reservedNames].some(name => typeof name !== 'string' || !PACKAGE_NAME_PATTERN.test(name))) {
throw new Error('Verified System Plugin names are invalid')
}
const enabledBundles = new Set(bundles)
const inspected = await Promise.all(Object.entries(dependencies).map(([name, spec]) => (
inspectDependency(this.profileRoot, name, spec, enabledBundles, disabled, reservedNames)
)))
const revisionHash = createHash('sha256')
updateRevision(revisionHash, 'manifest', { present: true, bytes: manifestBytes })
updateRevision(revisionHash, 'lockfile', lockfile)
updateRevision(revisionHash, 'selection', selectionFile)
const revision = revisionHash.digest('hex')
return Object.freeze({
schema: 1,
profile: this.profile,
revision: `sha256:${revision}`,
plugins: Object.freeze(inspected.filter(Boolean)),
})
}
}
export const userPluginInternals = Object.freeze({ PACKAGE_NAME_PATTERN, sourceForSpec, loadedIdentity })
@@ -0,0 +1,116 @@
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { userPluginInternals } from './user-inventory.mjs'
const PHASES = new Set([
'validated', 'paused', 'snapshotted', 'mutating', 'committed', 'restarting',
'restoring', 'completed', 'failed',
])
const TERMINAL = new Set(['completed', 'failed'])
const TRANSITIONS = new Map([
['validated', new Set(['paused', 'snapshotted', 'failed'])],
['paused', new Set(['snapshotted', 'restoring', 'failed'])],
['snapshotted', new Set(['mutating', 'restoring'])],
['mutating', new Set(['committed', 'restoring'])],
['committed', new Set(['restarting', 'completed', 'failed'])],
['restarting', new Set(['completed', 'failed'])],
['restoring', new Set(['failed'])],
['completed', new Set()],
['failed', new Set()],
])
function parseAction(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'action,name'
|| typeof value.name !== 'string' || !userPluginInternals.PACKAGE_NAME_PATTERN.test(value.name)
|| !['enable', 'disable', 'uninstall'].includes(value.action)) {
throw new Error('User Plugin journal action is invalid')
}
return Object.freeze({ name: value.name, action: value.action })
}
function parse(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'actions,error,phase,previousDisabled,recoveryResult,revision,schema,selectionPresent,snapshotId,taskId,updatedAt'
|| value.schema !== 1 || typeof value.taskId !== 'string' || !/^[A-Za-z0-9-]{1,128}$/.test(value.taskId)
|| !PHASES.has(value.phase) || typeof value.revision !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value.revision)
|| !Array.isArray(value.actions) || value.actions.length === 0
|| typeof value.selectionPresent !== 'boolean' || !Array.isArray(value.previousDisabled)
|| (value.snapshotId !== null && typeof value.snapshotId !== 'string')
|| (value.error !== null && typeof value.error !== 'string')
|| ![null, 'success', 'failed'].includes(value.recoveryResult)
|| typeof value.updatedAt !== 'string' || new Date(value.updatedAt).toISOString() !== value.updatedAt) {
throw new Error('User Plugin transaction journal is invalid')
}
const names = new Set()
const previousDisabled = value.previousDisabled.map(entry => {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|| Object.keys(entry).sort().join(',') !== 'index,name'
|| typeof entry.name !== 'string' || !userPluginInternals.PACKAGE_NAME_PATTERN.test(entry.name)
|| !Number.isSafeInteger(entry.index) || entry.index < 0
|| names.has(entry.name)) throw new Error('User Plugin journal disabled-order state is invalid')
names.add(entry.name)
return Object.freeze({ name: entry.name, index: entry.index })
})
return Object.freeze({
...value,
actions: Object.freeze(value.actions.map(parseAction)),
previousDisabled: Object.freeze(previousDisabled),
})
}
export class UserPluginJournal {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
this.queue = Promise.resolve()
}
exclusive(operation) {
const result = this.queue.then(operation, operation)
this.queue = result.then(() => undefined, () => undefined)
return result
}
async read() {
try { return parse(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) {
if (error?.code === 'ENOENT') return undefined
throw error
}
}
begin({ taskId, revision, actions, selection }) {
return this.exclusive(async () => {
const current = await this.read()
if (current !== undefined && !TERMINAL.has(current.phase)) {
throw new Error('a User Plugin transaction is already active')
}
const next = parse({
schema: 1, taskId, revision, actions,
selectionPresent: selection.present,
previousDisabled: selection.state.disabled,
phase: 'validated', snapshotId: null,
error: null, recoveryResult: null, updatedAt: this.now().toISOString(),
})
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return next
})
}
transition(phase, fields = {}) {
return this.exclusive(async () => {
const current = await this.read()
if (current === undefined || !TRANSITIONS.get(current.phase)?.has(phase)) {
throw new Error(`User Plugin journal cannot transition to ${phase}`)
}
const next = parse({ ...current, ...fields, phase, updatedAt: this.now().toISOString() })
if (next.taskId !== current.taskId || next.revision !== current.revision) {
throw new Error('User Plugin journal identity cannot change')
}
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return next
})
}
}
export const userPluginJournalInternals = Object.freeze({ parse, TERMINAL })
@@ -0,0 +1,143 @@
import { spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { lstat, mkdir, open, readFile, readdir, rename, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
function validId(id) {
if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw new Error('User Plugin snapshot ID is invalid')
return id
}
function run(command, args) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolveRun(Buffer.concat(stdout).toString('utf8'))
: reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`)))
})
}
async function measure(path) {
const handle = await open(path, 'r')
const hash = createHash('sha256')
let size = 0
try {
for await (const chunk of handle.createReadStream({ autoClose: false })) {
hash.update(chunk)
size += chunk.byteLength
}
} finally {
await handle.close()
}
return { sha256: hash.digest('hex'), size }
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
function parseManifest(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'archiveSha256,archiveSize,createdAt,id,schema,source'
|| value.schema !== 1 || !ID_PATTERN.test(value.id)
|| value.source !== '/data/dsh/profiles/web'
|| typeof value.createdAt !== 'string' || new Date(value.createdAt).toISOString() !== value.createdAt
|| typeof value.archiveSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.archiveSha256)
|| !Number.isSafeInteger(value.archiveSize) || value.archiveSize < 0) {
throw new Error('User Plugin snapshot manifest is invalid')
}
return Object.freeze({ ...value })
}
export class UserPluginSnapshots {
constructor({ root, profileRoot = '/data/dsh/profiles/web', now = () => new Date(), runImpl = run }) {
this.root = resolve(root)
this.profileRoot = resolve(profileRoot)
this.now = now
this.run = runImpl
}
path(id) { return join(this.root, validId(id)) }
async create(id = randomUUID()) {
validId(id)
const details = await lstat(this.profileRoot)
if (!details.isDirectory() || details.isSymbolicLink()) throw new Error('Web Profile must be a real directory')
await mkdir(this.root, { recursive: true })
const destination = this.path(id)
await lstat(destination).then(
() => { throw new Error(`User Plugin snapshot ${id} already exists`) },
error => { if (error?.code !== 'ENOENT') throw error },
)
const staging = join(this.root, `.${id}.${randomUUID()}.tmp`)
await mkdir(staging)
const archive = join(staging, 'profile.tar')
try {
await this.run('tar', ['-cpf', archive, '-C', this.profileRoot, '.'])
const measured = await measure(archive)
const manifest = parseManifest({
schema: 1,
id,
source: '/data/dsh/profiles/web',
createdAt: this.now().toISOString(),
archiveSha256: measured.sha256,
archiveSize: measured.size,
})
await durableReplace(join(staging, 'manifest.json'), canonicalJson(manifest))
await rename(staging, destination)
await syncDirectory(this.root)
return Object.freeze({ ...manifest, path: destination })
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async inspect(id) {
const root = this.path(id)
const manifest = parseManifest(JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8')))
if (manifest.id !== id) throw new Error('User Plugin snapshot ID does not match its directory')
const archive = await lstat(join(root, 'profile.tar')).then(
() => join(root, 'profile.tar'),
error => error?.code === 'ENOENT' ? join(root, 'profile.tar.gz') : Promise.reject(error),
)
const measured = await measure(archive)
if (measured.sha256 !== manifest.archiveSha256 || measured.size !== manifest.archiveSize) {
throw new Error('User Plugin snapshot archive does not match its manifest')
}
const entries = (await this.run('tar', [archive.endsWith('.gz') ? '-tzf' : '-tf', archive])).split('\n').filter(Boolean)
if (entries.length === 0 || entries.some(entry => entry.startsWith('/') || entry.split('/').includes('..'))) {
throw new Error('User Plugin snapshot archive contains unsafe paths')
}
return Object.freeze({ ...manifest, archive })
}
async restore(id) {
const snapshot = await this.inspect(id)
await mkdir(this.profileRoot, { recursive: true })
for (const entry of await readdir(this.profileRoot)) {
await rm(join(this.profileRoot, entry), { recursive: true, force: true })
}
const ownership = process.getuid?.() === 0 ? [] : ['--no-same-owner']
await this.run('tar', [snapshot.archive.endsWith('.gz') ? '-xzpf' : '-xpf', snapshot.archive, ...ownership, '-C', this.profileRoot])
await syncDirectory(this.profileRoot)
return snapshot
}
async remove(id) {
validId(id)
await rm(this.path(id), { recursive: true, force: true })
}
}
export const userPluginSnapshotInternals = Object.freeze({ parseManifest })
@@ -0,0 +1,67 @@
import { open, readFile, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { userPluginInternals } from './user-inventory.mjs'
function parse(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'disabled,schema'
|| value.schema !== 1 || !Array.isArray(value.disabled)) {
throw new Error('User Plugin selection state is invalid')
}
const names = new Set()
const disabled = value.disabled.map(entry => {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|| Object.keys(entry).some(key => !['index', 'name'].includes(key))
|| !userPluginInternals.PACKAGE_NAME_PATTERN.test(entry.name)
|| !Number.isSafeInteger(entry.index) || entry.index < 0 || names.has(entry.name)) {
throw new Error('User Plugin disabled-order entry is invalid')
}
names.add(entry.name)
return Object.freeze({ name: entry.name, index: entry.index })
})
return Object.freeze({ schema: 1, disabled: Object.freeze(disabled) })
}
export class UserPluginSelectionStore {
constructor(path) {
this.path = path
}
async read() {
return (await this.snapshot()).state
}
async snapshot() {
try {
return Object.freeze({ present: true, state: parse(JSON.parse(await readFile(this.path, 'utf8'))) })
} catch (error) {
if (error?.code === 'ENOENT') {
return Object.freeze({ present: false, state: parse({ schema: 1, disabled: [] }) })
}
throw error
}
}
async write(value) {
const state = parse(value)
await durableReplace(this.path, `${canonicalJson(state).toString('utf8')}\n`)
return state
}
async restore(snapshot) {
if (snapshot === null || typeof snapshot !== 'object' || typeof snapshot.present !== 'boolean') {
throw new Error('User Plugin selection snapshot is invalid')
}
if (snapshot.present) return this.write(snapshot.state)
await rm(this.path, { force: true })
const directory = await open(dirname(this.path), 'r').catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error))
if (directory !== undefined) {
try { await directory.sync() } finally { await directory.close() }
}
return parse({ schema: 1, disabled: [] })
}
}
export const userPluginStateInternals = Object.freeze({ parse })
@@ -0,0 +1,414 @@
import { spawn } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { userPluginInternals } from './user-inventory.mjs'
const PACKAGE_PATH_ENVIRONMENT = new Set([
'npm_config_cache',
'npm_config_prefix',
'npm_config_store_dir',
'npm_config_userconfig',
'pnpm_home',
])
function pluginEnvironment(dshHome, home = '/home/node') {
const environment = {
...process.env,
HOME: home,
XDG_CACHE_HOME: join(home, '.cache'),
XDG_CONFIG_HOME: join(home, '.config'),
XDG_DATA_HOME: join(home, '.local', 'share'),
XDG_STATE_HOME: join(home, '.local', 'state'),
DSH_HOME: dshHome,
}
for (const name of Object.keys(environment)) {
if (PACKAGE_PATH_ENVIRONMENT.has(name.toLowerCase())) delete environment[name]
}
return environment
}
function runDshPlugin({ dshHome, profile, name }, timeoutMs = 300_000) {
return new Promise((resolveRun, reject) => {
const child = spawn('/usr/local/bin/dsh', ['plugin', '--profile', profile, 'remove', name], {
env: pluginEnvironment(dshHome),
stdio: ['ignore', 'pipe', 'pipe'],
})
const stdout = []
const stderr = []
let size = 0
let settled = false
let timeoutError
let killTimer
const finish = (error) => {
if (settled) return
settled = true
clearTimeout(timer)
clearTimeout(killTimer)
if (error === undefined) resolveRun()
else reject(error)
}
const timer = setTimeout(() => {
timeoutError = new Error(`dsh plugin remove timed out for ${name}`)
child.kill('SIGTERM')
killTimer = setTimeout(() => child.kill('SIGKILL'), 5_000)
killTimer.unref()
}, timeoutMs)
timer.unref()
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => {
size += chunk.byteLength
if (size <= 256 * 1024) (stream === child.stdout ? stdout : stderr).push(chunk)
})
child.once('error', finish)
child.once('exit', code => timeoutError !== undefined
? finish(timeoutError)
: code === 0
? finish()
: finish(new Error(`dsh plugin remove failed for ${name}: ${[
['stdout', Buffer.concat(stdout).toString('utf8').trim()],
['stderr', Buffer.concat(stderr).toString('utf8').trim()],
].filter(([, value]) => value !== '').map(([stream, value]) => `${stream}: ${value}`).join('\\n')}`)))
})
}
function message(error) {
return error instanceof Error ? error.message : String(error)
}
function validateActions(inventory, actions) {
if (!Array.isArray(actions) || actions.length === 0 || actions.length > 128) {
throw new Error('User Plugin actions must be a non-empty bounded array')
}
const plugins = new Map(inventory.plugins.map(plugin => [plugin.name, plugin]))
const names = new Set()
return Object.freeze(actions.map(value => {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).sort().join(',') !== 'action,name'
|| !userPluginInternals.PACKAGE_NAME_PATTERN.test(value.name)
|| !['enable', 'disable', 'uninstall'].includes(value.action)
|| names.has(value.name)) {
throw new Error('User Plugin action is invalid')
}
names.add(value.name)
const plugin = plugins.get(value.name)
if (plugin === undefined) throw new Error(`User Plugin ${value.name} is not managed by the Web Profile`)
if (value.action === 'enable' && (plugin.damaged || plugin.reservedNameConflict)) {
throw new Error(`User Plugin ${value.name} cannot be enabled`)
}
return Object.freeze({ name: value.name, action: value.action })
}))
}
async function mutateProfile({ inventory, selectionStore, actions, runPlugin }) {
const manifestPath = join(inventory.profileRoot, 'package.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
const bundles = [...(manifest.dsh?.profile?.bundles ?? [])]
const originalBundles = [...bundles]
const selection = await selectionStore.read()
const disabled = new Map(selection.disabled.map(entry => [entry.name, entry.index]))
for (const { name, action } of actions) {
if (action === 'disable') {
const index = bundles.indexOf(name)
if (index !== -1) {
disabled.set(name, originalBundles.indexOf(name))
for (let found = bundles.indexOf(name); found !== -1; found = bundles.indexOf(name)) bundles.splice(found, 1)
}
} else if (action === 'enable') {
if (!bundles.includes(name)) bundles.splice(Math.min(disabled.get(name) ?? bundles.length, bundles.length), 0, name)
disabled.delete(name)
} else if (action === 'uninstall') {
for (let found = bundles.indexOf(name); found !== -1; found = bundles.indexOf(name)) bundles.splice(found, 1)
}
}
manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
await durableReplace(manifestPath, `${canonicalJson(manifest).toString('utf8')}\n`)
for (const { name, action } of actions) {
if (action === 'uninstall') {
await runPlugin({ dshHome: join(inventory.profileRoot, '..', '..'), profile: inventory.profile, name })
disabled.delete(name)
}
}
if (actions.some(({ action }) => action === 'uninstall')) {
const installedManifest = JSON.parse(await readFile(manifestPath, 'utf8'))
installedManifest.dsh = {
...installedManifest.dsh,
profile: { ...installedManifest.dsh?.profile, bundles },
}
await durableReplace(manifestPath, `${canonicalJson(installedManifest).toString('utf8')}\n`)
}
await selectionStore.write({
schema: 1,
disabled: [...disabled].map(([name, index]) => ({ name, index })),
})
const resulting = await inventory.read()
const result = new Map(resulting.plugins.map(plugin => [plugin.name, plugin]))
for (const { name, action } of actions) {
if (action === 'uninstall' && result.has(name)) throw new Error(`User Plugin ${name} remains installed`)
if (action === 'enable' && result.get(name)?.enabled !== true) throw new Error(`User Plugin ${name} was not enabled`)
if (action === 'disable' && result.get(name)?.enabled !== false) throw new Error(`User Plugin ${name} was not disabled`)
}
return resulting
}
export class UserPluginTransactionManager {
constructor({ inventory, selectionStore, snapshots, journal, pauseDsh, restartDsh, runPlugin = runDshPlugin, report = async () => {} }) {
this.inventory = inventory
this.selectionStore = selectionStore
this.snapshots = snapshots
this.journal = journal
this.pauseDsh = pauseDsh
this.restartDsh = restartDsh
this.runPlugin = runPlugin
this.report = report
}
record(messageValue, fields = {}) {
return Promise.resolve().then(() => this.report(messageValue, fields)).catch(() => {})
}
async validate({ revision, actions }) {
const current = await this.inventory.read()
if (revision !== current.revision) {
const error = new Error('User Plugin inventory revision has changed')
error.code = 'REVISION_CONFLICT'
throw error
}
return Object.freeze({ revision, actions: validateActions(current, actions) })
}
async apply({ taskId = randomUUID(), revision, actions, strategy = 'restart', onProgress = () => {} }) {
if (!['restart', 'next-start'].includes(strategy)) throw new Error('User Plugin activation strategy is invalid')
const validated = await this.validate({ revision, actions })
const normalized = validated.actions
const publishProgress = state => { try { onProgress(state) } catch {} }
const selectionBefore = await this.selectionStore.snapshot()
let state = await this.journal.begin({ taskId, revision, actions: normalized, selection: selectionBefore })
publishProgress(state)
let paused = false
let snapshotCreated = false
await this.record('user-plugin.transaction.started', { taskId, actions: normalized })
try {
if (strategy === 'restart') {
await this.pauseDsh()
paused = true
state = await this.journal.transition('paused')
publishProgress(state)
}
await this.snapshots.create(taskId)
snapshotCreated = true
state = await this.journal.transition('snapshotted', { snapshotId: taskId })
publishProgress(state)
state = await this.journal.transition('mutating')
publishProgress(state)
const result = await mutateProfile({
inventory: this.inventory,
selectionStore: this.selectionStore,
actions: normalized,
runPlugin: this.runPlugin,
})
state = await this.journal.transition('committed')
publishProgress(state)
let completedStrategy = strategy
let activationError = null
if (strategy === 'restart') {
state = await this.journal.transition('restarting')
publishProgress(state)
try {
await this.restartDsh()
} catch (error) {
completedStrategy = 'next-start'
activationError = message(error)
await this.record('user-plugin.activation.deferred', { taskId, error })
}
}
state = await this.journal.transition('completed')
publishProgress(state)
await this.snapshots.remove(taskId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', { taskId, error }))
await this.record('user-plugin.transaction.completed', { taskId })
return Object.freeze({
taskId, status: 'success', strategy: completedStrategy, activationError, inventory: result,
})
} catch (error) {
const committed = ['committed', 'restarting'].includes(state.phase)
if (committed) {
await this.journal.transition('failed', { error: message(error) })
await this.snapshots.remove(taskId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', { taskId, error: cleanupError }))
} else if (snapshotCreated || state.snapshotId !== null) {
const snapshotId = state.snapshotId ?? taskId
try {
if (state.phase !== 'restoring') state = await this.journal.transition('restoring', {
error: message(error),
snapshotId,
})
await this.snapshots.restore(snapshotId)
await this.selectionStore.restore(selectionBefore)
if (strategy === 'restart') await this.restartDsh()
await this.journal.transition('failed', { recoveryResult: 'success' })
await this.snapshots.remove(snapshotId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', {
taskId,
error: cleanupError,
}))
} catch (recoveryError) {
const aggregate = new AggregateError([error, recoveryError], 'User Plugin transaction and recovery failed')
aggregate.journal = await this.journal.transition('failed', {
error: `${message(error)}; recovery failed: ${message(recoveryError)}`,
recoveryResult: 'failed',
}).catch(() => {})
throw aggregate
}
} else if (paused) {
try {
await this.restartDsh()
await this.journal.transition('failed', { error: message(error), recoveryResult: 'success' })
} catch (recoveryError) {
const aggregate = new AggregateError([error, recoveryError], 'User Plugin transaction and recovery failed')
aggregate.journal = await this.journal.transition('failed', {
error: `${message(error)}; recovery failed: ${message(recoveryError)}`,
recoveryResult: 'failed',
}).catch(() => {})
throw aggregate
}
} else {
await this.journal.transition('failed', { error: message(error) }).catch(() => {})
}
await this.record('user-plugin.transaction.failed', { taskId, error, committed })
if (error !== null && typeof error === 'object') error.journal = await this.journal.read().catch(() => undefined)
throw error
}
}
async recover() {
const state = await this.journal.read()
if (state === undefined) return undefined
if (['completed', 'failed'].includes(state.phase)) {
if (state.snapshotId !== null) await this.snapshots.remove(state.snapshotId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error,
}))
return state
}
if (['committed', 'restarting'].includes(state.phase)) {
try {
if (state.phase === 'committed') await this.journal.transition('restarting')
await this.restartDsh()
const completed = await this.journal.transition('completed')
if (state.snapshotId !== null) await this.snapshots.remove(state.snapshotId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error,
}))
return completed
} catch (error) {
const failed = await this.journal.transition('failed', { error: message(error) })
if (state.snapshotId !== null) {
await this.snapshots.remove(state.snapshotId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error: cleanupError,
}))
}
return failed
}
}
if (state.phase === 'validated') return this.journal.transition('failed', { error: 'transaction interrupted before DSH pause' })
await this.pauseDsh()
if (state.phase === 'paused') {
try {
await this.restartDsh()
return this.journal.transition('failed', { error: 'transaction interrupted before snapshot', recoveryResult: 'success' })
} catch (error) {
return this.journal.transition('failed', { error: message(error), recoveryResult: 'failed' })
}
}
try {
const restoring = state.phase === 'restoring' ? state : await this.journal.transition('restoring', {
error: state.error ?? 'transaction interrupted before commit',
})
await this.snapshots.restore(restoring.snapshotId)
await this.selectionStore.restore({
present: restoring.selectionPresent,
state: { schema: 1, disabled: restoring.previousDisabled },
})
await this.restartDsh()
const failed = await this.journal.transition('failed', { recoveryResult: 'success' })
await this.snapshots.remove(restoring.snapshotId).catch(error => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error,
}))
return failed
} catch (error) {
return this.journal.transition('failed', { error: message(error), recoveryResult: 'failed' })
}
}
async recoverBeforeDshStart() {
return recoverUserPluginBeforeDshStart({
journal: this.journal,
report: (messageValue, fields) => this.record(messageValue, fields),
selectionStore: this.selectionStore,
snapshots: this.snapshots,
})
}
async completeDshStartup({ healthy, error = null }) {
const state = await this.journal.read()
if (state === undefined || state.phase !== 'restarting') return state
const next = healthy
? await this.journal.transition('completed')
: await this.journal.transition('failed', { error: error ?? 'DSH failed to start after the committed User Plugin change' })
if (state.snapshotId !== null) {
await this.snapshots.remove(state.snapshotId).catch(cleanupError => this.record('user-plugin.snapshot.cleanup.failed', {
taskId: state.taskId,
error: cleanupError,
}))
}
return next
}
}
export async function recoverUserPluginBeforeDshStart({ journal, selectionStore, snapshots, report = async () => {} }) {
const record = (messageValue, fields) => Promise.resolve()
.then(() => report(messageValue, fields))
.catch(() => {})
let state = await journal.read()
if (state === undefined) return undefined
if (['completed', 'failed'].includes(state.phase)) {
if (state.snapshotId !== null) await snapshots.remove(state.snapshotId).catch(error => record(
'user-plugin.snapshot.cleanup.failed', { taskId: state.taskId, error },
))
return state
}
if (state.phase === 'validated') {
return journal.transition('failed', {
error: 'transaction interrupted before DSH pause', recoveryResult: 'success',
})
}
if (state.phase === 'paused') {
return journal.transition('failed', {
error: 'transaction interrupted before snapshot', recoveryResult: 'success',
})
}
if (['committed', 'restarting'].includes(state.phase)) {
if (state.phase === 'committed') state = await journal.transition('restarting')
return state
}
try {
if (state.phase !== 'restoring') state = await journal.transition('restoring', {
error: state.error ?? 'transaction interrupted before commit',
})
await snapshots.restore(state.snapshotId)
await selectionStore.restore({
present: state.selectionPresent,
state: { schema: 1, disabled: state.previousDisabled },
})
const failed = await journal.transition('failed', { recoveryResult: 'success' })
await snapshots.remove(state.snapshotId).catch(error => record(
'user-plugin.snapshot.cleanup.failed', { taskId: state.taskId, error },
))
return failed
} catch (error) {
return journal.transition('failed', { error: message(error), recoveryResult: 'failed' })
}
}
export const userPluginTransactionInternals = Object.freeze({ mutateProfile, pluginEnvironment, runDshPlugin, validateActions })