feat(dev-infra): make gate plans inspectable and replayable
This commit is contained in:
220
scripts/gate-log-helper.mjs
Normal file
220
scripts/gate-log-helper.mjs
Normal file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
/** Pin the repository and each log-path component before creating or operating on private logs. */
|
||||
|
||||
import { constants } from 'node:fs'
|
||||
import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises'
|
||||
import { isAbsolute, sep } from 'node:path'
|
||||
|
||||
const MAX_REQUEST_BYTES = 8 * 1024 * 1024
|
||||
const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/
|
||||
|
||||
function errorCode(error) {
|
||||
return typeof error === 'object' && error !== null && 'code' in error
|
||||
? error.code
|
||||
: undefined
|
||||
}
|
||||
|
||||
async function readRequest() {
|
||||
const chunks = []
|
||||
let bytes = 0
|
||||
for await (const chunk of process.stdin) {
|
||||
bytes += chunk.length
|
||||
if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
}
|
||||
|
||||
function assertInteger(value, label, minimum) {
|
||||
if (!Number.isSafeInteger(value) || value < minimum) {
|
||||
throw new Error(`${label} must be an integer of at least ${minimum}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertLogName(name) {
|
||||
if (typeof name !== 'string' || !LOG_NAME.test(name)) {
|
||||
throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRequest(request) {
|
||||
if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object')
|
||||
switch (request.operation) {
|
||||
case 'write':
|
||||
assertLogName(request.filename)
|
||||
assertInteger(request.retention, 'retention', 1)
|
||||
if (typeof request.content !== 'string') throw new Error('gate-log content must be a string')
|
||||
return
|
||||
case 'prune':
|
||||
assertInteger(request.retain, 'retain', 0)
|
||||
return
|
||||
case 'clean':
|
||||
return
|
||||
default:
|
||||
throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertIdentity(value, label) {
|
||||
if (
|
||||
typeof value !== 'object'
|
||||
|| value === null
|
||||
|| typeof value.dev !== 'string'
|
||||
|| typeof value.ino !== 'string'
|
||||
) {
|
||||
throw new Error(`missing expected ${label} identity`)
|
||||
}
|
||||
}
|
||||
|
||||
function identityOf(metadata) {
|
||||
return { dev: String(metadata.dev), ino: String(metadata.ino) }
|
||||
}
|
||||
|
||||
function sameIdentity(metadata, expected) {
|
||||
return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino
|
||||
}
|
||||
|
||||
async function assertPinnedRepository(repository) {
|
||||
if (
|
||||
typeof repository !== 'object'
|
||||
|| repository === null
|
||||
|| typeof repository.root !== 'string'
|
||||
|| !isAbsolute(repository.root)
|
||||
|| typeof repository.relative !== 'string'
|
||||
|| repository.relative === ''
|
||||
|| repository.relative === '..'
|
||||
|| repository.relative.startsWith(`..${sep}`)
|
||||
|| isAbsolute(repository.relative)
|
||||
) {
|
||||
throw new Error('invalid repository-relative gate-log path')
|
||||
}
|
||||
assertIdentity(repository.identity, 'repository')
|
||||
const pinnedMetadata = await stat('.', { bigint: true })
|
||||
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) {
|
||||
throw new Error('gate-log repository identity changed before the helper started')
|
||||
}
|
||||
const rootMetadata = await lstat(repository.root, { bigint: true })
|
||||
if (
|
||||
!rootMetadata.isDirectory()
|
||||
|| rootMetadata.isSymbolicLink()
|
||||
|| !sameIdentity(rootMetadata, repository.identity)
|
||||
) {
|
||||
throw new Error('gate-log repository root is not a real directory')
|
||||
}
|
||||
}
|
||||
|
||||
async function enterLogDirectory(relativePath, create) {
|
||||
const traversed = []
|
||||
for (const component of relativePath.split(sep)) {
|
||||
if (component === '' || component === '.' || component === '..') {
|
||||
throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`)
|
||||
}
|
||||
traversed.push(component)
|
||||
let componentMetadata
|
||||
try {
|
||||
componentMetadata = await lstat(component, { bigint: true })
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'ENOENT') throw error
|
||||
if (!create) return undefined
|
||||
try {
|
||||
await mkdir(component, { mode: 0o700 })
|
||||
} catch (mkdirError) {
|
||||
if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError
|
||||
}
|
||||
componentMetadata = await lstat(component, { bigint: true })
|
||||
}
|
||||
const shown = traversed.join('/')
|
||||
if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) {
|
||||
throw new Error(`gate-log path component is not a real directory: ${shown}`)
|
||||
}
|
||||
const expected = identityOf(componentMetadata)
|
||||
process.chdir(component)
|
||||
const pinnedMetadata = await stat('.', { bigint: true })
|
||||
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) {
|
||||
throw new Error(`gate-log path component identity changed before pinning: ${shown}`)
|
||||
}
|
||||
}
|
||||
await chmod('.', 0o700)
|
||||
return identityOf(await stat('.', { bigint: true }))
|
||||
}
|
||||
|
||||
async function removeOldLogs(retain, newest) {
|
||||
assertInteger(retain, 'retain', 0)
|
||||
const entries = await readdir('.', { withFileTypes: true })
|
||||
const logs = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue
|
||||
let metadata
|
||||
try {
|
||||
metadata = await lstat(entry.name, { bigint: true })
|
||||
} catch (error) {
|
||||
if (errorCode(error) === 'ENOENT') continue
|
||||
throw error
|
||||
}
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink()) continue
|
||||
logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs })
|
||||
}
|
||||
logs.sort((left, right) => {
|
||||
if (left.name === newest) return 1
|
||||
if (right.name === newest) return -1
|
||||
if (left.mtimeNs < right.mtimeNs) return -1
|
||||
if (left.mtimeNs > right.mtimeNs) return 1
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
const removed = []
|
||||
for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) {
|
||||
try {
|
||||
await unlink(entry.name)
|
||||
removed.push(entry.name)
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async function writeLog(request) {
|
||||
assertLogName(request.filename)
|
||||
assertInteger(request.retention, 'retention', 1)
|
||||
if (typeof request.content !== 'string') throw new Error('gate-log content must be a string')
|
||||
const handle = await open(
|
||||
request.filename,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
)
|
||||
try {
|
||||
await handle.writeFile(request.content, 'utf8')
|
||||
await handle.chmod(0o600)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
const removed = await removeOldLogs(request.retention, request.filename)
|
||||
return { filename: request.filename, removed }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const request = await readRequest()
|
||||
assertRequest(request)
|
||||
await assertPinnedRepository(request.repository)
|
||||
const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write')
|
||||
if (directory === undefined) return { removed: [] }
|
||||
switch (request.operation) {
|
||||
case 'write': {
|
||||
const result = await writeLog(request)
|
||||
return { ...result, directory }
|
||||
}
|
||||
case 'prune':
|
||||
return { directory, removed: await removeOldLogs(request.retain) }
|
||||
case 'clean':
|
||||
return { directory, removed: await removeOldLogs(0) }
|
||||
default:
|
||||
throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.stdout.write(`${JSON.stringify(await main())}\n`)
|
||||
} catch (error) {
|
||||
process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
Reference in New Issue
Block a user