303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
|
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
|
import { readFile } from 'node:fs/promises'
|
|
|
|
/**
|
|
* Exercise the auto-update decision pipeline WITHOUT ever triggering a real update.
|
|
*
|
|
* # Prove the core logic deterministically (no network/DB/Docker):
|
|
* node ace auto-update:dry-run --scenarios
|
|
*
|
|
* # Simulate "what would happen if I were running 1.32.0 right now"
|
|
* # against the live GitHub releases feed and real pre-flight checks:
|
|
* node ace auto-update:dry-run --current=1.32.0 --force-enabled
|
|
*
|
|
* # Fully offline simulation with a canned release list + fixed clock:
|
|
* node ace auto-update:dry-run --current=1.32.0 --force-enabled \
|
|
* --releases-file=./fixtures/releases.json --now=2026-06-04T21:00:00Z \
|
|
* --window-start=20:00 --window-end=23:00 --skip-preflight
|
|
*/
|
|
export default class AutoUpdateDryRun extends BaseCommand {
|
|
static commandName = 'auto-update:dry-run'
|
|
static description = 'Dry-run the auto-update decision pipeline (never triggers an update)'
|
|
|
|
@flags.boolean({ description: 'Run the built-in deterministic scenario suite and exit' })
|
|
declare scenarios: boolean
|
|
|
|
@flags.string({ description: 'Simulate this currently-running version (e.g. 1.32.0)' })
|
|
declare current: string
|
|
|
|
@flags.boolean({ description: 'Ignore the persisted enabled setting and treat as enabled' })
|
|
declare forceEnabled: boolean
|
|
|
|
@flags.string({ description: 'Override cool-off hours' })
|
|
declare cooloff: string
|
|
|
|
@flags.string({ description: 'Override window start (HH:MM)' })
|
|
declare windowStart: string
|
|
|
|
@flags.string({ description: 'Override window end (HH:MM)' })
|
|
declare windowEnd: string
|
|
|
|
@flags.string({ description: 'Simulate the clock at this ISO timestamp' })
|
|
declare now: string
|
|
|
|
@flags.string({ description: 'Path to a JSON file with a GitHub releases array (offline)' })
|
|
declare releasesFile: string
|
|
|
|
@flags.boolean({ description: 'Bypass Docker/disk/queue pre-flight checks' })
|
|
declare skipPreflight: boolean
|
|
|
|
static options: CommandOptions = {
|
|
startApp: true,
|
|
}
|
|
|
|
async run() {
|
|
const { DateTime } = await import('luxon')
|
|
const { DockerService } = await import('#services/docker_service')
|
|
const { DownloadService } = await import('#services/download_service')
|
|
const { SystemService } = await import('#services/system_service')
|
|
const { SystemUpdateService } = await import('#services/system_update_service')
|
|
const { ContainerRegistryService } = await import('#services/container_registry_service')
|
|
const { QueueService } = await import('#services/queue_service')
|
|
const { AutoUpdateService } = await import('#services/auto_update_service')
|
|
|
|
const dockerService = new DockerService()
|
|
const svc = new AutoUpdateService(
|
|
dockerService,
|
|
new DownloadService(QueueService.getInstance()),
|
|
new SystemService(dockerService),
|
|
new SystemUpdateService(),
|
|
new ContainerRegistryService()
|
|
)
|
|
|
|
if (this.scenarios) {
|
|
const ok = this.runScenarios(svc, DateTime)
|
|
if (!ok) {
|
|
this.exitCode = 1
|
|
}
|
|
return
|
|
}
|
|
|
|
// --- Live / simulated single dry run ------------------------------------
|
|
const overrides: Record<string, any> = {}
|
|
if (this.current) overrides.currentVersion = this.current
|
|
if (this.forceEnabled) overrides.forceEnabled = true
|
|
if (this.cooloff) overrides.cooloffHours = Number(this.cooloff)
|
|
if (this.windowStart) overrides.windowStart = this.windowStart
|
|
if (this.windowEnd) overrides.windowEnd = this.windowEnd
|
|
if (this.skipPreflight) overrides.skipPreflight = true
|
|
if (this.now) overrides.now = DateTime.fromISO(this.now)
|
|
if (this.releasesFile) {
|
|
const raw = await readFile(this.releasesFile, 'utf-8')
|
|
overrides.releases = JSON.parse(raw)
|
|
}
|
|
|
|
this.logger.info('Running auto-update dry run (no update will be triggered)...')
|
|
const decision = await svc.dryRun(overrides)
|
|
|
|
this.logger.log('')
|
|
this.logger.log(` Current version : ${decision.currentVersion}`)
|
|
this.logger.log(` Enabled : ${decision.enabled}`)
|
|
this.logger.log(
|
|
` Window : ${decision.config.windowStart}-${decision.config.windowEnd} ` +
|
|
`(currently ${decision.withinWindow ? 'inside' : 'outside'})`
|
|
)
|
|
this.logger.log(` Cool-off hours : ${decision.config.cooloffHours}`)
|
|
this.logger.log(
|
|
` Eligible target : ${decision.eligibleTarget ? decision.eligibleTarget.tag + ' (published ' + decision.eligibleTarget.publishedAt + ')' : '—'}`
|
|
)
|
|
if (decision.preflight) {
|
|
if (decision.preflight.ok) {
|
|
this.logger.log(` Pre-flight : ok`)
|
|
} else {
|
|
this.logger.log(` Pre-flight : BLOCKED`)
|
|
for (const b of decision.preflight.blockers) {
|
|
this.logger.log(` - [${b.severity}] ${b.reason}`)
|
|
}
|
|
}
|
|
} else {
|
|
this.logger.log(` Pre-flight : (not reached)`)
|
|
}
|
|
this.logger.log('')
|
|
|
|
const verdict =
|
|
decision.outcome === 'ready'
|
|
? `WOULD UPDATE → ${decision.eligibleTarget!.tag}`
|
|
: `WOULD NOT UPDATE (${decision.outcome}): ${decision.reason}`
|
|
if (decision.outcome === 'ready') {
|
|
this.logger.success(verdict)
|
|
} else {
|
|
this.logger.info(verdict)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deterministic acceptance suite over the pure decision helpers — no network,
|
|
* DB, or Docker. Proves every branch reviewers care about.
|
|
*/
|
|
private runScenarios(svc: any, DateTime: any): boolean {
|
|
const NOW = '2026-06-04T12:00:00Z'
|
|
const now = DateTime.fromISO(NOW)
|
|
const daysAgo = (d: number) => now.minus({ days: d }).toISO()
|
|
const hoursAgo = (h: number) => now.minus({ hours: h }).toISO()
|
|
const rel = (tag: string, published: string, extra: object = {}) => ({
|
|
tag_name: tag,
|
|
published_at: published,
|
|
...extra,
|
|
})
|
|
|
|
type EligCase = {
|
|
name: string
|
|
releases: any[]
|
|
current: string
|
|
cooloff: number
|
|
expect: string | null
|
|
}
|
|
|
|
const eligibility: EligCase[] = [
|
|
{
|
|
name: 'only a major bump is newer → none (major requires manual)',
|
|
releases: [rel('v2.0.0', daysAgo(10))],
|
|
current: '1.32.0',
|
|
cooloff: 72,
|
|
expect: null,
|
|
},
|
|
{
|
|
name: 'same-major minor newer but inside cool-off → none',
|
|
releases: [rel('v1.33.0', hoursAgo(10))],
|
|
current: '1.32.0',
|
|
cooloff: 72,
|
|
expect: null,
|
|
},
|
|
{
|
|
name: 'same-major patch past cool-off → selected',
|
|
releases: [rel('v1.32.1', daysAgo(5))],
|
|
current: '1.32.0',
|
|
cooloff: 72,
|
|
expect: '1.32.1',
|
|
},
|
|
{
|
|
name: 'mixed: newest same-major past cool-off wins; major/in-cooloff/prerelease ignored',
|
|
releases: [
|
|
rel('v2.0.0', daysAgo(30)),
|
|
rel('v1.34.0', hoursAgo(5)),
|
|
rel('v1.33.2', daysAgo(4)),
|
|
rel('v1.33.5', daysAgo(1), { prerelease: true }),
|
|
rel('v1.33.0', daysAgo(8)),
|
|
],
|
|
current: '1.32.9',
|
|
cooloff: 72,
|
|
expect: '1.33.2',
|
|
},
|
|
{
|
|
name: 'draft releases ignored',
|
|
releases: [rel('v1.33.0', daysAgo(5), { draft: true })],
|
|
current: '1.32.0',
|
|
cooloff: 72,
|
|
expect: null,
|
|
},
|
|
{
|
|
name: 'malformed tag with injection chars → ignored (M2)',
|
|
releases: [rel('v1.33.0|; e reboot', daysAgo(10))],
|
|
current: '1.32.0',
|
|
cooloff: 72,
|
|
expect: null,
|
|
},
|
|
{
|
|
name: 'dev build never updates',
|
|
releases: [rel('v1.33.0', daysAgo(10))],
|
|
current: 'dev',
|
|
cooloff: 72,
|
|
expect: null,
|
|
},
|
|
{
|
|
name: 'cool-off of 0 applies immediately',
|
|
releases: [rel('v1.32.1', hoursAgo(1))],
|
|
current: '1.32.0',
|
|
cooloff: 0,
|
|
expect: '1.32.1',
|
|
},
|
|
]
|
|
|
|
type WinCase = { name: string; start: string; end: string; at: string; expect: boolean }
|
|
const at = (hhmm: string) => `2026-06-04T${hhmm}:00`
|
|
const windows: WinCase[] = [
|
|
{
|
|
name: 'normal 20:00-23:00 @ 21:00 → in',
|
|
start: '20:00',
|
|
end: '23:00',
|
|
at: at('21:00'),
|
|
expect: true,
|
|
},
|
|
{
|
|
name: 'normal 20:00-23:00 @ 19:00 → out',
|
|
start: '20:00',
|
|
end: '23:00',
|
|
at: at('19:00'),
|
|
expect: false,
|
|
},
|
|
{
|
|
name: 'wrap 22:00-02:00 @ 23:00 → in',
|
|
start: '22:00',
|
|
end: '02:00',
|
|
at: at('23:00'),
|
|
expect: true,
|
|
},
|
|
{
|
|
name: 'wrap 22:00-02:00 @ 01:00 → in',
|
|
start: '22:00',
|
|
end: '02:00',
|
|
at: at('01:00'),
|
|
expect: true,
|
|
},
|
|
{
|
|
name: 'wrap 22:00-02:00 @ 12:00 → out',
|
|
start: '22:00',
|
|
end: '02:00',
|
|
at: at('12:00'),
|
|
expect: false,
|
|
},
|
|
]
|
|
|
|
let passed = 0
|
|
let failed = 0
|
|
|
|
this.logger.log('')
|
|
this.logger.log('Eligibility scenarios:')
|
|
for (const c of eligibility) {
|
|
const got = svc.selectEligibleTarget(c.releases, c.current, c.cooloff, now)
|
|
const gotVersion = got ? got.version : null
|
|
const ok = gotVersion === c.expect
|
|
this.report(ok, `${c.name} (expected ${c.expect ?? 'none'}, got ${gotVersion ?? 'none'})`)
|
|
ok ? passed++ : failed++
|
|
}
|
|
|
|
this.logger.log('')
|
|
this.logger.log('Window scenarios:')
|
|
for (const c of windows) {
|
|
const cfg = { enabled: true, windowStart: c.start, windowEnd: c.end, cooloffHours: 72 }
|
|
const got = svc.isWithinWindow(cfg, DateTime.fromISO(c.at))
|
|
const ok = got === c.expect
|
|
this.report(ok, `${c.name} (expected ${c.expect}, got ${got})`)
|
|
ok ? passed++ : failed++
|
|
}
|
|
|
|
this.logger.log('')
|
|
if (failed === 0) {
|
|
this.logger.success(`All ${passed} scenarios passed`)
|
|
} else {
|
|
this.logger.error(`${failed} scenario(s) failed, ${passed} passed`)
|
|
}
|
|
return failed === 0
|
|
}
|
|
|
|
private report(ok: boolean, message: string) {
|
|
if (ok) {
|
|
this.logger.log(` ${this.colors.green('✓')} ${message}`)
|
|
} else {
|
|
this.logger.log(` ${this.colors.red('✗')} ${message}`)
|
|
}
|
|
}
|
|
}
|