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