431 lines
20 KiB
JavaScript
Executable file
431 lines
20 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
import crypto from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import { DEFAULT_PUBLIC_PREFIX, isMainModule, loadRuntimeState, normalizePublicPrefix } from './server.mjs'
|
|
|
|
const MAX_CONTROL_BYTES = 1024 * 1024
|
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
|
const SOURCE_COMMIT_PATTERN = /^[a-f0-9]{40}$/
|
|
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
|
|
const fail = (code) => {
|
|
throw new Error(code)
|
|
}
|
|
|
|
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
|
|
|
|
const requireText = (value, code) => {
|
|
if (typeof value !== 'string' || value.trim() === '') fail(code)
|
|
return value.trim()
|
|
}
|
|
|
|
function requireTrustedDirectory(directory, { enforceRootOwner = false } = {}) {
|
|
const info = fs.lstatSync(directory)
|
|
if (!info.isDirectory() || info.isSymbolicLink()) fail('HOLOLAKE_RELEASE_OPERATOR_DIRECTORY_INVALID')
|
|
if ((info.mode & 0o022) !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_DIRECTORY_WRITABLE_BY_NON_OWNER')
|
|
if (enforceRootOwner && info.uid !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_DIRECTORY_NOT_ROOT_OWNED')
|
|
return info
|
|
}
|
|
|
|
function requireTrustedFile(file, { enforceRootOwner = false, maxBytes = null } = {}) {
|
|
const info = fs.lstatSync(file)
|
|
if (!info.isFile() || info.isSymbolicLink()) fail('HOLOLAKE_RELEASE_OPERATOR_FILE_INVALID')
|
|
if ((info.mode & 0o022) !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_FILE_WRITABLE_BY_NON_OWNER')
|
|
if (enforceRootOwner && info.uid !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_FILE_NOT_ROOT_OWNED')
|
|
if (maxBytes !== null && info.size > maxBytes) fail('HOLOLAKE_RELEASE_OPERATOR_CONTROL_FILE_TOO_LARGE')
|
|
return info
|
|
}
|
|
|
|
function readJson(file, { enforceRootOwner = false } = {}) {
|
|
requireTrustedFile(file, { enforceRootOwner, maxBytes: MAX_CONTROL_BYTES })
|
|
const bytes = fs.readFileSync(file)
|
|
return { bytes, value: JSON.parse(bytes.toString('utf8')) }
|
|
}
|
|
|
|
function directFile(directory, name, code) {
|
|
const value = requireText(name, code)
|
|
if (value !== path.basename(value) || value === '.' || value === '..' || value.includes('\0')) fail(code)
|
|
const resolved = path.resolve(directory, value)
|
|
if (path.dirname(resolved) !== directory) fail(code)
|
|
return resolved
|
|
}
|
|
|
|
function packageNameFromUrl(value, publicPrefix) {
|
|
const url = new URL(requireText(value, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_REQUIRED'))
|
|
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
|
|
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID')
|
|
}
|
|
let packageName
|
|
try {
|
|
packageName = decodeURIComponent(path.posix.basename(url.pathname))
|
|
} catch {
|
|
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID')
|
|
}
|
|
if (!packageName || packageName === '.' || packageName === '..') fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_NAME_INVALID')
|
|
if (!url.pathname.startsWith(`${publicPrefix}/`) || url.pathname === `${publicPrefix}/latest.json` || url.pathname === `${publicPrefix}/health`) {
|
|
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_PUBLIC_PREFIX_MISMATCH')
|
|
}
|
|
return packageName
|
|
}
|
|
|
|
function addArtifact(artifacts, source, name, type) {
|
|
const previous = artifacts.get(name)
|
|
if (previous && previous.source !== source) fail('HOLOLAKE_RELEASE_OPERATOR_ARTIFACT_NAME_COLLISION')
|
|
artifacts.set(name, { source, name, type })
|
|
}
|
|
|
|
export function inspectReleaseBundle(
|
|
sourceDirectory,
|
|
humanApprovalPath,
|
|
{ enforceRootOwner = false, publicPrefix = DEFAULT_PUBLIC_PREFIX } = {},
|
|
) {
|
|
const source = path.resolve(sourceDirectory)
|
|
const normalizedPublicPrefix = normalizePublicPrefix(publicPrefix)
|
|
requireTrustedDirectory(source, { enforceRootOwner })
|
|
const latestPath = directFile(source, 'latest.json', 'HOLOLAKE_RELEASE_OPERATOR_BROADCAST_PATH_INVALID')
|
|
const pipelinePath = directFile(source, 'pipeline-receipt.json', 'HOLOLAKE_RELEASE_OPERATOR_PIPELINE_PATH_INVALID')
|
|
const approvalPath = path.resolve(humanApprovalPath)
|
|
const latestDocument = readJson(latestPath, { enforceRootOwner })
|
|
const pipelineReceipt = readJson(pipelinePath, { enforceRootOwner }).value
|
|
const approvalReceipt = readJson(approvalPath, { enforceRootOwner }).value
|
|
const broadcast = latestDocument.value
|
|
const broadcastSha256 = sha256(latestDocument.bytes)
|
|
|
|
if (broadcast?.schema !== 'hololake.release-broadcast/v1') fail('HOLOLAKE_RELEASE_OPERATOR_BROADCAST_INVALID')
|
|
const releaseId = requireText(broadcast.releaseId, 'HOLOLAKE_RELEASE_OPERATOR_RELEASE_ID_REQUIRED')
|
|
const version = requireText(broadcast.version, 'HOLOLAKE_RELEASE_OPERATOR_VERSION_REQUIRED')
|
|
if (!VERSION_PATTERN.test(version)) fail('HOLOLAKE_RELEASE_OPERATOR_VERSION_INVALID')
|
|
if (
|
|
broadcast?.hololake?.restart?.required !== true ||
|
|
broadcast?.hololake?.restart?.automaticAllowed !== false ||
|
|
broadcast?.hololake?.rollback?.supported !== true ||
|
|
broadcast?.hololake?.rollback?.healthReceiptRequired !== true ||
|
|
!broadcast.platforms ||
|
|
typeof broadcast.platforms !== 'object' ||
|
|
Array.isArray(broadcast.platforms) ||
|
|
Object.keys(broadcast.platforms).length === 0
|
|
) fail('HOLOLAKE_RELEASE_OPERATOR_BROADCAST_POLICY_INVALID')
|
|
|
|
if (
|
|
pipelineReceipt?.schema !== 'hololake.signed-release-pipeline-receipt/v1' ||
|
|
pipelineReceipt?.state !== 'SIGNED_NOTARIZED_RELEASE_BROADCAST_READY_FOR_JD_CONTROLLER_UPLOAD' ||
|
|
pipelineReceipt?.broadcastSha256 !== broadcastSha256 ||
|
|
!SOURCE_COMMIT_PATTERN.test(pipelineReceipt?.sourceCommit || '') ||
|
|
pipelineReceipt?.automaticUpload !== false ||
|
|
pipelineReceipt?.automaticActivation !== false
|
|
) fail('HOLOLAKE_RELEASE_OPERATOR_PIPELINE_RECEIPT_INVALID')
|
|
|
|
const approvalId = requireText(approvalReceipt?.approvalId, 'HOLOLAKE_RELEASE_OPERATOR_APPROVAL_ID_REQUIRED')
|
|
if (
|
|
approvalReceipt?.schema !== 'hololake.release-broadcast-human-approval/v1' ||
|
|
approvalReceipt?.state !== 'HUMAN_APPROVED_EXACT_SIGNED_NOTARIZED_RELEASE' ||
|
|
approvalReceipt?.releaseId !== releaseId ||
|
|
approvalReceipt?.version !== version ||
|
|
approvalReceipt?.broadcastSha256 !== broadcastSha256
|
|
) fail('HOLOLAKE_RELEASE_OPERATOR_HUMAN_APPROVAL_INVALID')
|
|
|
|
const artifacts = new Map()
|
|
addArtifact(artifacts, latestPath, 'latest.json', 'control')
|
|
addArtifact(artifacts, pipelinePath, 'pipeline-receipt.json', 'control')
|
|
addArtifact(artifacts, approvalPath, 'human-approval.json', 'control')
|
|
for (const platform of Object.values(broadcast.platforms)) {
|
|
const packageName = packageNameFromUrl(platform?.url, normalizedPublicPrefix)
|
|
const packagePath = directFile(source, packageName, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_PATH_INVALID')
|
|
const packageInfo = requireTrustedFile(packagePath, { enforceRootOwner })
|
|
if (!Number.isSafeInteger(platform.size) || platform.size <= 0 || platform.size !== packageInfo.size) {
|
|
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_SIZE_MISMATCH')
|
|
}
|
|
if (!SHA256_PATTERN.test(platform.sha256 || '') || sha256(fs.readFileSync(packagePath)) !== platform.sha256) {
|
|
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_SHA256_MISMATCH')
|
|
}
|
|
requireText(platform.signature, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_SIGNATURE_REQUIRED')
|
|
const codeReceiptId = requireText(platform.platformCodeSignatureReceipt, 'HOLOLAKE_RELEASE_OPERATOR_CODESIGN_RECEIPT_REQUIRED')
|
|
const notarizationReceiptId = requireText(platform.notarizationReceipt, 'HOLOLAKE_RELEASE_OPERATOR_NOTARIZATION_RECEIPT_REQUIRED')
|
|
const codeReceiptName = `${codeReceiptId}.json`
|
|
const notarizationReceiptName = `${notarizationReceiptId}.json`
|
|
const codeReceiptPath = directFile(source, codeReceiptName, 'HOLOLAKE_RELEASE_OPERATOR_CODESIGN_RECEIPT_PATH_INVALID')
|
|
const notarizationReceiptPath = directFile(source, notarizationReceiptName, 'HOLOLAKE_RELEASE_OPERATOR_NOTARIZATION_RECEIPT_PATH_INVALID')
|
|
const codeReceipt = readJson(codeReceiptPath, { enforceRootOwner }).value
|
|
const notarizationReceipt = readJson(notarizationReceiptPath, { enforceRootOwner }).value
|
|
if (
|
|
codeReceipt?.schema !== 'hololake.platform-code-signature-receipt/v1' ||
|
|
codeReceipt?.state !== 'DEVELOPER_ID_SIGNATURE_STRICT_AND_GATEKEEPER_ACCEPTED' ||
|
|
codeReceipt?.sourceCommit !== pipelineReceipt.sourceCommit
|
|
) fail('HOLOLAKE_RELEASE_OPERATOR_CODESIGN_RECEIPT_INVALID')
|
|
if (
|
|
notarizationReceipt?.schema !== 'hololake.apple-notarization-receipt/v1' ||
|
|
notarizationReceipt?.state !== 'APPLE_NOTARIZATION_ACCEPTED_AND_STAPLED' ||
|
|
notarizationReceipt?.sourceCommit !== pipelineReceipt.sourceCommit
|
|
) fail('HOLOLAKE_RELEASE_OPERATOR_NOTARIZATION_RECEIPT_INVALID')
|
|
addArtifact(artifacts, packagePath, packageName, 'package')
|
|
addArtifact(artifacts, codeReceiptPath, codeReceiptName, 'control')
|
|
addArtifact(artifacts, notarizationReceiptPath, notarizationReceiptName, 'control')
|
|
}
|
|
|
|
return {
|
|
schema: 'hololake.release-broadcast-operator-plan/v1',
|
|
state: 'EXACT_SIGNED_NOTARIZED_HUMAN_APPROVED_BUNDLE_VERIFIED',
|
|
releaseId,
|
|
version,
|
|
sourceCommit: pipelineReceipt.sourceCommit,
|
|
broadcastSha256,
|
|
approvalId,
|
|
publicPrefix: normalizedPublicPrefix,
|
|
artifacts: [...artifacts.values()],
|
|
}
|
|
}
|
|
|
|
function copyFileNoFollow(source, destination, mode) {
|
|
const sourceDescriptor = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
let destinationDescriptor = null
|
|
try {
|
|
const sourceInfo = fs.fstatSync(sourceDescriptor)
|
|
if (!sourceInfo.isFile() || (sourceInfo.mode & 0o022) !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_SOURCE_CHANGED')
|
|
destinationDescriptor = fs.openSync(destination, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, mode)
|
|
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
|
let position = 0
|
|
while (position < sourceInfo.size) {
|
|
const read = fs.readSync(sourceDescriptor, buffer, 0, Math.min(buffer.length, sourceInfo.size - position), position)
|
|
if (read <= 0) fail('HOLOLAKE_RELEASE_OPERATOR_SOURCE_TRUNCATED')
|
|
let written = 0
|
|
while (written < read) written += fs.writeSync(destinationDescriptor, buffer, written, read - written)
|
|
position += read
|
|
}
|
|
fs.fsyncSync(destinationDescriptor)
|
|
} finally {
|
|
if (destinationDescriptor !== null) fs.closeSync(destinationDescriptor)
|
|
fs.closeSync(sourceDescriptor)
|
|
}
|
|
}
|
|
|
|
function writeJsonExclusive(file, value, mode) {
|
|
const descriptor = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, mode)
|
|
try {
|
|
fs.writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`)
|
|
fs.fsyncSync(descriptor)
|
|
} finally {
|
|
fs.closeSync(descriptor)
|
|
}
|
|
}
|
|
|
|
function activationFor(plan) {
|
|
return {
|
|
schema: 'hololake.release-broadcast-activation/v1',
|
|
state: 'HUMAN_APPROVED_SIGNED_NOTARIZED_RELEASE_ACTIVE',
|
|
releaseId: plan.releaseId,
|
|
version: plan.version,
|
|
broadcastRelativePath: `releases/${plan.version}/latest.json`,
|
|
broadcastSha256: plan.broadcastSha256,
|
|
pipelineReceiptRelativePath: `releases/${plan.version}/pipeline-receipt.json`,
|
|
humanApprovalReceipt: plan.approvalId,
|
|
humanApprovalReceiptRelativePath: `releases/${plan.version}/human-approval.json`,
|
|
}
|
|
}
|
|
|
|
function prepareCandidate(candidateRoot, plan, { enforceRootOwner, trustedGroupId }) {
|
|
const releases = path.join(candidateRoot, 'releases')
|
|
const releaseDirectory = path.join(releases, plan.version)
|
|
fs.mkdirSync(releaseDirectory, { recursive: true, mode: enforceRootOwner ? 0o750 : 0o700 })
|
|
for (const artifact of plan.artifacts) {
|
|
const destination = path.join(releaseDirectory, artifact.name)
|
|
copyFileNoFollow(artifact.source, destination, enforceRootOwner ? 0o640 : 0o600)
|
|
if (enforceRootOwner) fs.chownSync(destination, 0, trustedGroupId)
|
|
}
|
|
const activationPath = path.join(candidateRoot, 'ACTIVE.json')
|
|
writeJsonExclusive(activationPath, activationFor(plan), enforceRootOwner ? 0o640 : 0o600)
|
|
if (enforceRootOwner) {
|
|
for (const directory of [candidateRoot, releases, releaseDirectory]) {
|
|
fs.chownSync(directory, 0, trustedGroupId)
|
|
fs.chmodSync(directory, 0o750)
|
|
}
|
|
fs.chownSync(activationPath, 0, trustedGroupId)
|
|
}
|
|
const state = loadRuntimeState(candidateRoot, { enforceRootOwner, publicPrefix: plan.publicPrefix })
|
|
if (state.state !== 'READY_SIGNED_NOTARIZED_BROADCAST') {
|
|
fail(`HOLOLAKE_RELEASE_OPERATOR_CANDIDATE_INVALID:${state.reasonCode || state.state}`)
|
|
}
|
|
return { releaseDirectory, activationPath }
|
|
}
|
|
|
|
function requireExpected(plan, expected) {
|
|
for (const [field, code] of [
|
|
['releaseId', 'HOLOLAKE_RELEASE_OPERATOR_EXPECTED_RELEASE_ID_MISMATCH'],
|
|
['version', 'HOLOLAKE_RELEASE_OPERATOR_EXPECTED_VERSION_MISMATCH'],
|
|
['sourceCommit', 'HOLOLAKE_RELEASE_OPERATOR_EXPECTED_SOURCE_COMMIT_MISMATCH'],
|
|
['broadcastSha256', 'HOLOLAKE_RELEASE_OPERATOR_EXPECTED_BROADCAST_SHA256_MISMATCH'],
|
|
]) {
|
|
if (requireText(expected?.[field], `${code}_REQUIRED`) !== plan[field]) fail(code)
|
|
}
|
|
}
|
|
|
|
function replaceActivation(stateRoot, candidateActivation, previousBytes, enforceRootOwner, trustedGroupId, publicPrefix) {
|
|
const tempActivation = path.join(stateRoot, `.ACTIVE.${crypto.randomUUID()}.tmp`)
|
|
copyFileNoFollow(candidateActivation, tempActivation, enforceRootOwner ? 0o640 : 0o600)
|
|
if (enforceRootOwner) fs.chownSync(tempActivation, 0, trustedGroupId)
|
|
fs.renameSync(tempActivation, path.join(stateRoot, 'ACTIVE.json'))
|
|
const committed = loadRuntimeState(stateRoot, { enforceRootOwner, publicPrefix })
|
|
if (committed.state === 'READY_SIGNED_NOTARIZED_BROADCAST') return committed
|
|
const failedActive = path.join(stateRoot, 'ACTIVE.json')
|
|
if (previousBytes === null) {
|
|
fs.unlinkSync(failedActive)
|
|
} else {
|
|
const rollback = path.join(stateRoot, `.ACTIVE.rollback.${crypto.randomUUID()}.tmp`)
|
|
const descriptor = fs.openSync(rollback, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, enforceRootOwner ? 0o640 : 0o600)
|
|
try {
|
|
fs.writeFileSync(descriptor, previousBytes)
|
|
fs.fsyncSync(descriptor)
|
|
} finally {
|
|
fs.closeSync(descriptor)
|
|
}
|
|
if (enforceRootOwner) fs.chownSync(rollback, 0, trustedGroupId)
|
|
fs.renameSync(rollback, failedActive)
|
|
}
|
|
fail(`HOLOLAKE_RELEASE_OPERATOR_POST_COMMIT_INVALID:${committed.reasonCode || committed.state}`)
|
|
}
|
|
|
|
function acquireOperatorLock(stateRoot, enforceRootOwner, trustedGroupId) {
|
|
const lockDirectory = path.join(stateRoot, '.operator-lock')
|
|
try {
|
|
fs.mkdirSync(lockDirectory, { mode: enforceRootOwner ? 0o750 : 0o700 })
|
|
} catch (error) {
|
|
if (error?.code === 'EEXIST') fail('HOLOLAKE_RELEASE_OPERATOR_LOCKED')
|
|
throw error
|
|
}
|
|
try {
|
|
if (enforceRootOwner) fs.chownSync(lockDirectory, 0, trustedGroupId)
|
|
writeJsonExclusive(path.join(lockDirectory, 'owner.json'), {
|
|
schema: 'hololake.release-broadcast-operator-lock/v1',
|
|
pid: process.pid,
|
|
acquiredAt: new Date().toISOString(),
|
|
}, enforceRootOwner ? 0o640 : 0o600)
|
|
if (enforceRootOwner) fs.chownSync(path.join(lockDirectory, 'owner.json'), 0, trustedGroupId)
|
|
return lockDirectory
|
|
} catch (error) {
|
|
fs.rmSync(lockDirectory, { recursive: true, force: true })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export function verifyReleaseBundle(sourceDirectory, humanApprovalPath) {
|
|
const plan = inspectReleaseBundle(sourceDirectory, humanApprovalPath)
|
|
const candidateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-operator-verify-'))
|
|
try {
|
|
prepareCandidate(candidateRoot, plan, { enforceRootOwner: false, trustedGroupId: process.getgid?.() ?? 0 })
|
|
return { ...plan, artifacts: plan.artifacts.map(({ name, type }) => ({ name, type })) }
|
|
} finally {
|
|
fs.rmSync(candidateRoot, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
export function activateRelease({
|
|
sourceDirectory,
|
|
humanApprovalPath,
|
|
stateRoot,
|
|
expected,
|
|
enforceRootOwner = true,
|
|
publicPrefix = DEFAULT_PUBLIC_PREFIX,
|
|
}) {
|
|
if (enforceRootOwner && process.getuid?.() !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_ROOT_REQUIRED')
|
|
const root = path.resolve(stateRoot)
|
|
const rootInfo = requireTrustedDirectory(root, { enforceRootOwner })
|
|
const trustedGroupId = rootInfo.gid
|
|
const lockDirectory = acquireOperatorLock(root, enforceRootOwner, trustedGroupId)
|
|
let candidateRoot = null
|
|
try {
|
|
const plan = inspectReleaseBundle(sourceDirectory, humanApprovalPath, { enforceRootOwner, publicPrefix })
|
|
requireExpected(plan, expected)
|
|
candidateRoot = fs.mkdtempSync(path.join(root, '.candidate-'))
|
|
const candidate = prepareCandidate(candidateRoot, plan, { enforceRootOwner, trustedGroupId })
|
|
const releasesRoot = path.join(root, 'releases')
|
|
if (!fs.existsSync(releasesRoot)) fs.mkdirSync(releasesRoot, { mode: enforceRootOwner ? 0o750 : 0o700 })
|
|
requireTrustedDirectory(releasesRoot, { enforceRootOwner })
|
|
if (enforceRootOwner) {
|
|
fs.chownSync(releasesRoot, 0, trustedGroupId)
|
|
fs.chmodSync(releasesRoot, 0o750)
|
|
}
|
|
const finalRelease = path.join(releasesRoot, plan.version)
|
|
if (fs.existsSync(finalRelease)) fail('HOLOLAKE_RELEASE_OPERATOR_VERSION_ALREADY_STAGED')
|
|
const activePath = path.join(root, 'ACTIVE.json')
|
|
const previousBytes = fs.existsSync(activePath) ? readJson(activePath, { enforceRootOwner }).bytes : null
|
|
fs.renameSync(candidate.releaseDirectory, finalRelease)
|
|
let committed
|
|
try {
|
|
committed = replaceActivation(
|
|
root,
|
|
candidate.activationPath,
|
|
previousBytes,
|
|
enforceRootOwner,
|
|
trustedGroupId,
|
|
plan.publicPrefix,
|
|
)
|
|
} catch (error) {
|
|
fs.rmSync(finalRelease, { recursive: true, force: true })
|
|
throw error
|
|
}
|
|
return {
|
|
schema: 'hololake.release-broadcast-operator-receipt/v1',
|
|
state: 'ACTIVATED_EXPLICIT_SERVICE_RESTART_REQUIRED',
|
|
releaseId: plan.releaseId,
|
|
version: plan.version,
|
|
sourceCommit: plan.sourceCommit,
|
|
broadcastSha256: plan.broadcastSha256,
|
|
approvalId: plan.approvalId,
|
|
publicPrefix: plan.publicPrefix,
|
|
automaticUpload: false,
|
|
automaticActivation: false,
|
|
automaticRestart: false,
|
|
runtimeStateAfterDiskCommit: committed.state,
|
|
}
|
|
} finally {
|
|
if (candidateRoot) fs.rmSync(candidateRoot, { recursive: true, force: true })
|
|
fs.rmSync(lockDirectory, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
const command = argv[0]
|
|
const values = {}
|
|
for (let index = 1; index < argv.length; index += 1) {
|
|
const key = argv[index]
|
|
if (!key.startsWith('--') || index + 1 >= argv.length) fail('HOLOLAKE_RELEASE_OPERATOR_ARGUMENT_INVALID')
|
|
values[key.slice(2)] = argv[++index]
|
|
}
|
|
return { command, values }
|
|
}
|
|
|
|
export function main(argv = process.argv.slice(2)) {
|
|
const { command, values } = parseArguments(argv)
|
|
const sourceDirectory = requireText(values.source, 'HOLOLAKE_RELEASE_OPERATOR_SOURCE_REQUIRED')
|
|
const humanApprovalPath = requireText(values['human-approval'], 'HOLOLAKE_RELEASE_OPERATOR_HUMAN_APPROVAL_PATH_REQUIRED')
|
|
if (command === 'verify') {
|
|
process.stdout.write(`${JSON.stringify(verifyReleaseBundle(sourceDirectory, humanApprovalPath), null, 2)}\n`)
|
|
return
|
|
}
|
|
if (command !== 'activate') fail('HOLOLAKE_RELEASE_OPERATOR_COMMAND_INVALID:verify|activate')
|
|
const receipt = activateRelease({
|
|
sourceDirectory,
|
|
humanApprovalPath,
|
|
stateRoot: requireText(values['state-root'], 'HOLOLAKE_RELEASE_OPERATOR_STATE_ROOT_REQUIRED'),
|
|
publicPrefix: values['public-prefix'] || DEFAULT_PUBLIC_PREFIX,
|
|
expected: {
|
|
releaseId: values['expect-release-id'],
|
|
version: values['expect-version'],
|
|
sourceCommit: values['expect-source-commit'],
|
|
broadcastSha256: values['expect-broadcast-sha256'],
|
|
},
|
|
})
|
|
process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`)
|
|
}
|
|
|
|
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
try {
|
|
main()
|
|
} catch (error) {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
process.exitCode = 1
|
|
}
|
|
}
|