import assert from 'node:assert/strict' import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' import { request as httpRequest } from 'node:http' import { join } from 'node:path' import WebSocket from '/run/dsh-platform/views/bootstrap/control-plane/services/management/node_modules/ws/wrapper.mjs' const API = '/_dsh_platform/api/v1/' const cookie = (await readFile('/tmp/dsh-smoke.cookies', 'utf8')) .split('\n') .map(line => line.startsWith('#HttpOnly_') ? line.slice('#HttpOnly_'.length) : line) .filter(line => line !== '' && !line.startsWith('#')) .map(line => line.split('\t')) .filter(fields => fields.length === 7) .map(fields => `${fields[5]}=${fields[6]}`) .join('; ') const headers = Object.freeze({ cookie, host: 'smoke.example', origin: 'http://smoke.example', 'x-dsh-csrf': process.env.SMOKE_MANAGEMENT_CSRF, }) function request(method, path, body) { return new Promise((resolve, reject) => { const encoded = body === undefined ? undefined : Buffer.from(JSON.stringify(body)) const outgoing = httpRequest({ host: '127.0.0.1', port: 3080, method, path, headers: { ...headers, ...(encoded === undefined ? {} : { 'content-type': 'application/json', 'content-length': encoded.byteLength }), }, }, response => { const chunks = [] response.on('data', chunk => chunks.push(chunk)) response.once('end', () => { const text = Buffer.concat(chunks).toString('utf8') const value = text === '' ? undefined : JSON.parse(text) if ((response.statusCode ?? 500) >= 400) { const error = new Error(value?.error ?? `HTTP ${String(response.statusCode)}`) error.statusCode = response.statusCode reject(error) } else resolve(value) }) }) outgoing.once('error', reject) outgoing.end(encoded) }) } async function waitFor(check, label, attempts = 300) { let last for (let attempt = 0; attempt < attempts; attempt += 1) { try { last = await check() if (last) return last } catch (error) { last = error } await new Promise(resolve => setTimeout(resolve, 200)) } throw new Error(`${label} timed out: ${last instanceof Error ? last.message : JSON.stringify(last)}`) } function connectTerminal(sessionId) { const socket = new WebSocket(`ws://127.0.0.1:3080${API}terminal/sessions/${sessionId}/stream`, { headers, }) let output = '' const waiters = new Set() socket.on('message', data => { const message = JSON.parse(data.toString()) if (message.type === 'output') output += message.data for (const waiter of waiters) waiter() }) const opened = new Promise((resolve, reject) => { socket.once('open', resolve) socket.once('error', reject) }) const waitOutput = async (marker, occurrences = 1) => { for (let attempt = 0; attempt < 300; attempt += 1) { if (output.split(marker).length - 1 >= occurrences) return output await new Promise(resolve => { const timer = setTimeout(() => { waiters.delete(done); resolve() }, 100) const done = () => { clearTimeout(timer); waiters.delete(done); resolve() } waiters.add(done) }) } throw new Error(`terminal output did not contain ${marker}: ${output}`) } return { socket, opened, waitOutput } } async function waitTask(kind, taskId, expected) { let lastOperation try { return await waitFor(async () => { const status = await request('GET', `${API}status`) const operation = status[kind] lastOperation = operation const operationState = operation?.status ?? operation?.state return operation?.taskId === taskId && expected.includes(operationState) ? operation : false }, `${kind} ${taskId}`, 600) } catch (error) { throw new Error(`${error.message}; last operation: ${JSON.stringify(lastOperation)}`) } } const terminal = await request('POST', `${API}terminal/sessions`, { cols: 100, rows: 32 }) assert.equal(terminal.privileged, true) const first = connectTerminal(terminal.sessionId) await first.opened first.socket.send(JSON.stringify({ type: 'input', data: "printf 'terminal-before-restart\\n'; printf 'uid=%s\\n' \"$(id -u)\"; printf 'gids=%s\\n' \"$(id -G)\"; printf 'cwd=%s\\n' \"$PWD\"; printf 'home=%s\\n' \"$HOME\"; printf 'dsh=%s\\n' \"$DSH_HOME\"; sudo -n true && printf 'sudo=ok\\n'\n", })) const identity = await first.waitOutput('sudo=ok', 3) assert.match(identity, /terminal-before-restart/) assert.match(identity, /uid=0/) assert.match(identity, /gids=.+/) assert.match(identity, /cwd=\/root/) assert.match(identity, /home=\/root/) assert.match(identity, /dsh=\/data\/dsh/) const restart = await request('POST', `${API}restart-dsh`) await waitTask('dshLifecycle', restart.taskId, ['running']) first.socket.send(JSON.stringify({ type: 'input', data: "printf 'terminal-after-restart-中文\\n'\n" })) await first.waitOutput('terminal-after-restart-中文', 2) first.socket.close() await new Promise(resolve => first.socket.once('close', resolve)) const second = connectTerminal(terminal.sessionId) await second.opened await second.waitOutput('terminal-after-restart-中文') await request('DELETE', `${API}terminal/sessions/${terminal.sessionId}`) await new Promise(resolve => second.socket.once('close', resolve)) await assert.rejects(request('GET', `${API}terminal/sessions/${terminal.sessionId}`), error => error.statusCode === 404) const profileRoot = '/data/dsh/profiles/web' const manifestPath = join(profileRoot, 'package.json') const faultNames = ['smoke-fault-one', 'smoke-fault-two'] const pluginRoots = new Map() for (const name of faultNames) { const source = join('/workspace/smoke-user-plugin-sources', name) const installed = join(profileRoot, 'node_modules', name) pluginRoots.set(name, source) await mkdir(source, { recursive: true }) await writeFile(join(source, 'package.json'), `${JSON.stringify({ name, version: '1.0.0', type: 'module', main: './index.mjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, }, null, 2)}\n`) await writeFile(join(source, 'index.mjs'), `throw new Error('${name} startup failure')\n`) await writeFile(join(source, 'cordis.patch.yml'), `- insert:\n - id: ${name}\n name: ${JSON.stringify(`file://${join(source, 'index.mjs')}`)}\n`) await cp(source, installed, { recursive: true }) } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) manifest.dependencies ??= {} manifest.dsh ??= {} manifest.dsh.profile ??= {} manifest.dsh.profile.bundles ??= [] for (const name of faultNames) { manifest.dependencies[name] = `file:${pluginRoots.get(name)}` if (!manifest.dsh.profile.bundles.includes(name)) manifest.dsh.profile.bundles.push(name) } await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) const brokenRestart = await request('POST', `${API}restart-dsh`) await waitTask('dshLifecycle', brokenRestart.taskId, ['failed']) await waitFor(async () => (await request('GET', `${API}status`)).recoveryMode ?? false, 'DSH recovery mode') let inventory = await request('GET', `${API}user-plugins`) for (const name of faultNames) { const plugin = inventory.plugins.find(value => value.name === name) assert.equal(plugin?.enabled, true) assert.equal(plugin?.damaged, false) } const firstRecovery = await request('POST', `${API}user-plugins/apply`, { profile: 'web', revision: inventory.revision, actions: [{ name: faultNames[0], action: 'disable' }], }) const firstOutcome = await waitTask('userPluginOperation', firstRecovery.taskId, ['success']) assert.equal(firstOutcome.strategy, 'next-start') assert.equal(firstOutcome.activationError, null) inventory = await request('GET', `${API}user-plugins`) assert.equal(inventory.plugins.find(value => value.name === faultNames[0])?.enabled, false) assert.equal(inventory.plugins.find(value => value.name === faultNames[1])?.enabled, true) const secondRecovery = await request('POST', `${API}user-plugins/apply`, { profile: 'web', revision: inventory.revision, actions: [{ name: faultNames[1], action: 'disable' }], }) const secondOutcome = await waitTask('userPluginOperation', secondRecovery.taskId, ['success']) assert.equal(secondOutcome.strategy, 'next-start') assert.equal(secondOutcome.activationError, null) const recoveredStart = await request('POST', `${API}start-dsh`) await waitTask('dshLifecycle', recoveredStart.taskId, ['running']) await waitFor(async () => { const status = await request('GET', `${API}status`) return status.recoveryMode === null ? status : false }, 'DSH recovery completion') inventory = await request('GET', `${API}user-plugins`) for (const name of faultNames) assert.equal(inventory.plugins.find(value => value.name === name)?.enabled, false) const uninstall = await request('POST', `${API}user-plugins/apply`, { profile: 'web', revision: inventory.revision, actions: [{ name: faultNames[0], action: 'uninstall' }], }) const uninstallOutcome = await waitTask('userPluginOperation', uninstall.taskId, ['success', 'failed']) if (uninstallOutcome.status !== 'success') { const diagnostics = await request('GET', `${API}logs?limit=50`) throw new Error(JSON.stringify({ error: uninstallOutcome.error, manifest: JSON.parse(await readFile(manifestPath, 'utf8')), logs: diagnostics.entries.slice(-10), })) } inventory = await request('GET', `${API}user-plugins`) assert.equal(inventory.plugins.some(value => value.name === faultNames[0]), false) const logEntries = (await request('GET', `${API}logs?limit=5000`)).entries for (const message of [ 'terminal.session.created', 'terminal.session.connected', 'terminal.session.disconnected', 'terminal.session.reconnected', 'terminal.session.closed', 'user-plugin.apply.started', 'user-plugin.apply.completed', ]) assert.ok(logEntries.some(entry => entry.message === message), `missing platform log ${message}`) assert.doesNotMatch(JSON.stringify(logEntries.filter(entry => entry.source === 'terminal')), /terminal-before-restart|terminal-after-restart/) process.stdout.write(`${JSON.stringify({ terminalSession: terminal.sessionId, faultNames })}\n`)