Files
dsh/upstream/container/control-plane/modules/plugin-manager/user-transaction.mjs
T

415 lines
17 KiB
JavaScript

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 })