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,321 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { appendFile, chmod, mkdtemp, readFile, readdir, stat, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import test from 'node:test'
|
||||
import { errorDetails, JsonlLogManager } from '../../control-plane/modules/log-manager/index.mjs'
|
||||
|
||||
test('writes source-separated JSONL and queries bounded chronological entries', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-logs-'))
|
||||
const logs = new JsonlLogManager({ root })
|
||||
await logs.append('gateway', 'stdout', 'ready')
|
||||
await logs.append('updater', 'platform', 'checked', { taskId: 'one' })
|
||||
const entries = await logs.query({ sources: ['updater'], limit: 10 })
|
||||
assert.equal(entries.length, 1)
|
||||
assert.equal(entries[0].taskId, 'one')
|
||||
assert.equal(entries[0].level, 'info')
|
||||
assert.deepEqual((await readdir(root)).sort(), ['gateway.jsonl', 'updater.jsonl'])
|
||||
assert.equal((await logs.query({ limit: 5_000 })).length, 2)
|
||||
await assert.rejects(logs.query({ limit: 5_001 }), /log query limit is invalid/)
|
||||
})
|
||||
|
||||
test('filters transaction logs by task, operation, and phase', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-transaction-'))
|
||||
const logs = new JsonlLogManager({ root })
|
||||
await logs.append('updater', 'platform', 'download', { taskId: 'task-a', operation: 'update', phase: 'downloading' })
|
||||
await logs.append('updater', 'platform', 'health', { taskId: 'task-a', operation: 'update', phase: 'switching' })
|
||||
await logs.append('updater', 'platform', 'other', { taskId: 'task-b', operation: 'rollback', phase: 'switching' })
|
||||
const entries = await logs.query({ taskId: 'task-a', operation: 'update', phase: 'downloading' })
|
||||
assert.deepEqual(entries.map(entry => entry.message), ['download'])
|
||||
const received = []
|
||||
const follower = await logs.follow({ taskId: 'task-a', operation: 'update', phase: 'switching' }, entry => received.push(entry), { intervalMs: 50 })
|
||||
try {
|
||||
await logs.append('updater', 'platform', 'switching-now', { taskId: 'task-a', operation: 'update', phase: 'switching' })
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
assert.deepEqual(received.map(entry => entry.message), ['switching-now'])
|
||||
} finally {
|
||||
follower.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('follows entries appended by another process without replaying existing logs', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-follow-'))
|
||||
const logs = new JsonlLogManager({ root })
|
||||
await logs.append('gateway', 'stdout', 'existing')
|
||||
const received = []
|
||||
const follower = await logs.follow({}, entry => received.push(entry), { intervalMs: 50 })
|
||||
try {
|
||||
const external = {
|
||||
timestamp: '2026-08-21T00:00:00.000Z', source: 'gateway', stream: 'stdout', level: 'info', message: '外部日志',
|
||||
}
|
||||
const encoded = Buffer.from(`${JSON.stringify(external)}\n`)
|
||||
const split = encoded.indexOf(Buffer.from('外')) + 1
|
||||
await appendFile(logs.currentPath('gateway'), encoded.subarray(0, split))
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
await appendFile(logs.currentPath('gateway'), encoded.subarray(split))
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(poll)
|
||||
reject(new Error('timed out waiting for followed log'))
|
||||
}, 1_000)
|
||||
const poll = setInterval(() => {
|
||||
if (received.length === 0) return
|
||||
clearTimeout(timeout)
|
||||
clearInterval(poll)
|
||||
resolve()
|
||||
}, 10)
|
||||
})
|
||||
assert.deepEqual(received.map(entry => entry.message), ['外部日志'])
|
||||
} finally {
|
||||
follower.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('assigns default levels and accepts only explicit supported levels', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-levels-'))
|
||||
const logs = new JsonlLogManager({ root })
|
||||
await logs.append('component', 'stdout', 'ordinary')
|
||||
await logs.append('component', 'stderr', 'failed')
|
||||
await logs.append('component', 'platform', 'slow response', { level: 'warning' })
|
||||
await logs.diagnostic('component', 'completed', { error: null })
|
||||
assert.deepEqual((await logs.query()).map(entry => entry.level), ['info', 'error', 'warning', 'info'])
|
||||
await assert.rejects(logs.append('component', 'stdout', 'invalid', { level: 'fatal' }), /log level is invalid/)
|
||||
})
|
||||
|
||||
test('rate-limits repeated diagnostics and reports the suppressed count', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-suppression-'))
|
||||
let now = Date.parse('2026-08-19T00:00:00.000Z')
|
||||
const logs = new JsonlLogManager({ root, now: () => new Date(now) })
|
||||
await logs.diagnosticRateLimited('authentication.failed', 'access-manager', 'access.authentication.failed', {
|
||||
level: 'warning', kind: 'main',
|
||||
})
|
||||
now += 1_000
|
||||
await logs.diagnosticRateLimited('authentication.failed', 'access-manager', 'access.authentication.failed', {
|
||||
level: 'warning', kind: 'main',
|
||||
})
|
||||
now += 30_000
|
||||
await logs.diagnosticRateLimited('authentication.failed', 'access-manager', 'access.authentication.failed', {
|
||||
level: 'warning', kind: 'main',
|
||||
})
|
||||
|
||||
const entries = await logs.query({ sources: ['access-manager'] })
|
||||
assert.equal(entries.length, 2)
|
||||
assert.equal(entries[0].suppressedCount, undefined)
|
||||
assert.equal(entries[1].suppressedCount, 1)
|
||||
await assert.rejects(
|
||||
Promise.resolve().then(() => logs.diagnosticRateLimited('invalid key', 'gateway', 'failed')),
|
||||
/suppression key is invalid/,
|
||||
)
|
||||
})
|
||||
|
||||
test('applies an explicit shared-reader mode to newly created and rotated log files', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-mode-'))
|
||||
const logs = new JsonlLogManager({ root, fileMode: 0o640, rotateBytes: 1_024 })
|
||||
await logs.append('stage0', 'platform', 'first')
|
||||
assert.equal((await stat(join(root, 'stage0.jsonl'))).mode & 0o777, 0o640)
|
||||
await logs.append('stage0', 'platform', 'x'.repeat(1_000))
|
||||
await logs.append('stage0', 'platform', 'rotated')
|
||||
assert.equal((await stat(join(root, 'stage0.jsonl'))).mode & 0o777, 0o640)
|
||||
})
|
||||
|
||||
test('repairs existing log files for shared Control Plane writers', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-shared-writers-'))
|
||||
const path = join(root, 'audit.jsonl')
|
||||
await writeFile(path, '{}\n')
|
||||
await chmod(path, 0o640)
|
||||
const logs = new JsonlLogManager({ root, fileMode: 0o660 })
|
||||
await logs.prepare()
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o660)
|
||||
await logs.append('audit', 'audit', 'shared-writer.ready')
|
||||
assert.equal((await readFile(path, 'utf8')).includes('shared-writer.ready'), true)
|
||||
})
|
||||
|
||||
test('rotates files and enforces the aggregate byte budget', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-rotation-'))
|
||||
const logs = new JsonlLogManager({ root, maxBytes: 70_000, rotateBytes: 10_000 })
|
||||
for (let index = 0; index < 150; index += 1) await logs.append('gateway', 'stdout', 'x'.repeat(800))
|
||||
const files = await logs.files()
|
||||
assert.ok(files.length > 1)
|
||||
assert.ok(files.reduce((sum, file) => sum + file.details.size, 0) <= 70_000)
|
||||
})
|
||||
|
||||
test('prunes expired logs and captures complete child output lines', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-retention-'))
|
||||
const logs = new JsonlLogManager({ root, retentionDays: 1 })
|
||||
await logs.append('old', 'stdout', 'expired')
|
||||
const oldPath = join(root, 'old.jsonl')
|
||||
const old = new Date(Date.now() - 2 * 86_400_000)
|
||||
await utimes(oldPath, old, old)
|
||||
await logs.prune()
|
||||
await assert.rejects(stat(oldPath), { code: 'ENOENT' })
|
||||
|
||||
const child = new EventEmitter()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
logs.capture(child, 'component', { stdout: true, stderr: false })
|
||||
child.stdout.end('one\ntwo')
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await logs.queue
|
||||
assert.deepEqual((await logs.query({ sources: ['component'] })).map(entry => entry.message), ['one', 'two'])
|
||||
})
|
||||
|
||||
test('captures one multiline Node error as one log entry', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-multiline-error-'))
|
||||
const logs = new JsonlLogManager({ root })
|
||||
const child = new EventEmitter()
|
||||
child.stderr = new PassThrough()
|
||||
logs.capture(child, 'dsh-runtime', { stdout: false, stderr: true })
|
||||
|
||||
child.stderr.write('ordinary before\nfile:///app/index.js:2\n throw new Error("startup failed")\n')
|
||||
child.stderr.end(' ^\n\nError: startup failed\n at file:///app/index.js:2:9\n\nNode.js v24.20.0\nordinary after\n')
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await logs.queue
|
||||
|
||||
assert.deepEqual((await logs.query({ sources: ['dsh-runtime'] })).map(entry => ({
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
})), [
|
||||
{ level: 'info', message: 'ordinary before' },
|
||||
{
|
||||
level: 'error',
|
||||
message: 'file:///app/index.js:2\n throw new Error("startup failed")\n ^\n\nError: startup failed\n at file:///app/index.js:2:9\n\nNode.js v24.20.0',
|
||||
},
|
||||
{ level: 'info', message: 'ordinary after' },
|
||||
])
|
||||
})
|
||||
|
||||
test('does not group multiline stderr from other components', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-other-stderr-'))
|
||||
const logs = new JsonlLogManager({ root })
|
||||
const child = new EventEmitter()
|
||||
child.stderr = new PassThrough()
|
||||
logs.capture(child, 'gateway', { stdout: false, stderr: true })
|
||||
child.stderr.end('Error: first line\n at second line\nordinary line\n')
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await logs.queue
|
||||
assert.deepEqual((await logs.query({ sources: ['gateway'] })).map(entry => entry.message), [
|
||||
'Error: first line', ' at second line', 'ordinary line',
|
||||
])
|
||||
})
|
||||
|
||||
test('mirrors new entries to the matching container output without replaying history', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-output-'))
|
||||
const stdout = []
|
||||
const stderr = []
|
||||
const logs = new JsonlLogManager({
|
||||
root,
|
||||
now: () => new Date('2026-08-19T00:00:00.000Z'),
|
||||
output: {
|
||||
stdout: { write: value => stdout.push(value) },
|
||||
stderr: { write: value => stderr.push(value) },
|
||||
},
|
||||
})
|
||||
await logs.append('gateway', 'stdout', 'ready')
|
||||
await logs.append('dsh-runtime', 'stderr', 'failed')
|
||||
assert.deepEqual(stdout.map(line => JSON.parse(line)), [{
|
||||
timestamp: '2026-08-19T00:00:00.000Z', source: 'gateway', stream: 'stdout', message: 'ready',
|
||||
level: 'info', platformLog: 'dsh-platform-log-v1',
|
||||
}])
|
||||
assert.deepEqual(stderr.map(line => JSON.parse(line)), [{
|
||||
timestamp: '2026-08-19T00:00:00.000Z', source: 'dsh-runtime', stream: 'stderr', message: 'failed',
|
||||
level: 'error', platformLog: 'dsh-platform-log-v1',
|
||||
}])
|
||||
|
||||
const secondOutput = []
|
||||
const restarted = new JsonlLogManager({
|
||||
root,
|
||||
output: { stdout: { write: value => secondOutput.push(value) }, stderr: { write: value => secondOutput.push(value) } },
|
||||
})
|
||||
assert.equal((await restarted.query()).length, 2)
|
||||
assert.deepEqual(secondOutput, [])
|
||||
})
|
||||
|
||||
test('passes a platform-management envelope through without persisting it twice', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-forwarded-'))
|
||||
const stdout = []
|
||||
const stderr = []
|
||||
const logs = new JsonlLogManager({
|
||||
root,
|
||||
output: {
|
||||
stdout: { write: value => stdout.push(value) },
|
||||
stderr: { write: value => stderr.push(value) },
|
||||
},
|
||||
})
|
||||
const child = new EventEmitter()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
logs.capture(child, 'platform-management', { stdout: true, stderr: true }, { acceptForwarded: true })
|
||||
const audit = JSON.stringify({
|
||||
timestamp: '2026-08-19T00:00:00.000Z', source: 'audit', stream: 'audit', message: 'update.started',
|
||||
platformLog: 'dsh-platform-log-v1', taskId: 'task-one',
|
||||
})
|
||||
child.stdout.end(`${audit}\nplain output\n`)
|
||||
child.stderr.end('[net-proxy] 已启用代理 http://172.17.0.1:7890\nwarning: slow proxy\nplain error\n')
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await logs.queue
|
||||
|
||||
assert.equal(stdout[0], `${audit}\n`)
|
||||
assert.equal(JSON.parse(stdout[1]).source, 'platform-management')
|
||||
assert.equal(JSON.parse(stderr[0]).stream, 'stderr')
|
||||
assert.deepEqual(
|
||||
(await logs.query({ sources: ['platform-management'] })).map(entry => [entry.stream, entry.level, entry.message]),
|
||||
[
|
||||
['stdout', 'info', 'plain output'],
|
||||
['stderr', 'info', '[net-proxy] 已启用代理 http://172.17.0.1:7890'],
|
||||
['stderr', 'warning', 'warning: slow proxy'],
|
||||
['stderr', 'error', 'plain error'],
|
||||
],
|
||||
)
|
||||
assert.deepEqual(await logs.query({ sources: ['audit'] }), [])
|
||||
const bootstrap = await readFile(new URL('../bootstrap/index.mjs', import.meta.url), 'utf8')
|
||||
assert.match(bootstrap, /acceptForwarded: \['gateway', 'platform-management'\]\.includes\(source\)/)
|
||||
})
|
||||
|
||||
test('records bounded structured error diagnostics in JSONL and container output', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-log-diagnostic-'))
|
||||
const stdout = []
|
||||
const logs = new JsonlLogManager({
|
||||
root,
|
||||
now: () => new Date('2026-08-20T00:00:00.000Z'),
|
||||
output: { stdout: { write: value => stdout.push(value) } },
|
||||
})
|
||||
const cause = Object.assign(new Error('socket unavailable'), { code: 'ECONNREFUSED' })
|
||||
const failure = new AggregateError([cause, 'secondary failure'], 'startup failed', { cause })
|
||||
await logs.diagnostic('stage0', 'bootstrap.launch.failed', { error: failure, recordId: 'bootstrap-one' })
|
||||
|
||||
const [entry] = await logs.query({ sources: ['stage0'] })
|
||||
assert.equal(entry.message, 'bootstrap.launch.failed')
|
||||
assert.equal(entry.recordId, 'bootstrap-one')
|
||||
assert.equal(entry.error, 'startup failed')
|
||||
assert.equal(entry.errorCause.errorCode, 'ECONNREFUSED')
|
||||
assert.equal(entry.errors[1].error, 'secondary failure')
|
||||
assert.match(entry.errorStack, /AggregateError: startup failed/)
|
||||
assert.equal(JSON.parse(stdout[0]).platformLog, 'dsh-platform-log-v1')
|
||||
})
|
||||
|
||||
test('falls back to container stderr when a diagnostic cannot be persisted', async () => {
|
||||
const stderr = []
|
||||
const logs = new JsonlLogManager({
|
||||
root: '/dev/null/not-a-directory',
|
||||
now: () => new Date('2026-08-20T00:00:00.000Z'),
|
||||
output: { stderr: { write: value => stderr.push(value) } },
|
||||
})
|
||||
await logs.diagnostic('updater', 'update.failed', { error: new Error('network failed'), taskId: 'one' })
|
||||
const fallback = JSON.parse(stderr[0])
|
||||
assert.equal(fallback.message, 'diagnostic.write.failed')
|
||||
assert.equal(fallback.diagnostic.message, 'update.failed')
|
||||
assert.equal(fallback.diagnostic.error, 'network failed')
|
||||
assert.equal(fallback.loggingError.errorCode, 'ENOTDIR')
|
||||
})
|
||||
|
||||
test('bounds recursive and oversized error details', () => {
|
||||
let failure = new Error('root')
|
||||
for (let index = 0; index < 6; index += 1) failure = new Error(`level-${String(index)}`, { cause: failure })
|
||||
const details = errorDetails(new AggregateError(Array.from({ length: 10 }, (_, index) => new Error(String(index))), 'x'.repeat(20_000), { cause: failure }))
|
||||
assert.equal(details.error.endsWith('...[truncated]'), true)
|
||||
assert.equal(details.errors.length, 8)
|
||||
assert.equal(details.errorsTruncated, 2)
|
||||
assert.equal(details.errorCause.errorCause.errorCause.errorCause, undefined)
|
||||
})
|
||||
Reference in new issue
Block a user