mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,785 @@
|
||||
// Local lifecycle owns reviewed legacy adoption ceremonies.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
inspectLegacySqlitePath,
|
||||
publishReviewedLegacyCrontabAdoption,
|
||||
type CommitReviewedLegacyCrontabAdoptionOptions,
|
||||
} from '@qinglong/local-admin';
|
||||
import {
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
LegacyCrontabDecisionIssuerKeyringFileProvider,
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile,
|
||||
} from '@qinglong/local-admin/decision-issuer';
|
||||
import { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
import { openLocalSqliteBootstrapDatabase } from '@qinglong/local-sqlite/bootstrap';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const UUID_V7_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const LOCAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
interface PathIdentity {
|
||||
readonly path: string;
|
||||
readonly kind: 'directory' | 'file';
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly size: bigint;
|
||||
readonly modifiedAtNs: bigint;
|
||||
readonly changedAtNs: bigint;
|
||||
readonly uid: number;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
interface DeploymentProofOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly filePaths: readonly string[];
|
||||
readonly mutableFilePaths?: readonly string[];
|
||||
readonly directoryPaths: readonly string[];
|
||||
readonly missingPaths?: readonly string[];
|
||||
}
|
||||
|
||||
export interface IssueLegacyCrontabAdoptionDecisionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'legacy-crontab.decision.issue';
|
||||
readonly options: {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly issuerKeyringPath: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly sourcePath: string;
|
||||
readonly reviewFilePath: string;
|
||||
readonly authorizationPath: string;
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly decisionId: string;
|
||||
readonly legacyTimezone?: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
readonly lifetimeMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommitLegacyCrontabAdoptionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'legacy-crontab.adoption.commit';
|
||||
readonly options: {
|
||||
readonly deploymentRoot: string;
|
||||
readonly targetPath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly issuerKeyringPath: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly sourcePath: string;
|
||||
readonly authorizationPath: string;
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly expectedDecisionId: string;
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly legacyTimezone?: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueLegacyCrontabAdoptionDecisionCommandResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'legacy-crontab.decision.issue';
|
||||
readonly review: {
|
||||
readonly decisionCount: number;
|
||||
readonly fileBytes: number;
|
||||
readonly fileDigest: string;
|
||||
};
|
||||
readonly authorization: {
|
||||
readonly decisionId: string;
|
||||
readonly decisionCount: number;
|
||||
readonly fileBytes: number;
|
||||
readonly fileDigest: string;
|
||||
readonly keyId: string;
|
||||
};
|
||||
readonly receipt: {
|
||||
readonly reviewerSubjectId: string;
|
||||
readonly issuedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly decisionDigest: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommitLegacyCrontabAdoptionCommandResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'legacy-crontab.adoption.commit';
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly adoption: {
|
||||
readonly mutationId: string;
|
||||
readonly decisionId: string;
|
||||
readonly projectId: string;
|
||||
readonly publicationDigest: string;
|
||||
readonly adoptedTaskCount: number;
|
||||
readonly adoptedTriggerCount: number;
|
||||
readonly skippedCount: number;
|
||||
readonly auditEventId: string;
|
||||
readonly createdAtMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type LegacyCrontabAdoptionCommand =
|
||||
| IssueLegacyCrontabAdoptionDecisionCommand
|
||||
| CommitLegacyCrontabAdoptionCommand;
|
||||
|
||||
export type LegacyCrontabAdoptionCommandResult =
|
||||
| IssueLegacyCrontabAdoptionDecisionCommandResult
|
||||
| CommitLegacyCrontabAdoptionCommandResult;
|
||||
|
||||
export class LegacyCrontabAdoptionCliConfigurationError extends TypeError {
|
||||
readonly code = 'LEGACY_CRONTAB_ADOPTION_CLI_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Legacy Crontab adoption CLI configuration is invalid: ${message}`);
|
||||
this.name = 'LegacyCrontabAdoptionCliConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacyCrontabAdoptionCliAuthenticationError extends Error {
|
||||
readonly code = 'LEGACY_CRONTAB_ADOPTION_CLI_AUTHENTICATION_FAILED';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Legacy Crontab adoption CLI authentication failed: ${message}`);
|
||||
this.name = 'LegacyCrontabAdoptionCliAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function identity(
|
||||
targetPath: string,
|
||||
kind: PathIdentity['kind'],
|
||||
uid: number,
|
||||
): PathIdentity {
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(targetPath, { bigint: true });
|
||||
} catch (error) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
`${kind} is unavailable`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
const expectedKind =
|
||||
kind === 'directory' ? stat.isDirectory() : stat.isFile();
|
||||
const mode = Number(stat.mode) & 0o777;
|
||||
if (
|
||||
!expectedKind ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
mode !== (kind === 'directory' ? 0o700 : 0o600)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
`${kind} ownership or private mode is invalid`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
path: targetPath,
|
||||
kind,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
size: stat.size,
|
||||
modifiedAtNs: stat.mtimeNs,
|
||||
changedAtNs: stat.ctimeNs,
|
||||
uid,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
function sameIdentity(expected: PathIdentity, mutableFile: boolean): void {
|
||||
const actual = identity(expected.path, expected.kind, expected.uid);
|
||||
if (
|
||||
actual.device !== expected.device ||
|
||||
actual.inode !== expected.inode ||
|
||||
actual.mode !== expected.mode ||
|
||||
(expected.kind === 'file' &&
|
||||
!mutableFile &&
|
||||
(actual.size !== expected.size ||
|
||||
actual.modifiedAtNs !== expected.modifiedAtNs ||
|
||||
actual.changedAtNs !== expected.changedAtNs))
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
`${expected.kind} identity changed during command execution`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function descendants(deploymentRoot: string, targetPath: string): string[] {
|
||||
const relative = path.relative(deploymentRoot, targetPath);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'authority paths must be descendants of deploymentRoot',
|
||||
);
|
||||
}
|
||||
const directories: string[] = [];
|
||||
let current = deploymentRoot;
|
||||
for (const part of relative.split(path.sep).slice(0, -1)) {
|
||||
current = path.join(current, part);
|
||||
directories.push(current);
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
function assertMissing(targetPath: string): void {
|
||||
try {
|
||||
fs.lstatSync(targetPath);
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'authorization destination cannot be inspected',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'authorization destination must not exist',
|
||||
);
|
||||
}
|
||||
|
||||
function optionalRuntimeOptions(options: Record<string, unknown>): string[] {
|
||||
return [
|
||||
...(options.busyTimeoutMs === undefined ? [] : ['busyTimeoutMs']),
|
||||
...(options.legacyTimezone === undefined ? [] : ['legacyTimezone']),
|
||||
];
|
||||
}
|
||||
|
||||
function validRuntimeOptions(options: Record<string, unknown>): boolean {
|
||||
return !(
|
||||
(options.legacyTimezone !== undefined &&
|
||||
(typeof options.legacyTimezone !== 'string' ||
|
||||
options.legacyTimezone.length < 1 ||
|
||||
options.legacyTimezone.length > 128)) ||
|
||||
(options.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.busyTimeoutMs) ||
|
||||
(options.busyTimeoutMs as number) < 1 ||
|
||||
(options.busyTimeoutMs as number) > 60_000))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LegacyCrontabAdoptionCommand> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['schemaVersion', 'operation', 'options'])
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'command shape is invalid',
|
||||
);
|
||||
}
|
||||
const command = value as Record<string, unknown>;
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
(command.operation !== 'legacy-crontab.decision.issue' &&
|
||||
command.operation !== 'legacy-crontab.adoption.commit') ||
|
||||
!command.options ||
|
||||
typeof command.options !== 'object' ||
|
||||
Array.isArray(command.options)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'command value is invalid',
|
||||
);
|
||||
}
|
||||
const options = command.options as Record<string, unknown>;
|
||||
const issue = command.operation === 'legacy-crontab.decision.issue';
|
||||
const expected = issue
|
||||
? [
|
||||
'authorizationPath',
|
||||
...optionalRuntimeOptions(options),
|
||||
'credentialFilePath',
|
||||
'databasePath',
|
||||
'decisionId',
|
||||
'deploymentRoot',
|
||||
'expectedPlanDigest',
|
||||
'issuerKeyringPath',
|
||||
...(options.lifetimeMs === undefined ? [] : ['lifetimeMs']),
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
'reviewFilePath',
|
||||
'sourcePath',
|
||||
]
|
||||
: [
|
||||
'authorizationPath',
|
||||
...optionalRuntimeOptions(options),
|
||||
'credentialFilePath',
|
||||
'deploymentRoot',
|
||||
'expectedDecisionId',
|
||||
'expectedPlanDigest',
|
||||
'issuerKeyringPath',
|
||||
'mutationId',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'sourcePath',
|
||||
'targetPath',
|
||||
];
|
||||
if (
|
||||
!exactKeys(options, expected) ||
|
||||
(options.profile !== 'edge' && options.profile !== 'standalone') ||
|
||||
typeof options.expectedPlanDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(options.expectedPlanDigest) ||
|
||||
!validRuntimeOptions(options) ||
|
||||
(issue &&
|
||||
(typeof options.decisionId !== 'string' ||
|
||||
!UUID_V7_PATTERN.test(options.decisionId))) ||
|
||||
(issue &&
|
||||
options.lifetimeMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.lifetimeMs) ||
|
||||
(options.lifetimeMs as number) < 1_000 ||
|
||||
(options.lifetimeMs as number) > 30 * 60 * 1_000)) ||
|
||||
(!issue &&
|
||||
(typeof options.expectedDecisionId !== 'string' ||
|
||||
!UUID_V7_PATTERN.test(options.expectedDecisionId) ||
|
||||
typeof options.mutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(options.mutationId) ||
|
||||
typeof options.projectId !== 'string' ||
|
||||
!LOCAL_ID_PATTERN.test(options.projectId) ||
|
||||
typeof options.requestId !== 'string' ||
|
||||
!LOCAL_ID_PATTERN.test(options.requestId)))
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'command options are invalid',
|
||||
);
|
||||
}
|
||||
const pathKeys = [
|
||||
'authorizationPath',
|
||||
'credentialFilePath',
|
||||
'deploymentRoot',
|
||||
'issuerKeyringPath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'sourcePath',
|
||||
...(issue ? ['databasePath', 'reviewFilePath'] : ['targetPath']),
|
||||
];
|
||||
for (const key of pathKeys) {
|
||||
boundedPath(options[key], key);
|
||||
}
|
||||
return Object.freeze(value as LegacyCrontabAdoptionCommand);
|
||||
}
|
||||
|
||||
function readCommandFile(
|
||||
commandFilePath: string,
|
||||
): Readonly<LegacyCrontabAdoptionCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(commandFilePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LegacyCrontabAdoptionCliConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function deploymentProof(options: DeploymentProofOptions): {
|
||||
verify(): void;
|
||||
} {
|
||||
const uid = currentUid();
|
||||
const rootPath = boundedPath(options.deploymentRoot, 'deploymentRoot');
|
||||
const root = identity(rootPath, 'directory', uid);
|
||||
const filePaths = options.filePaths.map((candidate) =>
|
||||
boundedPath(candidate, 'authority file'),
|
||||
);
|
||||
const directoryPaths = options.directoryPaths.map((candidate) =>
|
||||
boundedPath(candidate, 'authority directory'),
|
||||
);
|
||||
const missingPaths = (options.missingPaths ?? []).map((candidate) =>
|
||||
boundedPath(candidate, 'missing authority path'),
|
||||
);
|
||||
const mutableFilePaths = new Set(
|
||||
(options.mutableFilePaths ?? []).map((candidate) =>
|
||||
boundedPath(candidate, 'mutable authority file'),
|
||||
),
|
||||
);
|
||||
if (
|
||||
[...mutableFilePaths].some((candidate) => !filePaths.includes(candidate))
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'mutable authority files must be declared files',
|
||||
);
|
||||
}
|
||||
const nestedDirectories = new Set<string>();
|
||||
for (const target of [...filePaths, ...directoryPaths]) {
|
||||
for (const directory of descendants(rootPath, target)) {
|
||||
nestedDirectories.add(directory);
|
||||
}
|
||||
}
|
||||
const directories = [
|
||||
root,
|
||||
...[...nestedDirectories]
|
||||
.filter((candidate) => candidate !== rootPath)
|
||||
.map((candidate) => identity(candidate, 'directory', uid)),
|
||||
...directoryPaths
|
||||
.filter(
|
||||
(candidate, index) =>
|
||||
directoryPaths.indexOf(candidate) === index &&
|
||||
candidate !== rootPath &&
|
||||
!nestedDirectories.has(candidate),
|
||||
)
|
||||
.map((candidate) => identity(candidate, 'directory', uid)),
|
||||
];
|
||||
const files = filePaths.map((candidate) => identity(candidate, 'file', uid));
|
||||
const uniqueFiles = new Set(
|
||||
files.map((entry) => `${entry.device}:${entry.inode}`),
|
||||
);
|
||||
if (uniqueFiles.size !== files.length) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'authority files must not share an inode',
|
||||
);
|
||||
}
|
||||
for (const missingPath of missingPaths) assertMissing(missingPath);
|
||||
if (!files.some((entry) => entry.path === options.credentialFilePath)) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'credential file must be part of the deployment proof',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
verify() {
|
||||
if (currentUid() !== uid) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'POSIX user changed during command execution',
|
||||
);
|
||||
}
|
||||
for (const expected of [...directories, ...files]) {
|
||||
sameIdentity(expected, mutableFilePaths.has(expected.path));
|
||||
}
|
||||
for (const missingPath of missingPaths) assertMissing(missingPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type LocalBootstrapDatabase = Awaited<
|
||||
ReturnType<typeof openLocalSqliteBootstrapDatabase>
|
||||
>;
|
||||
type CommitReviewer = Parameters<
|
||||
NonNullable<
|
||||
CommitReviewedLegacyCrontabAdoptionOptions['confirmReviewerAuthority']
|
||||
>
|
||||
>[0];
|
||||
|
||||
async function createReviewerAuthority(
|
||||
database: LocalBootstrapDatabase,
|
||||
deploymentRoot: string,
|
||||
databasePath: string,
|
||||
ownerPepperKeyringDirectory: string,
|
||||
credentialFilePath: string,
|
||||
proof: ReturnType<typeof deploymentProof>,
|
||||
): Promise<{
|
||||
readonly reviewer: Readonly<CommitReviewer>;
|
||||
readonly confirm: () => Promise<void>;
|
||||
}> {
|
||||
proof.verify();
|
||||
let authenticated: Awaited<
|
||||
ReturnType<typeof establishAuthenticatedLocalCommand>
|
||||
>;
|
||||
try {
|
||||
authenticated = await establishAuthenticatedLocalCommand(database, {
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
authenticationNamespace: 'local_adoption',
|
||||
});
|
||||
} catch {
|
||||
throw new LegacyCrontabAdoptionCliAuthenticationError(
|
||||
'credential authority is unavailable',
|
||||
);
|
||||
}
|
||||
const reviewer: Readonly<CommitReviewer> = authenticated.principal;
|
||||
return Object.freeze({
|
||||
reviewer,
|
||||
async confirm() {
|
||||
proof.verify();
|
||||
try {
|
||||
await authenticated.confirm();
|
||||
} catch {
|
||||
throw new LegacyCrontabAdoptionCliAuthenticationError(
|
||||
'credential authority changed during adoption',
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function issueDecision(
|
||||
command: Readonly<IssueLegacyCrontabAdoptionDecisionCommand>,
|
||||
): Promise<Readonly<IssueLegacyCrontabAdoptionDecisionCommandResult>> {
|
||||
const options = command.options;
|
||||
const proof = deploymentProof({
|
||||
deploymentRoot: options.deploymentRoot,
|
||||
credentialFilePath: options.credentialFilePath,
|
||||
filePaths: [
|
||||
options.databasePath,
|
||||
options.issuerKeyringPath,
|
||||
options.credentialFilePath,
|
||||
options.sourcePath,
|
||||
options.reviewFilePath,
|
||||
],
|
||||
directoryPaths: [
|
||||
options.ownerPepperKeyringDirectory,
|
||||
path.dirname(options.authorizationPath),
|
||||
],
|
||||
missingPaths: [options.authorizationPath],
|
||||
});
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
});
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'source no longer matches expectedPlanDigest',
|
||||
);
|
||||
}
|
||||
return withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
{
|
||||
filePath: options.reviewFilePath,
|
||||
expectedDecisionId: options.decisionId,
|
||||
expectedProfile: options.profile,
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
expectedInventoryDigest: plan.tasks.inventoryDigest,
|
||||
},
|
||||
async (review) => {
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||
});
|
||||
try {
|
||||
const authority = await createReviewerAuthority(
|
||||
database,
|
||||
options.deploymentRoot,
|
||||
options.databasePath,
|
||||
options.ownerPepperKeyringDirectory,
|
||||
options.credentialFilePath,
|
||||
proof,
|
||||
);
|
||||
const issued =
|
||||
await issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
decisionId: options.decisionId,
|
||||
authorizationPath: options.authorizationPath,
|
||||
issuerKeyringPath: options.issuerKeyringPath,
|
||||
decisions: review.decisions,
|
||||
authenticateReviewer: () => authority.reviewer,
|
||||
confirmIssuerAuthority: authority.confirm,
|
||||
confirmDecisionStreamAuthority: review.confirmIdentity,
|
||||
...(options.lifetimeMs === undefined
|
||||
? {}
|
||||
: { lifetimeMs: options.lifetimeMs }),
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'legacy-crontab.decision.issue',
|
||||
review: Object.freeze({
|
||||
decisionCount: review.evidence.decisionCount,
|
||||
fileBytes: review.evidence.fileBytes,
|
||||
fileDigest: review.evidence.fileDigest,
|
||||
}),
|
||||
authorization: Object.freeze({
|
||||
decisionId: issued.file.decisionId,
|
||||
decisionCount: issued.file.decisionCount,
|
||||
fileBytes: issued.file.fileBytes,
|
||||
fileDigest: issued.file.fileDigest,
|
||||
keyId: issued.file.keyId,
|
||||
}),
|
||||
receipt: Object.freeze({
|
||||
reviewerSubjectId: issued.receipt.reviewer.subject.id,
|
||||
issuedAtMs: issued.receipt.issuedAtMs,
|
||||
expiresAtMs: issued.receipt.expiresAtMs,
|
||||
decisionDigest: issued.receipt.decisions.decisionDigest,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function commitAdoption(
|
||||
command: Readonly<CommitLegacyCrontabAdoptionCommand>,
|
||||
): Promise<Readonly<CommitLegacyCrontabAdoptionCommandResult>> {
|
||||
const options = command.options;
|
||||
const proof = deploymentProof({
|
||||
deploymentRoot: options.deploymentRoot,
|
||||
credentialFilePath: options.credentialFilePath,
|
||||
filePaths: [
|
||||
options.targetPath,
|
||||
options.issuerKeyringPath,
|
||||
options.credentialFilePath,
|
||||
options.sourcePath,
|
||||
options.authorizationPath,
|
||||
],
|
||||
mutableFilePaths: [options.targetPath],
|
||||
directoryPaths: [options.ownerPepperKeyringDirectory],
|
||||
});
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
});
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'source no longer matches expectedPlanDigest',
|
||||
);
|
||||
}
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.targetPath,
|
||||
profile: options.profile,
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||
});
|
||||
try {
|
||||
const authority = await createReviewerAuthority(
|
||||
database,
|
||||
options.deploymentRoot,
|
||||
options.targetPath,
|
||||
options.ownerPepperKeyringDirectory,
|
||||
options.credentialFilePath,
|
||||
proof,
|
||||
);
|
||||
const confirmReviewerAuthority = async (
|
||||
reviewer: Readonly<CommitReviewer>,
|
||||
): Promise<void> => {
|
||||
await authority.confirm();
|
||||
if (
|
||||
reviewer.subject.type !== authority.reviewer.subject.type ||
|
||||
reviewer.subject.id !== authority.reviewer.subject.id ||
|
||||
reviewer.authenticationId !== authority.reviewer.authenticationId ||
|
||||
reviewer.assurance !== authority.reviewer.assurance
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliAuthenticationError(
|
||||
'current operator does not match the signed reviewer',
|
||||
);
|
||||
}
|
||||
};
|
||||
const published = await publishReviewedLegacyCrontabAdoption({
|
||||
sourcePath: options.sourcePath,
|
||||
targetPath: options.targetPath,
|
||||
authorizationPath: options.authorizationPath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
expectedDecisionId: options.expectedDecisionId,
|
||||
projectId: options.projectId,
|
||||
mutationId: options.mutationId,
|
||||
requestId: options.requestId,
|
||||
keyProvider: new LegacyCrontabDecisionIssuerKeyringFileProvider(
|
||||
options.issuerKeyringPath,
|
||||
),
|
||||
observedAtMs: Date.now(),
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||
confirmReviewerAuthority,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'legacy-crontab.adoption.commit',
|
||||
status: published.status,
|
||||
adoption: Object.freeze({
|
||||
mutationId: published.adoption.mutationId,
|
||||
decisionId: published.adoption.decisionId,
|
||||
projectId: published.adoption.projectId,
|
||||
publicationDigest: published.adoption.publicationDigest,
|
||||
adoptedTaskCount: published.adoption.adoptedTaskCount,
|
||||
adoptedTriggerCount: published.adoption.adoptedTriggerCount,
|
||||
skippedCount: published.adoption.skippedCount,
|
||||
auditEventId: published.adoption.auditEventId,
|
||||
createdAtMs: published.adoption.createdAtMs,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLegacyCrontabAdoptionCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LegacyCrontabAdoptionCommandResult>> {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
return command.operation === 'legacy-crontab.decision.issue'
|
||||
? issueDecision(command)
|
||||
: commitAdoption(command);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Keep the one-shot adoption binary beside its lifecycle command.
|
||||
import { runLegacyCrontabAdoptionCommandFile } from './adoption';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-adoption run --command-file /absolute/private-command.json';
|
||||
|
||||
function publicErrorCode(error: unknown): string {
|
||||
let current = error;
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
if (!current || typeof current !== 'object') break;
|
||||
const candidate = current as { readonly code?: unknown; cause?: unknown };
|
||||
if (
|
||||
candidate.code === 'LEGACY_CRONTAB_ADOPTION_CLI_AUTHENTICATION_FAILED'
|
||||
) {
|
||||
return candidate.code;
|
||||
}
|
||||
current = candidate.cause;
|
||||
}
|
||||
const candidate = error as { readonly code?: unknown };
|
||||
return typeof candidate?.code === 'string'
|
||||
? candidate.code
|
||||
: 'LEGACY_CRONTAB_ADOPTION_CLI_FAILED';
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 3 || argv[0] !== 'run' || argv[1] !== '--command-file') {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LEGACY_CRONTAB_ADOPTION_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLegacyCrontabAdoptionCommandFile(argv[2]!);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: publicErrorCode(error),
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Legacy Crontab adoption command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,213 @@
|
||||
// Local lifecycle owns short-lived readiness inspection.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
inspectLocalSqliteReadinessPath,
|
||||
type LocalSqliteDatabaseOptions,
|
||||
type LocalSqliteReadinessEvidence,
|
||||
} from '@qinglong/local-sqlite/readiness-inspection';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface LocalReadinessResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.readiness.inspect';
|
||||
readonly status: 'ready';
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly storage: Readonly<{
|
||||
contractName: string;
|
||||
contractVersion: number;
|
||||
migrationCount: number;
|
||||
tableCount: number;
|
||||
sqliteVersion: string;
|
||||
journalMode: 'delete' | 'wal';
|
||||
}>;
|
||||
}
|
||||
|
||||
export class LocalReadinessConfigurationError extends TypeError {
|
||||
readonly code = 'QL3_LOCAL_READINESS_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Local readiness configuration is invalid: ${message}`, options);
|
||||
this.name = 'LocalReadinessConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalReadinessIncompatibleError extends Error {
|
||||
readonly code = 'QL3_LOCAL_READINESS_INCOMPATIBLE';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Local readiness is incompatible: ${message}`, options);
|
||||
this.name = 'LocalReadinessIncompatibleError';
|
||||
}
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function normalizedDatabasePath(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'databasePath must be a normalized bounded absolute non-root path',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function inspectPrivateDatabaseFile(databasePath: string): void {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(databasePath);
|
||||
} catch (error) {
|
||||
throw new LocalReadinessConfigurationError('database is unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== currentUid() ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
fs.realpathSync(databasePath) !== databasePath
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'database must be a canonical current-UID mode-0600 regular file',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLocalReadinessOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalSqliteDatabaseOptions> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalReadinessConfigurationError('options must be an object');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const expectedKeys = [
|
||||
'databasePath',
|
||||
'profile',
|
||||
...(Object.hasOwn(candidate, 'busyTimeoutMs') ? ['busyTimeoutMs'] : []),
|
||||
].sort();
|
||||
const actualKeys = Object.keys(candidate).sort();
|
||||
if (
|
||||
actualKeys.length !== expectedKeys.length ||
|
||||
actualKeys.some((key, index) => key !== expectedKeys[index])
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError('options shape is invalid');
|
||||
}
|
||||
if (candidate.profile !== 'edge' && candidate.profile !== 'standalone') {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
const busyTimeoutMs = candidate.busyTimeoutMs;
|
||||
if (
|
||||
busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(busyTimeoutMs) ||
|
||||
(busyTimeoutMs as number) < 100 ||
|
||||
(busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'busyTimeoutMs must be between 100 and 30000',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
databasePath: normalizedDatabasePath(candidate.databasePath),
|
||||
profile: candidate.profile,
|
||||
...(busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: busyTimeoutMs as number }),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseLocalReadinessArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<LocalSqliteDatabaseOptions> {
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
if (argument === '--') continue;
|
||||
const match = /^--(database|profile|busy-timeout-ms)=(.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'arguments must contain one --database and one --profile, with optional --busy-timeout-ms',
|
||||
);
|
||||
}
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
values.size < 2 ||
|
||||
values.size > 3 ||
|
||||
!values.has('database') ||
|
||||
!values.has('profile')
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'arguments must contain one --database and one --profile, with optional --busy-timeout-ms',
|
||||
);
|
||||
}
|
||||
const rawTimeout = values.get('busy-timeout-ms');
|
||||
return normalizeLocalReadinessOptions({
|
||||
databasePath: values.get('database'),
|
||||
profile: values.get('profile'),
|
||||
...(rawTimeout === undefined ? {} : { busyTimeoutMs: Number(rawTimeout) }),
|
||||
});
|
||||
}
|
||||
|
||||
function resultFromEvidence(
|
||||
profile: 'edge' | 'standalone',
|
||||
evidence: LocalSqliteReadinessEvidence,
|
||||
): Readonly<LocalReadinessResult> {
|
||||
const expectedJournalMode = profile === 'edge' ? 'delete' : 'wal';
|
||||
if (evidence.journalMode !== expectedJournalMode) {
|
||||
throw new LocalReadinessIncompatibleError(
|
||||
`${profile} requires ${expectedJournalMode} journal mode`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.readiness.inspect' as const,
|
||||
status: 'ready' as const,
|
||||
profile,
|
||||
storage: Object.freeze({
|
||||
contractName: evidence.contractName,
|
||||
contractVersion: evidence.contractVersion,
|
||||
migrationCount: evidence.migrationIds.length,
|
||||
tableCount: evidence.tableCount,
|
||||
sqliteVersion: evidence.sqliteVersion,
|
||||
journalMode: expectedJournalMode,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function inspectLocalReadiness(
|
||||
value: unknown,
|
||||
): Promise<Readonly<LocalReadinessResult>> {
|
||||
const options = normalizeLocalReadinessOptions(value);
|
||||
inspectPrivateDatabaseFile(options.databasePath);
|
||||
let evidence: LocalSqliteReadinessEvidence;
|
||||
try {
|
||||
evidence = await inspectLocalSqliteReadinessPath(options);
|
||||
} catch (error) {
|
||||
throw new LocalReadinessIncompatibleError('storage audit failed', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return resultFromEvidence(options.profile, evidence);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Keep the readiness binary beside its lifecycle inspection.
|
||||
import {
|
||||
inspectLocalReadiness,
|
||||
parseLocalReadinessArguments,
|
||||
} from './localReadiness';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-readiness --database=/absolute/qinglong3.sqlite --profile=<edge|standalone> [--busy-timeout-ms=5000]';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
await inspectLocalReadiness(parseLocalReadinessArguments(argv)),
|
||||
)}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'QL3_LOCAL_READINESS_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,491 @@
|
||||
// Local lifecycle owns first-run storage and key-material setup.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
import {
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
backupLocalOwnerPepperKey,
|
||||
provisionLocalOwnerPepperKey,
|
||||
} from '@qinglong/local-owner-console/pepper-custody';
|
||||
import {
|
||||
LocalSecretKeyringFileProvider,
|
||||
provisionLocalSecretKeyring,
|
||||
} from '@qinglong/local-secret';
|
||||
import { openLocalSqliteBootstrapDatabase } from '@qinglong/local-sqlite/bootstrap';
|
||||
import { migrateLocalSqlitePath } from '@qinglong/local-sqlite/migration';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface LocalSetupCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.setup.prepare';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
databasePath: string;
|
||||
profile: 'edge' | 'standalone';
|
||||
ownerPepperKeyringDirectory: string;
|
||||
ownerPepperBackupDirectory: string;
|
||||
ownerPepperKeyId: string;
|
||||
localSecretKeyringPath: string;
|
||||
busyTimeoutMs?: number;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
registerMutationId: string;
|
||||
activateMutationId: string;
|
||||
registeredAtMs: number;
|
||||
activatedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalSetupResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly storage: Readonly<{
|
||||
contractName: string;
|
||||
contractVersion: number;
|
||||
migrationCount: number;
|
||||
}>;
|
||||
readonly ownerPepper: Readonly<{
|
||||
registerStatus: 'inserted' | 'existing';
|
||||
activateStatus: 'inserted' | 'existing';
|
||||
generation: number;
|
||||
}>;
|
||||
readonly envelopeKeyring: Readonly<{
|
||||
version: 1;
|
||||
keyCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class LocalSetupConfigurationError extends TypeError {
|
||||
readonly code = 'QL3_LOCAL_SETUP_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Local setup configuration is invalid: ${message}`, options);
|
||||
this.name = 'LocalSetupConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function absolute(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function privateDirectory(value: unknown, label: string): string {
|
||||
const directory = absolute(value, label);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory);
|
||||
} catch (error) {
|
||||
throw new LocalSetupConfigurationError(`${label} is unavailable`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== currentUid() ||
|
||||
(stat.mode & 0o777) !== 0o700 ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
`${label} must be a canonical current-UID 0700 directory`,
|
||||
);
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
function child(root: string, value: unknown, label: string): string {
|
||||
const candidate = absolute(value, label);
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative === '' ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
`${label} must be a distinct child of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function safeInteger(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function normalizeLocalSetupCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalSetupCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(command, ['operation', 'options', 'request', 'schemaVersion'], 'command');
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !== 'local.setup.prepare'
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const rawOptions = object(command.options, 'options');
|
||||
const optionalKeys = Object.hasOwn(rawOptions, 'busyTimeoutMs')
|
||||
? ['busyTimeoutMs']
|
||||
: [];
|
||||
exact(
|
||||
rawOptions,
|
||||
[
|
||||
'databasePath',
|
||||
'deploymentRoot',
|
||||
'localSecretKeyringPath',
|
||||
'ownerPepperBackupDirectory',
|
||||
'ownerPepperKeyId',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
...optionalKeys,
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = privateDirectory(
|
||||
rawOptions.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
);
|
||||
const databasePath = child(
|
||||
deploymentRoot,
|
||||
rawOptions.databasePath,
|
||||
'databasePath',
|
||||
);
|
||||
const localSecretKeyringPath = child(
|
||||
deploymentRoot,
|
||||
rawOptions.localSecretKeyringPath,
|
||||
'localSecretKeyringPath',
|
||||
);
|
||||
const ownerPepperKeyringDirectory = privateDirectory(
|
||||
child(
|
||||
deploymentRoot,
|
||||
rawOptions.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
),
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const ownerPepperBackupDirectory = privateDirectory(
|
||||
child(
|
||||
deploymentRoot,
|
||||
rawOptions.ownerPepperBackupDirectory,
|
||||
'ownerPepperBackupDirectory',
|
||||
),
|
||||
'ownerPepperBackupDirectory',
|
||||
);
|
||||
if (
|
||||
new Set([
|
||||
databasePath,
|
||||
localSecretKeyringPath,
|
||||
ownerPepperKeyringDirectory,
|
||||
ownerPepperBackupDirectory,
|
||||
]).size !== 4
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'setup authority paths must be distinct',
|
||||
);
|
||||
}
|
||||
if (
|
||||
rawOptions.profile !== 'edge' &&
|
||||
rawOptions.profile !== 'standalone'
|
||||
) {
|
||||
throw new LocalSetupConfigurationError('profile is invalid');
|
||||
}
|
||||
if (
|
||||
typeof rawOptions.ownerPepperKeyId !== 'string' ||
|
||||
rawOptions.ownerPepperKeyId.length < 1 ||
|
||||
rawOptions.ownerPepperKeyId.length > 128
|
||||
) {
|
||||
throw new LocalSetupConfigurationError('ownerPepperKeyId is invalid');
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'activateMutationId',
|
||||
'activatedAtMs',
|
||||
'registerMutationId',
|
||||
'registeredAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
typeof request.registerMutationId !== 'string' ||
|
||||
typeof request.activateMutationId !== 'string'
|
||||
) {
|
||||
throw new LocalSetupConfigurationError('mutation identity is invalid');
|
||||
}
|
||||
const registeredAtMs = safeInteger(
|
||||
request.registeredAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'registeredAtMs',
|
||||
);
|
||||
const activatedAtMs = safeInteger(
|
||||
request.activatedAtMs,
|
||||
registeredAtMs,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'activatedAtMs',
|
||||
);
|
||||
const busyTimeoutMs =
|
||||
rawOptions.busyTimeoutMs === undefined
|
||||
? undefined
|
||||
: safeInteger(rawOptions.busyTimeoutMs, 100, 30_000, 'busyTimeoutMs');
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.setup.prepare' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
profile: rawOptions.profile,
|
||||
ownerPepperKeyringDirectory,
|
||||
ownerPepperBackupDirectory,
|
||||
ownerPepperKeyId: rawOptions.ownerPepperKeyId,
|
||||
localSecretKeyringPath,
|
||||
...(busyTimeoutMs === undefined ? {} : { busyTimeoutMs }),
|
||||
}),
|
||||
request: Object.freeze({
|
||||
registerMutationId: request.registerMutationId,
|
||||
activateMutationId: request.activateMutationId,
|
||||
registeredAtMs,
|
||||
activatedAtMs,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function existingOrProvisionPepper(
|
||||
command: Readonly<LocalSetupCommand>,
|
||||
): Readonly<{ digest: string; created: boolean }> {
|
||||
const provider = new LocalOwnerPepperKeyringFileProvider(
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
);
|
||||
const inspected = provider.inspect();
|
||||
if (inspected.keyIds.length === 0) {
|
||||
const created = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: command.options.ownerPepperKeyringDirectory,
|
||||
pepperKeyId: command.options.ownerPepperKeyId,
|
||||
});
|
||||
return Object.freeze({ digest: created.digest, created: true });
|
||||
}
|
||||
if (
|
||||
inspected.keyIds.length !== 1 ||
|
||||
inspected.keyIds[0] !== command.options.ownerPepperKeyId
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper keyring is not an exact setup replay',
|
||||
);
|
||||
}
|
||||
const material = provider.resolve(command.options.ownerPepperKeyId);
|
||||
if (!material) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper material is unavailable',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
digest: material.summary.digest,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
function existingOrBackupPepper(
|
||||
command: Readonly<LocalSetupCommand>,
|
||||
expectedDigest: string,
|
||||
): Readonly<{ digest: string; created: boolean }> {
|
||||
const provider = new LocalOwnerPepperKeyringFileProvider(
|
||||
command.options.ownerPepperBackupDirectory,
|
||||
);
|
||||
const inspected = provider.inspect();
|
||||
if (inspected.keyIds.length === 0) {
|
||||
const created = backupLocalOwnerPepperKey({
|
||||
keyringDirectory: command.options.ownerPepperKeyringDirectory,
|
||||
backupDirectory: command.options.ownerPepperBackupDirectory,
|
||||
pepperKeyId: command.options.ownerPepperKeyId,
|
||||
});
|
||||
if (created.digest !== expectedDigest) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper backup digest changed',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ digest: created.digest, created: true });
|
||||
}
|
||||
if (
|
||||
inspected.keyIds.length !== 1 ||
|
||||
inspected.keyIds[0] !== command.options.ownerPepperKeyId
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper backup is not an exact setup replay',
|
||||
);
|
||||
}
|
||||
const material = provider.resolve(command.options.ownerPepperKeyId);
|
||||
if (!material || material.summary.digest !== expectedDigest) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper backup does not match primary material',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
digest: material.summary.digest,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function existingOrProvisionEnvelopeKeyring(
|
||||
filePath: string,
|
||||
): Promise<Readonly<{ version: 1; keyCount: number; created: boolean }>> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const created = await provisionLocalSecretKeyring(filePath);
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
keyCount: created.keyIds.length,
|
||||
created: true,
|
||||
});
|
||||
}
|
||||
const existing = await new LocalSecretKeyringFileProvider(filePath).inspect();
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
keyCount: existing.keyIds.length,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeLocalSetup(
|
||||
input: unknown,
|
||||
): Promise<Readonly<LocalSetupResult>> {
|
||||
const command = normalizeLocalSetupCommand(input);
|
||||
const migration = await migrateLocalSqlitePath({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
const pepper = existingOrProvisionPepper(command);
|
||||
const backup = existingOrBackupPepper(command, pepper.digest);
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let registered;
|
||||
let activated;
|
||||
try {
|
||||
registered = await database.ownerPepper.register({
|
||||
mutationId: command.request.registerMutationId,
|
||||
pepperKeyId: command.options.ownerPepperKeyId,
|
||||
materialDigest: pepper.digest,
|
||||
backupDigest: backup.digest,
|
||||
registeredAtMs: command.request.registeredAtMs,
|
||||
});
|
||||
activated = await database.ownerPepper.activate({
|
||||
mutationId: command.request.activateMutationId,
|
||||
pepperKeyId: command.options.ownerPepperKeyId,
|
||||
expectedGeneration: 0,
|
||||
activatedAtMs: command.request.activatedAtMs,
|
||||
});
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
const envelopeKeyring = await existingOrProvisionEnvelopeKeyring(
|
||||
command.options.localSecretKeyringPath,
|
||||
);
|
||||
const created =
|
||||
pepper.created ||
|
||||
backup.created ||
|
||||
registered.status === 'inserted' ||
|
||||
activated.status === 'inserted' ||
|
||||
envelopeKeyring.created;
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
status: created ? ('prepared' as const) : ('existing' as const),
|
||||
profile: command.options.profile,
|
||||
storage: Object.freeze({
|
||||
contractName: migration.readiness.contractName,
|
||||
contractVersion: migration.readiness.contractVersion,
|
||||
migrationCount: migration.readiness.migrationIds.length,
|
||||
}),
|
||||
ownerPepper: Object.freeze({
|
||||
registerStatus: registered.status,
|
||||
activateStatus: activated.status,
|
||||
generation: activated.activation.generation,
|
||||
}),
|
||||
envelopeKeyring: Object.freeze({
|
||||
version: 1 as const,
|
||||
keyCount: envelopeKeyring.keyCount,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function executeLocalSetupCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalSetupResult>> {
|
||||
return executeLocalSetup(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Keep the one-shot setup binary beside its lifecycle command.
|
||||
import { executeLocalSetupCommandFile } from './localSetup';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-setup run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 3 || argv[0] !== 'run' || argv[1] !== '--command-file') {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_LOCAL_SETUP_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(await executeLocalSetupCommandFile(argv[2]!))}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'QL3_LOCAL_SETUP_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
Reference in New Issue
Block a user