Files
dsh/upstream/container/control-plane/services/management/cli.mjs
T

443 lines
19 KiB
JavaScript

import { access, rm } from 'node:fs/promises'
import { createInterface } from 'node:readline/promises'
import { stdin, stdout } from 'node:process'
import { LocalApiClient } from '../../modules/updater/lib/client.mjs'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
const API_PREFIX = '/_dsh_platform/api/v1'
function usage() {
return `usage: dsh-platform <command> [options]
commands:
status
check
update [--wait]
start|stop|restart [--wait]
channel [stable|experimental]
retry|rollback|return-stable
recover
logs [--source NAME] [--since ISO] [--limit N]
access status|reset|set-username|reset-password
access reset-management-password|disable-management-password|disable-two-factor|clear-sessions [--management-only]|generate-key
access clear-retry [--global-only] [--main-password|--management-password|--two-factor]
trust status|reset`
}
export function parseCli(argv) {
const [command, ...rest] = argv
if (['status', 'check', 'retry', 'rollback', 'return-stable', 'recover'].includes(command)) {
if (rest.length !== 0) throw new Error(usage())
return { command }
}
if (command === 'channel' && (rest.length === 0 || (rest.length === 1 && ['stable', 'experimental'].includes(rest[0])))) {
return { command, channel: rest[0] }
}
if (['update', 'start', 'stop', 'restart'].includes(command)) {
if (rest.some(value => value !== '--wait') || rest.filter(value => value === '--wait').length > 1) throw new Error(usage())
return { command, wait: rest.includes('--wait') }
}
if (command === 'logs') {
const options = { command, sources: [] }
for (let index = 0; index < rest.length; index += 2) {
const name = rest[index]
const value = rest[index + 1]
if (value === undefined) throw new Error(usage())
if (name === '--source') options.sources.push(value)
else if (name === '--since') options.since = value
else if (name === '--limit') options.limit = Number(value)
else throw new Error(usage())
}
return options
}
if (command === 'trust' && rest.length === 1 && ['status', 'reset'].includes(rest[0])) {
return { command, operation: rest[0] }
}
if (command === 'access' && rest.length === 1 && ['status', 'reset', 'set-username', 'reset-password', 'reset-management-password', 'disable-management-password', 'disable-two-factor', 'clear-sessions', 'generate-key', 'image-baseline', 'clear-retry'].includes(rest[0])) {
return { command, operation: rest[0] }
}
if (command === 'access' && rest[0] === 'clear-retry') {
const options = rest.slice(1)
const credentialOptions = options.filter(value => ['--main-password', '--management-password', '--two-factor'].includes(value))
if (options.length > 0 && options.length <= 2 && new Set(options).size === options.length
&& credentialOptions.length <= 1
&& options.every(value => ['--global-only', '--main-password', '--management-password', '--two-factor'].includes(value))) {
return {
command,
operation: 'clear-retry',
...(options.includes('--global-only') ? { globalOnly: true } : {}),
...(options.includes('--main-password') ? { credential: 'main' } : {}),
...(options.includes('--management-password') ? { credential: 'management' } : {}),
...(options.includes('--two-factor') ? { credential: 'totp' } : {}),
}
}
}
if (command === 'access' && rest[0] === 'clear-sessions') {
const options = rest.slice(1)
if (options.length <= 1 && (options.length === 0 || options[0] === '--management-only')) {
return { command, operation: 'clear-sessions', ...(options.length === 1 ? { managementOnly: true } : {}) }
}
}
throw new Error(usage())
}
export async function recoverImageBaseline({
recovery,
input = stdin,
output = stdout,
getuid = () => process.getuid?.(),
ask,
} = {}) {
if (getuid() !== 0) throw new Error('image baseline recovery must run as root from the container console')
if (!input.isTTY || !output.isTTY) throw new Error('image baseline recovery requires an interactive container console')
const status = await recovery.request('GET', '/v1/status')
const imageBuildId = status.imageBaseline?.imageBuildId
if (typeof imageBuildId !== 'string') throw new Error('image baseline is unavailable')
const expected = `RECOVER IMAGE BASELINE ${imageBuildId}`
let answer
if (ask !== undefined) answer = await ask(expected, status)
else {
const prompt = createInterface({ input, output })
answer = await prompt.question(
`Current Deployment may be incompatible with image ${status.imageBaseline.dsh}. Type ${expected}: `,
)
prompt.close()
}
if (answer !== expected) throw new Error('image baseline recovery cancelled')
return recovery.request('POST', '/v1/recover-image-baseline', { confirm: imageBuildId })
}
export async function resetTrust({
dataRoot = '/data/platform',
runRoot = '/run/dsh-platform',
input = stdin,
output = stdout,
getuid = () => process.getuid?.(),
} = {}) {
if (getuid() !== 0) throw new Error('trust reset must run as root from the container console')
if (!input.isTTY || !output.isTTY) throw new Error('trust reset requires an interactive container console')
const paths = new PlatformPaths(dataRoot, runRoot)
const socket = paths.trustSocket
if (await access(socket).then(() => true, () => false)) throw new Error('stop Stage-0 before resetting trust')
const prompt = createInterface({ input, output })
const answer = await prompt.question('Type RESET DSH TRUST to clear accepted trust state: ')
prompt.close()
if (answer !== 'RESET DSH TRUST') throw new Error('trust reset cancelled')
await rm(paths.trustStateRoot, { recursive: true, force: true })
return { status: 'reset', recoveryRoot: 'image' }
}
function queryString(options) {
const query = new URLSearchParams()
for (const source of options.sources ?? []) query.append('source', source)
if (options.since !== undefined) query.set('since', options.since)
if (options.limit !== undefined) query.set('limit', String(options.limit))
const value = query.toString()
return value === '' ? '' : `?${value}`
}
function json(value) {
return JSON.stringify(value)
}
function managementPasswordAction(value) {
const normalized = value.trim().toLowerCase()
if (['', 'n'].includes(normalized)) return 'preserve'
if (normalized === 'd') return 'disable'
if (normalized === 'y') return 'reset'
throw new Error('management password choice must be y, n, or d')
}
function affirmative(value, label) {
const normalized = value.trim().toLowerCase()
if (normalized === 'y') return true
if (['', 'n'].includes(normalized)) return false
throw new Error(`${label} choice must be y or n`)
}
export async function readSecret(input, output, prompt) {
output.write(prompt)
if (typeof input.setRawMode !== 'function') throw new Error('password input must be an interactive TTY')
input.setRawMode(true)
input.resume()
return new Promise((resolve, reject) => {
let value = ''
const cleanup = () => {
input.off('data', onData)
input.off('end', onEnd)
input.off('error', onError)
input.setRawMode(false)
input.pause()
}
const finish = (error, result) => {
cleanup()
if (error === undefined) resolve(result)
else reject(error)
}
const onData = chunk => {
for (const character of String(chunk)) {
if (character === '\r' || character === '\n') {
output.write('\n')
finish(undefined, value)
return
}
if (character === '\u0003') {
finish(new Error('access recovery cancelled'))
return
}
if (character === '\u007f') value = value.slice(0, -1)
else if (character >= ' ') value += character
}
}
const onEnd = () => finish(new Error('password input ended before confirmation'))
const onError = error => finish(error)
input.on('data', onData)
input.once('end', onEnd)
input.once('error', onError)
})
}
const RETRYABLE_CONTROL_PLANE_ERRORS = new Set(['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE'])
function isRetryableControlPlaneError(error) {
return RETRYABLE_CONTROL_PLANE_ERRORS.has(error?.code)
}
export async function runCli({
argv = process.argv.slice(2),
management = new LocalApiClient(process.env.DSH_PLATFORM_MANAGEMENT_CLI_SOCKET ?? '/run/dsh-platform/management-cli.sock'),
trust = new LocalApiClient(process.env.DSH_PLATFORM_TRUST_SOCKET ?? '/run/dsh-platform/stage0-trust.sock'),
reset = resetTrust,
recovery = new LocalApiClient(process.env.DSH_PLATFORM_RECOVERY_SOCKET ?? '/run/dsh-platform/recovery.sock'),
access = new LocalApiClient(process.env.DSH_PLATFORM_ACCESS_RECOVERY_SOCKET ?? '/run/dsh-platform/access/recovery.sock'),
recover = recoverImageBaseline,
write = value => process.stdout.write(`${value}\n`),
delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
input = stdin,
output = stdout,
getuid = () => process.getuid?.(),
readPassword = readSecret,
ask = async question => {
const prompt = createInterface({ input, output })
try { return await prompt.question(question) } finally { prompt.close() }
},
} = {}) {
const parsed = parseCli(argv)
if (parsed.command === 'recover') {
if (getuid() !== 0) throw new Error('image baseline recovery must run as root from the container console')
if (!input.isTTY || !output.isTTY) throw new Error('image baseline recovery requires an interactive container console')
write(json(await recover({ recovery, input, output })))
return 0
}
if (parsed.command === 'access') {
if (getuid() !== 0) throw new Error('access recovery requires root')
if (!input.isTTY || !output.isTTY) throw new Error('access recovery requires an interactive container console')
const current = await access.request('GET', '/v1/recovery/status')
if (parsed.operation === 'status') {
write(json(current))
return 0
}
const confirm = async question => affirmative(await ask(question), 'confirmation')
const cancel = () => {
write(json({ status: 'cancelled' }))
return 0
}
if (parsed.operation === 'generate-key') {
if (!await confirm('Generate an administrator authentication reset key? y/[n]: ')) return cancel()
write(json(await access.request('POST', '/v1/recovery/generate-key')))
return 0
}
if (parsed.operation === 'clear-sessions') {
const question = parsed.managementOnly
? 'Clear all Management console sessions? y/[n]: '
: 'Clear all DSH and Management console sessions? y/[n]: '
if (!await confirm(question)) return cancel()
write(json(await access.request('POST', '/v1/recovery/clear-sessions', {
managementOnly: parsed.managementOnly === true,
})))
return 0
}
if (parsed.operation === 'clear-retry') {
const credentialLabel = parsed.credential === 'main'
? 'main password' : parsed.credential === 'management' ? 'Management console password'
: parsed.credential === 'totp' ? 'two-factor' : 'all administrator'
const question = parsed.credential === 'totp'
? parsed.globalOnly
? 'Clear the instance-wide two-factor daily limit? y/[n]: '
: 'Clear two-factor daily limits? The fixed 10-second retry remains active. y/[n]: '
: parsed.globalOnly
? 'Clear instance-wide administrator login rate limits? y/[n]: '
: `Clear ${credentialLabel} login retry limits? y/[n]: `
if (!await confirm(question)) return cancel()
const authenticationRetry = await access.request('POST', '/v1/recovery/clear-retry', {
scope: parsed.globalOnly ? 'global' : 'all',
...(parsed.credential === undefined ? {} : { credential: parsed.credential }),
})
write(json(authenticationRetry))
return 0
}
if (current.account === null) throw new Error('administrator account is unavailable')
const revision = current.account.revision
if (parsed.operation === 'set-username') {
if (!await confirm('Change administrator username? y/[n]: ')) return cancel()
const username = await ask('New administrator username: ')
write(json(await access.request('POST', '/v1/recovery/set-username', { revision, username })))
return 0
}
if (parsed.operation === 'disable-management-password') {
if (!await confirm('Disable management password? y/[n]: ')) return cancel()
write(json(await access.request('POST', '/v1/recovery/disable-management-password', { revision })))
return 0
}
if (parsed.operation === 'disable-two-factor') {
if (!await confirm('Disable two-factor authentication? y/[n]: ')) return cancel()
write(json(await access.request('POST', '/v1/recovery/reset-access', {
revision,
managementPasswordAction: 'preserve',
totpAction: 'disable',
})))
return 0
}
if (parsed.operation === 'reset') {
let username
let password
if (affirmative(await ask('Change administrator username? y/[n]: '), 'username')) {
username = await ask('New administrator username: ')
}
if (affirmative(await ask('Change administrator password? y/[n]: '), 'password')) {
password = await readPassword(input, output, 'New administrator password: ')
}
let action = 'preserve'
let managementPassword
if (current.account.managementAdditionalCredential.enabled) {
action = managementPasswordAction(await ask(
'Change Management console password? (y)es/[n]o/(d)isable: ',
))
if (action === 'reset') {
managementPassword = await readPassword(input, output, 'New management password: ')
}
}
let totpAction = 'preserve'
if (current.account.totp?.enabled === true) {
if (affirmative(await ask('Disable two-factor authentication? y/[n]: '), 'two-factor')) {
totpAction = 'disable'
}
}
if (username === undefined && password === undefined && action === 'preserve' && totpAction === 'preserve') return cancel()
write(json(await access.request('POST', '/v1/recovery/reset-access', {
revision,
...(username === undefined ? {} : { username }),
...(password === undefined ? {} : { password }),
managementPasswordAction: action,
...(totpAction === 'preserve' ? {} : { totpAction }),
...(managementPassword === undefined ? {} : { managementPassword }),
})))
return 0
}
const confirmed = parsed.operation === 'reset-management-password'
? await confirm('Set or change management password? y/[n]: ')
: await confirm('Change administrator password? y/[n]: ')
if (!confirmed) return cancel()
const password = await readPassword(input, output, parsed.operation === 'reset-management-password'
? 'New management password: ' : 'New administrator password: ')
const route = parsed.operation === 'reset-management-password'
? '/v1/recovery/reset-management-password' : '/v1/recovery/reset-password'
const value = await access.request('POST', route, { revision, password })
write(json(value))
return 0
}
if (parsed.command === 'trust') {
const value = parsed.operation === 'status' ? await trust.status() : await reset()
write(json(value))
return 0
}
if (parsed.command === 'logs') {
const value = await management.request('GET', `${API_PREFIX}/logs${queryString(parsed)}`)
for (const entry of value.entries) write(JSON.stringify(entry))
return 0
}
if (parsed.command === 'update') {
const started = await management.request('POST', `${API_PREFIX}/update`)
write(json(started))
if (!parsed.wait) return 0
let controlPlaneRestarted = false
for (;;) {
let value
try {
value = await management.request('GET', `${API_PREFIX}/status`)
} catch (error) {
if (!isRetryableControlPlaneError(error)) throw error
controlPlaneRestarted = true
await delay(1_000)
continue
}
const terminal = ['success', 'failed'].includes(value.update.status)
const sameTask = value.update.taskId === started.taskId
const resumedTask = controlPlaneRestarted
&& value.update.taskId !== null
&& value.update.operation === 'update'
if (terminal && (sameTask || resumedTask)) {
write(json(value.update))
return value.update.status === 'success' ? 0 : 1
}
await delay(1_000)
}
}
if (['start', 'stop', 'restart'].includes(parsed.command)) {
const started = await management.request('POST', `${API_PREFIX}/${parsed.command}-dsh`)
write(json(started))
if (!parsed.wait) return 0
for (;;) {
const value = await management.request('GET', `${API_PREFIX}/status`)
const lifecycle = value.dshLifecycle
const successState = parsed.command === 'stop' ? 'stopped' : 'running'
if (lifecycle.taskId === started.taskId && [successState, 'failed'].includes(lifecycle.state)) {
write(json(lifecycle))
return lifecycle.state === successState ? 0 : 1
}
await delay(1_000)
}
}
if (parsed.command === 'channel') {
if (parsed.channel === undefined) {
const value = await management.request('GET', `${API_PREFIX}/status`)
write(value.updateChannel)
} else {
const value = await management.request('PUT', `${API_PREFIX}/channel`, { channel: parsed.channel })
write(value.updateChannel)
}
return 0
}
if (parsed.command === 'retry') {
const status = await management.request('GET', `${API_PREFIX}/status`)
const choices = [...status.holds, ...(status.experimentalBlocked === null ? [] : [status.experimentalBlocked])]
const unique = [...new Map(choices.map(value => [value.id, value])).values()]
if (unique.length !== 1) throw new Error('retry requires exactly one active Hold or Blocked combination')
const value = await management.request('POST', `${API_PREFIX}/holds/retry`, { id: unique[0].id })
write(json(value))
return 0
}
if (parsed.command === 'rollback' || parsed.command === 'return-stable') {
const { plan } = await management.request('GET', `${API_PREFIX}/rollback-plan`)
if (plan === null) throw new Error('no complete rollback plan is available')
let confirmDataLoss = false
if (parsed.command === 'return-stable') {
if (!input.isTTY || !output.isTTY) throw new Error('return-stable requires an interactive container console')
const prompt = createInterface({ input, output })
const answer = await prompt.question(`Restore data snapshot from ${plan.snapshot.createdAt}? Type RETURN STABLE AND LOSE NEWER DATA: `)
prompt.close()
if (answer !== 'RETURN STABLE AND LOSE NEWER DATA') throw new Error('return-stable cancelled')
confirmDataLoss = true
}
const value = await management.request('POST', `${API_PREFIX}/${parsed.command}`, {
planId: plan.planId,
...(parsed.command === 'return-stable' ? { confirmDataLoss } : {}),
})
write(json(value))
return 0
}
const method = parsed.command === 'status' ? 'GET' : 'POST'
const value = await management.request(method, `${API_PREFIX}/${parsed.command}`)
write(json(value))
return 0
}