Files

442 lines
19 KiB
JavaScript

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
}