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,248 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { request as httpRequest } from 'node:http'
|
||||
import { mkdir, mkdtemp } from 'node:fs/promises'
|
||||
import { tmpdir, userInfo } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import WebSocket from '../../control-plane/services/management/node_modules/ws/wrapper.mjs'
|
||||
import { createManagementServer } from '../../control-plane/services/management/server.mjs'
|
||||
import { TerminalSessionManager, terminalSessionInternals } from '../../control-plane/services/management/terminal/sessions.mjs'
|
||||
|
||||
const API = '/_dsh_platform/api/v1/'
|
||||
|
||||
function request(port, 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, method, path,
|
||||
headers: encoded === undefined ? 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')
|
||||
resolve({ status: response.statusCode, body: text === '' ? undefined : JSON.parse(text) })
|
||||
})
|
||||
})
|
||||
outgoing.once('error', reject)
|
||||
outgoing.end(encoded)
|
||||
})
|
||||
}
|
||||
|
||||
function collector(url) {
|
||||
const socket = new WebSocket(url)
|
||||
let output = ''
|
||||
let exit
|
||||
const waiters = new Set()
|
||||
socket.on('message', bytes => {
|
||||
const value = JSON.parse(bytes.toString())
|
||||
if (value.type === 'output') output += value.data
|
||||
if (value.type === 'exit') exit = value
|
||||
for (const waiter of waiters) waiter()
|
||||
})
|
||||
const waitFor = async predicate => {
|
||||
for (let attempt = 0; attempt < 400; attempt += 1) {
|
||||
if (predicate({ output, exit })) return { output, exit }
|
||||
await new Promise(resolve => {
|
||||
const timer = setTimeout(() => { waiters.delete(done); resolve() }, 25)
|
||||
const done = () => { clearTimeout(timer); waiters.delete(done); resolve() }
|
||||
waiters.add(done)
|
||||
})
|
||||
}
|
||||
throw new Error(`terminal output did not reach expected state: ${output}`)
|
||||
}
|
||||
return { socket, waitFor, output: () => output }
|
||||
}
|
||||
|
||||
async function fixture({ reconnectMs = 30_000 } = {}) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-terminal-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh')
|
||||
await mkdir(workspace)
|
||||
await mkdir(dshHome)
|
||||
const reports = []
|
||||
const terminalSessions = new TerminalSessionManager({
|
||||
cwd: workspace,
|
||||
dshHome,
|
||||
reconnectMs,
|
||||
report: async (message, fields) => { reports.push({ message, fields }) },
|
||||
})
|
||||
const logs = {
|
||||
diagnostic: async () => {}, query: async () => [], on: () => {}, off: () => {},
|
||||
}
|
||||
const state = { read: async () => ({}), on: () => {}, off: () => {} }
|
||||
const coordinator = { publicStatus: async () => ({}), hasActiveTask: () => false, state, on: () => {}, off: () => {} }
|
||||
const server = createManagementServer({ coordinator, logs, terminalSessions })
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const port = server.address().port
|
||||
return {
|
||||
root, workspace, dshHome, reports, terminalSessions, server, port,
|
||||
close: async () => {
|
||||
await terminalSessions.shutdown()
|
||||
await new Promise(resolve => server.close(resolve))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('terminal HTTP and WebSocket lifecycle provides a real bounded PTY', async () => {
|
||||
const value = await fixture()
|
||||
try {
|
||||
const created = await request(value.port, 'POST', `${API}terminal/sessions`, { cols: 92, rows: 31 })
|
||||
assert.equal(created.status, 201)
|
||||
assert.match(created.body.sessionId, terminalSessionInternals.SESSION_PATTERN)
|
||||
const path = `${API}terminal/sessions/${created.body.sessionId}`
|
||||
assert.equal((await request(value.port, 'GET', path)).body.status, 'running')
|
||||
|
||||
const first = collector(`ws://127.0.0.1:${value.port}${path}/stream`)
|
||||
await new Promise((resolve, reject) => {
|
||||
first.socket.once('open', resolve)
|
||||
first.socket.once('error', reject)
|
||||
})
|
||||
first.socket.send(JSON.stringify({ type: 'resize', cols: 101, rows: 37 }))
|
||||
first.socket.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: "printf '\\033[31mred\\033[0m\\n'; printf '中文✓\\n'; printf 'cwd=%s\\n' \"$PWD\"; printf 'home=%s\\n' \"$HOME\"; printf 'dsh=%s\\n' \"$DSH_HOME\"; printf 'uid=%s\\n' \"$(id -u)\"; printf 'gids=%s\\n' \"$(id -G)\"; printf 'path=%s\\n' \"$PATH\"; printf 'umask=%s\\n' \"$(umask)\"; stty size; printf 'command-complete\\n'\n",
|
||||
}))
|
||||
let initial
|
||||
try {
|
||||
initial = await first.waitFor(({ output }) => output.includes('\x1b[31mred\x1b[0m') && output.match(/command-complete/g)?.length >= 3)
|
||||
} catch (error) {
|
||||
error.message += `; status=${JSON.stringify((await request(value.port, 'GET', path)).body)} reports=${JSON.stringify(value.reports)}`
|
||||
throw error
|
||||
}
|
||||
assert.match(initial.output, /\x1b\[31mred\x1b\[0m/)
|
||||
assert.match(initial.output, /中文✓/)
|
||||
assert.match(initial.output, new RegExp(`cwd=${value.workspace.replaceAll('/', '\\/')}`))
|
||||
assert.match(initial.output, new RegExp(`home=${userInfo().homedir.replaceAll('/', '\\/')}`))
|
||||
assert.match(initial.output, new RegExp(`dsh=${value.dshHome.replaceAll('/', '\\/')}`))
|
||||
assert.match(initial.output, new RegExp(`uid=${String(process.getuid())}`))
|
||||
for (const group of process.getgroups()) assert.match(initial.output, new RegExp(`gids=[^\\r\\n]*\\b${String(group)}\\b`))
|
||||
assert.match(initial.output, /path=\S+/)
|
||||
assert.match(initial.output, /umask=0002/)
|
||||
assert.match(initial.output, /37 101/)
|
||||
|
||||
first.socket.send(JSON.stringify({ type: 'input', data: "read -p 'answer: ' value; printf 'interactive=%s\\n' \"$value\"\n" }))
|
||||
await first.waitFor(({ output }) => output.includes('answer:'))
|
||||
first.socket.send(JSON.stringify({ type: 'input', data: 'works\n' }))
|
||||
await first.waitFor(({ output }) => output.includes('interactive=works'))
|
||||
first.socket.send(JSON.stringify({ type: 'input', data: "printf 'long-start\\n'; head -c 300000 /dev/zero | tr '\\0' x; printf '\\nlong-tail\\n'\n" }))
|
||||
await first.waitFor(({ output }) => output.length > 300_000 && output.match(/long-tail/g)?.length >= 2)
|
||||
first.socket.send(JSON.stringify({ type: 'input', data: "printf 'cached-marker\\n'\n" }))
|
||||
await first.waitFor(({ output }) => output.includes('cached-marker'))
|
||||
first.socket.close()
|
||||
await new Promise(resolve => first.socket.once('close', resolve))
|
||||
|
||||
const second = collector(`ws://127.0.0.1:${value.port}${path}/stream`)
|
||||
await new Promise((resolve, reject) => {
|
||||
second.socket.once('open', resolve)
|
||||
second.socket.once('error', reject)
|
||||
})
|
||||
const replay = (await second.waitFor(({ output }) => output.includes('cached-marker'))).output
|
||||
assert.match(replay, /long-tail/)
|
||||
assert.match(replay, /cached-marker/)
|
||||
assert.doesNotMatch(replay, /long-start/)
|
||||
assert.ok(Buffer.byteLength(replay) <= 256 * 1024 + 1024)
|
||||
second.socket.send(JSON.stringify({ type: 'input', data: 'exit 7\n' }))
|
||||
const ended = await second.waitFor(result => result.exit !== undefined)
|
||||
assert.deepEqual({ code: ended.exit.code, signal: ended.exit.signal }, { code: 7, signal: null })
|
||||
assert.equal((await request(value.port, 'GET', path)).body.status, 'exited')
|
||||
assert.equal((await request(value.port, 'DELETE', path)).body.closed, true)
|
||||
second.socket.close()
|
||||
|
||||
const messages = value.reports.map(entry => entry.message)
|
||||
for (const event of [
|
||||
'terminal.session.created', 'terminal.session.connected', 'terminal.session.disconnected',
|
||||
'terminal.session.reconnected', 'terminal.session.exited', 'terminal.session.closed',
|
||||
]) assert.ok(messages.includes(event), `${event} was not reported`)
|
||||
assert.doesNotMatch(JSON.stringify(value.reports), /cached-marker|interactive=works|command-complete/)
|
||||
} finally {
|
||||
await value.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('terminal sessions accept a trusted per-session network environment', async () => {
|
||||
const value = await fixture()
|
||||
try {
|
||||
const created = value.terminalSessions.create({}, {
|
||||
HTTP_PROXY: 'http://127.0.0.1:17896',
|
||||
NO_PROXY: 'localhost,.example.com',
|
||||
})
|
||||
const path = `${API}terminal/sessions/${created.sessionId}`
|
||||
const connection = collector(`ws://127.0.0.1:${value.port}${path}/stream`)
|
||||
await new Promise((resolve, reject) => {
|
||||
connection.socket.once('open', resolve)
|
||||
connection.socket.once('error', reject)
|
||||
})
|
||||
connection.socket.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: "printf 'proxy=%s\\nno_proxy=%s\\n' \"$HTTP_PROXY\" \"$NO_PROXY\"\n",
|
||||
}))
|
||||
const result = await connection.waitFor(({ output }) => output.includes('no_proxy=localhost,.example.com'))
|
||||
assert.match(result.output, /proxy=http:\/\/127\.0\.0\.1:17896/)
|
||||
connection.socket.close()
|
||||
} finally {
|
||||
await value.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('terminal sessions enforce dimensions, message size, expiry, and exact IDs', async () => {
|
||||
const value = await fixture({ reconnectMs: 80 })
|
||||
try {
|
||||
for (const body of [{ cols: 1, rows: 24 }, { cols: 501, rows: 24 }, { cols: 80, rows: 0 }, { cols: 80, rows: 201 }]) {
|
||||
assert.equal((await request(value.port, 'POST', `${API}terminal/sessions`, body)).status, 400)
|
||||
}
|
||||
const created = (await request(value.port, 'POST', `${API}terminal/sessions`, {})).body
|
||||
const path = `${API}terminal/sessions/${created.sessionId}`
|
||||
const connection = collector(`ws://127.0.0.1:${value.port}${path}/stream`)
|
||||
await new Promise((resolve, reject) => {
|
||||
connection.socket.once('open', resolve)
|
||||
connection.socket.once('error', reject)
|
||||
})
|
||||
connection.socket.send(JSON.stringify({ type: 'resize', cols: 1, rows: 20 }))
|
||||
const closeCode = await new Promise(resolve => connection.socket.once('close', resolve))
|
||||
assert.equal(closeCode, 1008)
|
||||
let expiredStatus
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
expiredStatus = (await request(value.port, 'GET', path)).status
|
||||
if (expiredStatus === 404) break
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
assert.equal(expiredStatus, 404)
|
||||
|
||||
const oversized = (await request(value.port, 'POST', `${API}terminal/sessions`, {})).body
|
||||
const oversizedConnection = collector(`ws://127.0.0.1:${value.port}${API}terminal/sessions/${oversized.sessionId}/stream`)
|
||||
await new Promise((resolve, reject) => {
|
||||
oversizedConnection.socket.once('open', resolve)
|
||||
oversizedConnection.socket.once('error', reject)
|
||||
})
|
||||
oversizedConnection.socket.send(JSON.stringify({ type: 'input', data: 'x'.repeat(64 * 1024 + 1) }))
|
||||
const oversizedClose = await new Promise(resolve => oversizedConnection.socket.once('close', resolve))
|
||||
assert.ok([1008, 1009].includes(oversizedClose))
|
||||
assert.equal((await request(value.port, 'GET', `${API}terminal/sessions/not-a-uuid`)).status, 404)
|
||||
} finally {
|
||||
await value.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('terminal sessions default to the Root maintenance home', () => {
|
||||
const terminalSessions = new TerminalSessionManager()
|
||||
assert.equal(terminalSessions.cwd, '/root')
|
||||
})
|
||||
|
||||
test('terminal shutdown waits for every helper process to close', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-terminal-shutdown-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'dsh')
|
||||
await mkdir(workspace)
|
||||
await mkdir(dshHome)
|
||||
const terminalSessions = new TerminalSessionManager({ cwd: workspace, dshHome })
|
||||
const created = terminalSessions.create()
|
||||
const session = terminalSessions.sessions.get(created.sessionId)
|
||||
assert.equal(session.child.exitCode, null)
|
||||
await terminalSessions.shutdown()
|
||||
assert.equal(terminalSessions.sessions.size, 0)
|
||||
assert.equal(session.child.exitCode !== null || session.child.signalCode !== null, true)
|
||||
})
|
||||
Reference in new issue
Block a user