fix(dev-infra): pin replay log path identities
This commit is contained in:
@@ -89,6 +89,22 @@ async function assertPinnedRepository(repository) {
|
||||
throw new Error('invalid repository-relative gate-log path')
|
||||
}
|
||||
assertIdentity(repository.identity, 'repository')
|
||||
const names = repository.relative.split(sep)
|
||||
if (!Array.isArray(repository.components) || repository.components.length !== names.length) {
|
||||
throw new Error('invalid gate-log path-component plan')
|
||||
}
|
||||
for (let index = 0; index < names.length; index += 1) {
|
||||
const component = repository.components[index]
|
||||
if (
|
||||
typeof component !== 'object'
|
||||
|| component === null
|
||||
|| component.name !== names[index]
|
||||
|| !('identity' in component)
|
||||
) {
|
||||
throw new Error('invalid gate-log path-component plan')
|
||||
}
|
||||
if (component.identity !== null) assertIdentity(component.identity, `path component ${component.name}`)
|
||||
}
|
||||
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')
|
||||
@@ -101,34 +117,49 @@ async function assertPinnedRepository(repository) {
|
||||
) {
|
||||
throw new Error('gate-log repository root is not a real directory')
|
||||
}
|
||||
return repository.components
|
||||
}
|
||||
|
||||
async function enterLogDirectory(relativePath, create) {
|
||||
async function enterLogDirectory(components, 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)}`)
|
||||
for (const component of components) {
|
||||
if (component.name === '' || component.name === '.' || component.name === '..') {
|
||||
throw new Error(`invalid gate-log path component ${JSON.stringify(component.name)}`)
|
||||
}
|
||||
traversed.push(component)
|
||||
traversed.push(component.name)
|
||||
let componentMetadata
|
||||
let created = false
|
||||
try {
|
||||
componentMetadata = await lstat(component, { bigint: true })
|
||||
componentMetadata = await lstat(component.name, { bigint: true })
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'ENOENT') throw error
|
||||
if (component.identity !== null) {
|
||||
throw new Error(`gate-log path component disappeared after validation: ${traversed.join('/')}`)
|
||||
}
|
||||
if (!create) return undefined
|
||||
try {
|
||||
await mkdir(component, { mode: 0o700 })
|
||||
await mkdir(component.name, { mode: 0o700 })
|
||||
} catch (mkdirError) {
|
||||
if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError
|
||||
if (errorCode(mkdirError) === 'EEXIST') {
|
||||
throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`)
|
||||
}
|
||||
throw mkdirError
|
||||
}
|
||||
componentMetadata = await lstat(component, { bigint: true })
|
||||
componentMetadata = await lstat(component.name, { bigint: true })
|
||||
created = true
|
||||
}
|
||||
if (component.identity === null && !created) {
|
||||
throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`)
|
||||
}
|
||||
if (component.identity !== null && !sameIdentity(componentMetadata, component.identity)) {
|
||||
throw new Error(`gate-log path component identity changed after validation: ${traversed.join('/')}`)
|
||||
}
|
||||
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 expected = component.identity ?? identityOf(componentMetadata)
|
||||
process.chdir(component.name)
|
||||
const pinnedMetadata = await stat('.', { bigint: true })
|
||||
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) {
|
||||
throw new Error(`gate-log path component identity changed before pinning: ${shown}`)
|
||||
@@ -195,8 +226,8 @@ async function writeLog(request) {
|
||||
async function main() {
|
||||
const request = await readRequest()
|
||||
assertRequest(request)
|
||||
await assertPinnedRepository(request.repository)
|
||||
const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write')
|
||||
const components = await assertPinnedRepository(request.repository)
|
||||
const directory = await enterLogDirectory(components, request.operation === 'write')
|
||||
if (directory === undefined) return { removed: [] }
|
||||
switch (request.operation) {
|
||||
case 'write': {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
formatOnlyNotice,
|
||||
gateDependencyClosure,
|
||||
gatePlanForMode,
|
||||
isMainModule,
|
||||
listedGatePlan,
|
||||
limitGateFailureLog,
|
||||
parseCliRequest,
|
||||
@@ -240,6 +241,15 @@ describe('gate plan inspection and replay', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('recognizes a symlinked script entry path', () => {
|
||||
const temporary = temporaryRoot('dsh-run-gates-entry-')
|
||||
const entry = join(temporary, 'run-gates.ts')
|
||||
symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry)
|
||||
|
||||
expect(isMainModule(entry)).toBe(true)
|
||||
expect(isMainModule(join(temporary, 'missing.ts'))).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a cross-platform scheduler replay and labels focused evidence', () => {
|
||||
const subject = plan([gate('snapshot')])
|
||||
expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot')
|
||||
@@ -465,6 +475,103 @@ describe('gate failure logs', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => {
|
||||
const subjectGate = gate('subject')
|
||||
const subject = plan([subjectGate])
|
||||
|
||||
for (const operation of ['write', 'prune', 'clean'] as const) {
|
||||
const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`)
|
||||
const repositoryRoot = join(auditRoot, 'repository')
|
||||
const external = join(auditRoot, 'external')
|
||||
const cache = join(repositoryRoot, '.cache')
|
||||
const directory = join(cache, 'gates')
|
||||
const displacedCache = join(repositoryRoot, '.cache-pinned')
|
||||
const externalCache = join(external, 'cache')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
mkdirSync(join(externalCache, 'gates'), { recursive: true })
|
||||
writeFileSync(join(directory, 'old.log'), 'old private log\n')
|
||||
const victim = operation === 'write' ? undefined : join(externalCache, 'gates/victim.log')
|
||||
if (victim !== undefined) writeFileSync(victim, 'keep\n')
|
||||
const swapAncestor = (): void => {
|
||||
renameSync(cache, displacedCache)
|
||||
renameSync(externalCache, cache)
|
||||
}
|
||||
|
||||
let invocation: Promise<unknown>
|
||||
if (operation === 'write') {
|
||||
invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), {
|
||||
directory,
|
||||
repositoryRoot,
|
||||
retention: 1,
|
||||
unique: operation,
|
||||
platform: 'linux',
|
||||
beforeHelper: swapAncestor,
|
||||
})
|
||||
} else if (operation === 'prune') {
|
||||
invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor)
|
||||
} else {
|
||||
invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor)
|
||||
}
|
||||
|
||||
await expect(invocation).rejects.toThrow('gate-log helper')
|
||||
if (victim === undefined) {
|
||||
expect(readdirSync(join(cache, 'gates'))).toEqual([])
|
||||
} else {
|
||||
expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n')
|
||||
}
|
||||
expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n')
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects a real directory introduced at a previously missing component', async () => {
|
||||
const auditRoot = temporaryRoot('dsh-gate-missing-real-swap-')
|
||||
const repositoryRoot = join(auditRoot, 'repository')
|
||||
const externalCache = join(auditRoot, 'external-cache')
|
||||
const cache = join(repositoryRoot, '.cache')
|
||||
const directory = join(cache, 'gates')
|
||||
mkdirSync(repositoryRoot)
|
||||
mkdirSync(join(externalCache, 'gates'), { recursive: true })
|
||||
const victim = join(externalCache, 'gates/victim.log')
|
||||
writeFileSync(victim, 'keep\n')
|
||||
const subjectGate = gate('subject')
|
||||
|
||||
const invocation = writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), {
|
||||
directory,
|
||||
repositoryRoot,
|
||||
retention: 1,
|
||||
unique: 'missing-swap',
|
||||
platform: 'linux',
|
||||
beforeHelper: () => {
|
||||
renameSync(externalCache, cache)
|
||||
},
|
||||
})
|
||||
|
||||
await expect(invocation).rejects.toThrow('gate-log helper')
|
||||
expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n')
|
||||
expect(readdirSync(join(cache, 'gates'))).toEqual(['victim.log'])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects a repository root replaced after validation', async () => {
|
||||
const auditRoot = temporaryRoot('dsh-gate-root-swap-')
|
||||
const repositoryRoot = join(auditRoot, 'repository')
|
||||
const externalRoot = join(auditRoot, 'external-repository')
|
||||
const displacedRoot = join(auditRoot, 'repository-pinned')
|
||||
const directory = join(repositoryRoot, '.cache/gates')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
mkdirSync(join(externalRoot, '.cache/gates'), { recursive: true })
|
||||
writeFileSync(join(directory, 'old.log'), 'old private log\n')
|
||||
writeFileSync(join(externalRoot, '.cache/gates/victim.log'), 'keep\n')
|
||||
|
||||
const invocation = cleanGateFailureLogs(directory, repositoryRoot, () => {
|
||||
renameSync(repositoryRoot, displacedRoot)
|
||||
renameSync(externalRoot, repositoryRoot)
|
||||
})
|
||||
|
||||
await expect(invocation).rejects.toThrow('gate-log helper')
|
||||
expect(readFileSync(join(repositoryRoot, '.cache/gates/victim.log'), 'utf8')).toBe('keep\n')
|
||||
expect(readFileSync(join(displacedRoot, '.cache/gates/old.log'), 'utf8')).toBe('old private log\n')
|
||||
})
|
||||
|
||||
it('uses a console-only fallback on Windows before creating a retention directory', async () => {
|
||||
const repositoryRoot = temporaryRoot()
|
||||
const directory = join(repositoryRoot, '.cache/gates')
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
@@ -136,6 +137,16 @@ interface GateLogDirectoryIdentity {
|
||||
ino: string
|
||||
}
|
||||
|
||||
interface GateLogPathComponent {
|
||||
name: string
|
||||
identity: GateLogDirectoryIdentity | null
|
||||
}
|
||||
|
||||
interface GateLogPathPlan {
|
||||
repositoryIdentity: GateLogDirectoryIdentity
|
||||
pathComponents: GateLogPathComponent[]
|
||||
}
|
||||
|
||||
type GateLogHelperRequest =
|
||||
| { operation: 'write'; filename: string; content: string; retention: number }
|
||||
| { operation: 'prune'; retain: number }
|
||||
@@ -211,9 +222,19 @@ async function main(args: string[]): Promise<number> {
|
||||
: 0
|
||||
}
|
||||
|
||||
function isMainModule(): boolean {
|
||||
const entry = process.argv[1]
|
||||
return entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href
|
||||
/**
|
||||
* Decide whether this module is the process entry, including through a symlinked path.
|
||||
* @param entry - process entry path to compare with this module.
|
||||
* @returns Whether the entry resolves to this module.
|
||||
*/
|
||||
export function isMainModule(entry: string | undefined = process.argv[1]): boolean {
|
||||
if (entry === undefined) return false
|
||||
if (import.meta.url === pathToFileURL(resolve(entry)).href) return true
|
||||
try {
|
||||
return import.meta.url === pathToFileURL(realpathSync(entry)).href
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1058,9 +1079,7 @@ export async function writeGateFailureLog(
|
||||
if (!Number.isSafeInteger(retention) || retention < 1) {
|
||||
throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`)
|
||||
}
|
||||
await assertRepoLocalLogPath(repositoryRoot, directory)
|
||||
const repositoryIdentity = await readDirectoryIdentity(repositoryRoot)
|
||||
if (repositoryIdentity === undefined) throw new Error(`run-gates: repository root disappeared: ${repositoryRoot}`)
|
||||
const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory)
|
||||
const timestamp = now.toISOString().replaceAll(/[:.]/g, '-')
|
||||
const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '')
|
||||
if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.')
|
||||
@@ -1070,6 +1089,7 @@ export async function writeGateFailureLog(
|
||||
directory,
|
||||
repositoryRoot,
|
||||
repositoryIdentity,
|
||||
pathComponents,
|
||||
{
|
||||
operation: 'write',
|
||||
filename,
|
||||
@@ -1082,24 +1102,37 @@ export async function writeGateFailureLog(
|
||||
return resolve(directory, filename)
|
||||
}
|
||||
|
||||
async function assertRepoLocalLogPath(repositoryRoot: string, target: string): Promise<void> {
|
||||
async function inspectRepoLocalLogPath(
|
||||
repositoryRoot: string,
|
||||
target: string,
|
||||
): Promise<GateLogPathPlan> {
|
||||
const relativeTarget = relative(repositoryRoot, target)
|
||||
if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
|
||||
throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`)
|
||||
}
|
||||
|
||||
const rootMetadata = await lstat(repositoryRoot)
|
||||
const rootMetadata = await lstat(repositoryRoot, { bigint: true })
|
||||
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
|
||||
throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`)
|
||||
}
|
||||
const components: GateLogPathComponent[] = []
|
||||
let current = repositoryRoot
|
||||
let missing = false
|
||||
for (const component of relativeTarget.split(sep)) {
|
||||
current = resolve(current, component)
|
||||
if (missing) {
|
||||
components.push({ name: component, identity: null })
|
||||
continue
|
||||
}
|
||||
let metadata
|
||||
try {
|
||||
metadata = await lstat(current)
|
||||
metadata = await lstat(current, { bigint: true })
|
||||
} catch (error: unknown) {
|
||||
if (hasErrorCode(error, 'ENOENT')) return
|
||||
if (hasErrorCode(error, 'ENOENT')) {
|
||||
missing = true
|
||||
components.push({ name: component, identity: null })
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const shown = relative(repositoryRoot, current).split(sep).join('/')
|
||||
@@ -1109,6 +1142,11 @@ async function assertRepoLocalLogPath(repositoryRoot: string, target: string): P
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`)
|
||||
}
|
||||
components.push({ name: component, identity: { dev: String(metadata.dev), ino: String(metadata.ino) } })
|
||||
}
|
||||
return {
|
||||
repositoryIdentity: { dev: String(rootMetadata.dev), ino: String(rootMetadata.ino) },
|
||||
pathComponents: components,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1134,6 +1172,7 @@ async function runGateLogHelper(
|
||||
directory: string,
|
||||
repositoryRoot: string,
|
||||
repositoryIdentity: GateLogDirectoryIdentity,
|
||||
pathComponents: GateLogPathComponent[],
|
||||
request: GateLogHelperRequest,
|
||||
beforeHelper: (() => Promise<void> | void) | undefined,
|
||||
): Promise<GateLogHelperResult> {
|
||||
@@ -1144,6 +1183,7 @@ async function runGateLogHelper(
|
||||
root: repositoryRoot,
|
||||
relative: relative(repositoryRoot, directory),
|
||||
identity: repositoryIdentity,
|
||||
components: pathComponents,
|
||||
},
|
||||
})
|
||||
const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => {
|
||||
@@ -1181,7 +1221,7 @@ async function runGateLogHelper(
|
||||
throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`)
|
||||
}
|
||||
if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.')
|
||||
await assertRepoLocalLogPath(repositoryRoot, directory)
|
||||
await inspectRepoLocalLogPath(repositoryRoot, directory)
|
||||
const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot)
|
||||
if (
|
||||
currentRepositoryIdentity === undefined
|
||||
@@ -1229,10 +1269,15 @@ export async function cleanGateFailureLogs(
|
||||
repositoryRoot = root,
|
||||
beforeHelper?: () => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
await assertRepoLocalLogPath(repositoryRoot, directory)
|
||||
const repositoryIdentity = await readDirectoryIdentity(repositoryRoot)
|
||||
if (repositoryIdentity === undefined) return
|
||||
await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'clean' }, beforeHelper)
|
||||
const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory)
|
||||
await runGateLogHelper(
|
||||
directory,
|
||||
repositoryRoot,
|
||||
repositoryIdentity,
|
||||
pathComponents,
|
||||
{ operation: 'clean' },
|
||||
beforeHelper,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1251,10 +1296,15 @@ export async function pruneGateLogs(
|
||||
if (!Number.isSafeInteger(retain) || retain < 0) {
|
||||
throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`)
|
||||
}
|
||||
await assertRepoLocalLogPath(repositoryRoot, directory)
|
||||
const repositoryIdentity = await readDirectoryIdentity(repositoryRoot)
|
||||
if (repositoryIdentity === undefined) return
|
||||
await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'prune', retain }, beforeHelper)
|
||||
const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory)
|
||||
await runGateLogHelper(
|
||||
directory,
|
||||
repositoryRoot,
|
||||
repositoryIdentity,
|
||||
pathComponents,
|
||||
{ operation: 'prune', retain },
|
||||
beforeHelper,
|
||||
)
|
||||
}
|
||||
|
||||
async function attachFailureLog(plan: GatePlan, result: GateResult): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user