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:
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 })
|
||||
Reference in new issue
Block a user