feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,176 @@
import crypto from 'node:crypto';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import type { LocalApplicationProcessConfig } from './processConfig';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/;
interface LegacySilenceCommitmentPayload {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-local-legacy-silence-commitment';
readonly state: 'legacy_stopped';
readonly cutoverId: string;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly activationDigest: string;
readonly previousRecordDigest: string;
readonly requestedAtMs: number;
readonly observedAtMs: number;
readonly controller: Readonly<{
kind: 'docker';
endpointDigest: string;
legacyContainerId: string;
legacyContainerIdentityDigest: string;
legacySourceBindingDigest: string;
}>;
}
interface LegacySilenceCommitment extends LegacySilenceCommitmentPayload {
readonly commitmentDigest: string;
}
export class LocalApplicationCutoverCommitmentError extends TypeError {
readonly code = 'QL3_LOCAL_APPLICATION_CUTOVER_COMMITMENT_INVALID';
constructor(message: string) {
super(`Local application cutover commitment is invalid: ${message}`);
this.name = 'LocalApplicationCutoverCommitmentError';
}
}
function invalid(message: string): never {
throw new LocalApplicationCutoverCommitmentError(message);
}
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)
) {
invalid(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(
value: Record<string, unknown>,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const keys = [...expected].sort();
if (
actual.length !== keys.length ||
actual.some((key, index) => key !== keys[index])
) {
invalid(`${label} shape is invalid`);
}
}
function digest(value: unknown): string {
return crypto
.createHash('sha256')
.update(JSON.stringify(value), 'utf8')
.digest('hex');
}
function parseLegacySilenceCommitment(
value: unknown,
): Readonly<LegacySilenceCommitment> {
const commitment = object(value, 'commitment');
exact(
commitment,
[
'activationDigest',
'commitmentDigest',
'controller',
'cutoverId',
'instanceId',
'kind',
'observedAtMs',
'previousRecordDigest',
'profile',
'requestedAtMs',
'schemaVersion',
'state',
],
'commitment',
);
const controller = object(commitment.controller, 'controller');
exact(
controller,
[
'endpointDigest',
'kind',
'legacyContainerId',
'legacyContainerIdentityDigest',
'legacySourceBindingDigest',
],
'controller',
);
if (
commitment.schemaVersion !== 1 ||
commitment.kind !== 'qinglong3-local-legacy-silence-commitment' ||
commitment.state !== 'legacy_stopped' ||
typeof commitment.cutoverId !== 'string' ||
!ID_PATTERN.test(commitment.cutoverId) ||
(commitment.profile !== 'edge' && commitment.profile !== 'standalone') ||
typeof commitment.instanceId !== 'string' ||
!ID_PATTERN.test(commitment.instanceId) ||
typeof commitment.activationDigest !== 'string' ||
!DIGEST_PATTERN.test(commitment.activationDigest) ||
typeof commitment.previousRecordDigest !== 'string' ||
!DIGEST_PATTERN.test(commitment.previousRecordDigest) ||
!Number.isSafeInteger(commitment.requestedAtMs) ||
(commitment.requestedAtMs as number) < 0 ||
!Number.isSafeInteger(commitment.observedAtMs) ||
(commitment.observedAtMs as number) <
(commitment.requestedAtMs as number) ||
controller.kind !== 'docker' ||
typeof controller.endpointDigest !== 'string' ||
!DIGEST_PATTERN.test(controller.endpointDigest) ||
typeof controller.legacyContainerId !== 'string' ||
!CONTAINER_ID_PATTERN.test(controller.legacyContainerId) ||
typeof controller.legacyContainerIdentityDigest !== 'string' ||
!DIGEST_PATTERN.test(controller.legacyContainerIdentityDigest) ||
typeof controller.legacySourceBindingDigest !== 'string' ||
!DIGEST_PATTERN.test(controller.legacySourceBindingDigest) ||
typeof commitment.commitmentDigest !== 'string' ||
!DIGEST_PATTERN.test(commitment.commitmentDigest)
) {
invalid('commitment fields are invalid');
}
const { commitmentDigest, ...payload } = commitment;
if (digest(payload) !== commitmentDigest) {
invalid('commitment digest does not match');
}
return commitment as unknown as Readonly<LegacySilenceCommitment>;
}
export function verifyLocalApplicationCutoverCommitment(
config: Readonly<LocalApplicationProcessConfig>,
): void {
if (config.storage.mode === 'fresh') return;
if (config.cutover === undefined) {
invalid('adopted storage requires a v3 cutover commitment');
}
const commitment = parseLegacySilenceCommitment(
readPrivateLocalCommandFile(config.cutover.commitmentPath),
);
if (
commitment.commitmentDigest !==
config.cutover.expectedCommitmentDigest ||
commitment.cutoverId !== config.cutover.cutoverId ||
commitment.profile !== config.profile ||
commitment.instanceId !== config.instanceId ||
commitment.activationDigest !== config.storage.expectedActivationDigest
) {
invalid('commitment no longer matches the reviewed application identity');
}
}
@@ -0,0 +1,249 @@
import fs from 'node:fs';
import path from 'node:path';
export type LocalApplicationLifecycleReceiptFailure = (
message: string,
cause?: unknown,
) => never;
interface DirectoryIdentity {
readonly device: bigint;
readonly inode: bigint;
readonly uid: number;
readonly mode: number;
}
function isCode(error: unknown, code: string): boolean {
return (
!!error &&
typeof error === 'object' &&
'code' in error &&
error.code === code
);
}
function currentUid(fail: LocalApplicationLifecycleReceiptFailure): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== process.geteuid()
) {
fail('real and effective POSIX users must match');
}
return process.getuid();
}
function privateDirectoryIdentity(
directory: string,
uid: number,
fail: LocalApplicationLifecycleReceiptFailure,
): DirectoryIdentity {
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(directory, { bigint: true });
} catch (error) {
fail('receipt directory cannot be read', error);
}
const mode = Number(stat.mode) & 0o777;
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
(mode & 0o022) !== 0
) {
fail('receipt directory must be an owner-controlled regular directory');
}
return Object.freeze({
device: stat.dev,
inode: stat.ino,
uid,
mode,
});
}
function verifyDirectoryIdentity(
directory: string,
expected: Readonly<DirectoryIdentity>,
fail: LocalApplicationLifecycleReceiptFailure,
): void {
const current = privateDirectoryIdentity(directory, expected.uid, fail);
if (
current.device !== expected.device ||
current.inode !== expected.inode ||
current.mode !== expected.mode
) {
fail('receipt directory identity changed');
}
}
function openStage(
stagePath: string,
uid: number,
maximumBytes: number,
fail: LocalApplicationLifecycleReceiptFailure,
): {
readonly descriptor: number;
readonly device: bigint;
readonly inode: bigint;
} {
let descriptor: number;
try {
descriptor = fs.openSync(
stagePath,
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_EXCL |
(fs.constants.O_NOFOLLOW ?? 0),
0o600,
);
} catch (error) {
if (!isCode(error, 'EEXIST'))
fail('receipt stage cannot be created', error);
let before: fs.BigIntStats;
try {
before = fs.lstatSync(stagePath, { bigint: true });
} catch (readError) {
fail('existing receipt stage cannot be read', readError);
}
if (
!before.isFile() ||
before.isSymbolicLink() ||
Number(before.uid) !== uid ||
(Number(before.mode) & 0o777) !== 0o600 ||
before.nlink !== 1n ||
before.size > BigInt(maximumBytes)
) {
fail('existing receipt stage is not a private regular file');
}
try {
descriptor = fs.openSync(
stagePath,
fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
} catch (openError) {
fail('existing receipt stage cannot be opened', openError);
}
const opened = fs.fstatSync(descriptor, { bigint: true });
if (
!opened.isFile() ||
opened.dev !== before.dev ||
opened.ino !== before.ino ||
Number(opened.uid) !== uid ||
(Number(opened.mode) & 0o777) !== 0o600 ||
opened.nlink !== 1n
) {
fs.closeSync(descriptor);
fail('receipt stage identity changed while opening');
}
fs.ftruncateSync(descriptor, 0);
}
const opened = fs.fstatSync(descriptor, { bigint: true });
if (
!opened.isFile() ||
Number(opened.uid) !== uid ||
(Number(opened.mode) & 0o777) !== 0o600 ||
opened.nlink !== 1n
) {
fs.closeSync(descriptor);
fail('receipt stage is not a private regular file');
}
return Object.freeze({
descriptor,
device: opened.dev,
inode: opened.ino,
});
}
function writeAll(
descriptor: number,
material: Buffer,
fail: LocalApplicationLifecycleReceiptFailure,
): void {
let offset = 0;
while (offset < material.byteLength) {
const written = fs.writeSync(
descriptor,
material,
offset,
material.byteLength - offset,
);
if (written < 1) fail('receipt stage write made no progress');
offset += written;
}
}
function bestEffortSyncDirectory(directory: string): void {
let descriptor: number | undefined;
try {
descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
fs.fsyncSync(descriptor);
} catch {
// Atomic visibility is already established. Some supported filesystems
// reject directory fsync, so power-loss durability remains best effort.
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function publishLocalApplicationLifecycleReceiptFile(options: {
readonly targetPath: string;
readonly contents: string;
readonly maximumBytes: number;
readonly fail: LocalApplicationLifecycleReceiptFailure;
readonly isFailure: (error: unknown) => boolean;
}): string {
const directory = path.dirname(options.targetPath);
const stagePath = `${options.targetPath}.stage`;
const uid = currentUid(options.fail);
const directoryIdentity = privateDirectoryIdentity(
directory,
uid,
options.fail,
);
const material = Buffer.from(options.contents, 'utf8');
if (material.byteLength < 1 || material.byteLength > options.maximumBytes) {
material.fill(0);
options.fail('serialized receipt exceeds its byte limit');
}
let descriptor: number | undefined;
try {
const stage = openStage(stagePath, uid, options.maximumBytes, options.fail);
descriptor = stage.descriptor;
writeAll(descriptor, material, options.fail);
fs.fsyncSync(descriptor);
const written = fs.fstatSync(descriptor, { bigint: true });
if (
written.dev !== stage.device ||
written.ino !== stage.inode ||
written.size !== BigInt(material.byteLength) ||
written.nlink !== 1n
) {
options.fail('receipt stage identity changed while writing');
}
fs.closeSync(descriptor);
descriptor = undefined;
verifyDirectoryIdentity(directory, directoryIdentity, options.fail);
fs.renameSync(stagePath, options.targetPath);
const published = fs.lstatSync(options.targetPath, { bigint: true });
if (
!published.isFile() ||
published.isSymbolicLink() ||
published.dev !== stage.device ||
published.ino !== stage.inode ||
Number(published.uid) !== uid ||
(Number(published.mode) & 0o777) !== 0o600 ||
published.nlink !== 1n ||
published.size !== BigInt(material.byteLength)
) {
options.fail('published receipt identity is invalid');
}
bestEffortSyncDirectory(directory);
return options.targetPath;
} catch (error) {
if (options.isFailure(error)) throw error;
return options.fail('receipt cannot be published', error);
} finally {
material.fill(0);
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
@@ -0,0 +1,398 @@
import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalJsonFile } from '@qinglong/local-command-file';
import { createLocalPluginPackageFileStageProvider } from '@qinglong/local-admin/package-installation';
import { assertLocalPluginPackagePublisherKeyPublicationAllowed } from '@qinglong/local-admin/package-publisher-trust';
import type { PluginPackageManifest } from '@qinglong/runtime-core/plugin-package';
import {
PluginPackagePublisherTrustRegistry,
type PluginPackagePublisherKeyDefinition,
type PluginPackageSignature,
} from '@qinglong/runtime-core/plugin-package-bundle';
import {
normalizePluginPackageLock,
type PluginPackageLock,
type PluginPackageSourceLock,
} from '@qinglong/runtime-core/plugin-package-install';
import type { PluginPackageStageProvider } from '@qinglong/runtime-core/plugin-package-installation';
export const LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA =
'qinglong/local-plugin-package-recovery-source@v1' as const;
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA =
'qinglong/plugin-package-publisher-trust@v1' as const;
export const MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES = 64;
export const MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_BUNDLES = 64;
const MAX_PATH_BYTES = 4_096;
const MAX_SOURCE_FILE_BYTES = 256 * 1024;
const MAX_TRUST_FILE_BYTES = 256 * 1024;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const SOURCE_FILE_PATTERN = /^([0-9a-f]{64})\.json$/;
const BUNDLE_FILE_PATTERN = /^([0-9a-f]{64})\.bundle$/;
export interface LocalPluginPackageRecoveryCatalogOptions {
readonly catalogRoot: string;
readonly bundleRoot: string;
readonly publisherTrustFilePath: string;
readonly stagingRoot: string;
}
interface LocalPluginPackageRecoverySource {
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA;
readonly lockDigest: string;
readonly source: Readonly<PluginPackageSourceLock>;
readonly bundlePath: string;
readonly manifest: PluginPackageManifest;
readonly signature: PluginPackageSignature;
}
export class LocalPluginPackageRecoveryCatalogError extends Error {
readonly code = 'QL3_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(
`Local Plugin Package recovery catalog is invalid: ${message}`,
options,
);
this.name = 'LocalPluginPackageRecoveryCatalogError';
}
}
function record(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 LocalPluginPackageRecoveryCatalogError(
`${label} must be an object`,
);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
Object.values(descriptors).some(
(descriptor) =>
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true,
)
) {
throw new LocalPluginPackageRecoveryCatalogError(
`${label} must contain enumerable data properties`,
);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new LocalPluginPackageRecoveryCatalogError(
`${label} shape is invalid`,
);
}
}
function absolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES ||
value.includes('\0') ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value
) {
throw new LocalPluginPackageRecoveryCatalogError(
`${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 LocalPluginPackageRecoveryCatalogError(
'real and effective POSIX users must match',
);
}
return process.getuid();
}
interface CatalogDirectoryIdentity {
readonly path: string;
readonly uid: number;
readonly device: bigint;
readonly inode: bigint;
}
function privateDirectory(
candidate: string,
kind: 'catalog' | 'bundle',
): Readonly<CatalogDirectoryIdentity> {
const directoryPath = absolutePath(candidate, `${kind}Root`);
const uid = currentUid();
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(directoryPath, { bigint: true });
} catch (error) {
throw new LocalPluginPackageRecoveryCatalogError(
`${kind} root is unavailable`,
{ cause: error instanceof Error ? error : undefined },
);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
(Number(stat.mode) & 0o777) !== 0o700 ||
fs.realpathSync(directoryPath) !== directoryPath
) {
throw new LocalPluginPackageRecoveryCatalogError(
`${kind} root must be an owner-only non-symlink directory`,
);
}
const entries = fs.readdirSync(directoryPath);
const pattern =
kind === 'catalog' ? SOURCE_FILE_PATTERN : BUNDLE_FILE_PATTERN;
const maximum =
kind === 'catalog'
? MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES
: MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_BUNDLES;
if (
entries.length > maximum ||
entries.some((entry) => !pattern.test(entry))
) {
throw new LocalPluginPackageRecoveryCatalogError(
`${kind} root contains unbounded or unknown entries`,
);
}
return Object.freeze({
path: directoryPath,
uid,
device: stat.dev,
inode: stat.ino,
});
}
function revalidateCatalogDirectory(
identity: Readonly<CatalogDirectoryIdentity>,
): void {
const stat = fs.lstatSync(identity.path, { bigint: true });
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== identity.uid ||
(Number(stat.mode) & 0o777) !== 0o700 ||
stat.dev !== identity.device ||
stat.ino !== identity.inode ||
fs.realpathSync(identity.path) !== identity.path
) {
throw new LocalPluginPackageRecoveryCatalogError(
'catalog root identity changed while reading',
);
}
}
function sourceLock(value: unknown): Readonly<PluginPackageSourceLock> {
const source = record(value, 'source');
exactKeys(
source,
['artifactBytes', 'artifactDigest', 'contentDigest', 'kind', 'locator'],
'source',
);
if (
(source.kind !== 'offline' && source.kind !== 'oci') ||
typeof source.locator !== 'string' ||
source.locator.length === 0 ||
Buffer.byteLength(source.locator, 'utf8') > MAX_PATH_BYTES ||
typeof source.artifactDigest !== 'string' ||
!DIGEST_PATTERN.test(source.artifactDigest) ||
!Number.isSafeInteger(source.artifactBytes) ||
(source.artifactBytes as number) < 1 ||
typeof source.contentDigest !== 'string' ||
!DIGEST_PATTERN.test(source.contentDigest)
) {
throw new LocalPluginPackageRecoveryCatalogError('source lock is invalid');
}
return Object.freeze({
kind: source.kind,
locator: source.locator,
artifactDigest: source.artifactDigest,
artifactBytes: source.artifactBytes as number,
contentDigest: source.contentDigest,
});
}
function sourceMatches(
left: Readonly<PluginPackageSourceLock>,
right: Readonly<PluginPackageSourceLock>,
): boolean {
return (
left.kind === right.kind &&
left.locator === right.locator &&
left.artifactDigest === right.artifactDigest &&
left.artifactBytes === right.artifactBytes &&
left.contentDigest === right.contentDigest
);
}
function loadSource(
catalogRoot: string,
bundleRoot: string,
lock: Readonly<PluginPackageLock>,
): Readonly<LocalPluginPackageRecoverySource> {
const directory = privateDirectory(catalogRoot, 'catalog');
const bundles = privateDirectory(bundleRoot, 'bundle');
const fileName = `${lock.lockDigest}.json`;
const sourcePath = path.join(directory.path, fileName);
let value: unknown;
try {
value = readPrivateLocalJsonFile(sourcePath, {
maxBytes: MAX_SOURCE_FILE_BYTES,
});
} catch (error) {
throw new LocalPluginPackageRecoveryCatalogError(
'locked source entry is unavailable',
{ cause: error instanceof Error ? error : undefined },
);
}
revalidateCatalogDirectory(directory);
const entry = record(value, 'source entry');
exactKeys(
entry,
['bundlePath', 'lockDigest', 'manifest', 'schema', 'signature', 'source'],
'source entry',
);
const source = sourceLock(entry.source);
const expectedBundlePath = path.join(
bundles.path,
`${source.artifactDigest}.bundle`,
);
if (
entry.schema !== LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA ||
entry.lockDigest !== lock.lockDigest ||
!sourceMatches(source, lock.source) ||
entry.bundlePath !== expectedBundlePath
) {
throw new LocalPluginPackageRecoveryCatalogError(
'source entry does not match its durable PackageLock',
);
}
revalidateCatalogDirectory(bundles);
return Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA,
lockDigest: lock.lockDigest,
source,
bundlePath: expectedBundlePath,
manifest: entry.manifest as PluginPackageManifest,
signature: entry.signature as PluginPackageSignature,
});
}
function loadTrust(filePath: string): PluginPackagePublisherTrustRegistry {
let value: unknown;
try {
value = readPrivateLocalJsonFile(filePath, {
maxBytes: MAX_TRUST_FILE_BYTES,
});
} catch (error) {
throw new LocalPluginPackageRecoveryCatalogError(
'publisher trust file is unavailable',
{ cause: error instanceof Error ? error : undefined },
);
}
const trust = record(value, 'publisher trust');
exactKeys(trust, ['keys', 'schema'], 'publisher trust');
if (
trust.schema !== LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA ||
!Array.isArray(trust.keys)
) {
throw new LocalPluginPackageRecoveryCatalogError(
'publisher trust file shape is invalid',
);
}
try {
return new PluginPackagePublisherTrustRegistry(
trust.keys as PluginPackagePublisherKeyDefinition[],
);
} catch (error) {
throw new LocalPluginPackageRecoveryCatalogError(
'publisher trust keys are invalid',
{ cause: error instanceof Error ? error : undefined },
);
}
}
export function createLocalPluginPackageRecoveryCatalogStageProvider(
value: LocalPluginPackageRecoveryCatalogOptions,
): PluginPackageStageProvider {
const options = record(value, 'catalog options');
exactKeys(
options,
['bundleRoot', 'catalogRoot', 'publisherTrustFilePath', 'stagingRoot'],
'catalog options',
);
const catalogRoot = absolutePath(value.catalogRoot, 'catalogRoot');
const bundleRoot = absolutePath(value.bundleRoot, 'bundleRoot');
const publisherTrustFilePath = absolutePath(
value.publisherTrustFilePath,
'publisherTrustFilePath',
);
const stagingRoot = absolutePath(value.stagingRoot, 'stagingRoot');
const trustRoot = path.dirname(publisherTrustFilePath);
if (
path.basename(publisherTrustFilePath) !== 'current.json' ||
catalogRoot === publisherTrustFilePath ||
catalogRoot === stagingRoot ||
catalogRoot === bundleRoot ||
bundleRoot === publisherTrustFilePath ||
bundleRoot === stagingRoot ||
publisherTrustFilePath === stagingRoot
) {
throw new LocalPluginPackageRecoveryCatalogError(
'catalog authorities are invalid',
);
}
return Object.freeze({
async stage(lockValue: Readonly<PluginPackageLock>) {
const lock = normalizePluginPackageLock(lockValue);
const source = loadSource(catalogRoot, bundleRoot, lock);
assertLocalPluginPackagePublisherKeyPublicationAllowed({
trustRoot,
publisher: source.signature.publisher,
keyId: source.signature.keyId,
});
const trust = loadTrust(publisherTrustFilePath);
const staged = await createLocalPluginPackageFileStageProvider({
bundlePath: source.bundlePath,
stagingRoot,
manifest: source.manifest,
signature: source.signature,
trust,
observedAtMs: lock.createdAtMs,
}).stage(lock);
assertLocalPluginPackagePublisherKeyPublicationAllowed({
trustRoot,
publisher: source.signature.publisher,
keyId: source.signature.keyId,
});
return staged;
},
});
}
@@ -0,0 +1,392 @@
import type { PluginPackageStageProvider } from '@qinglong/runtime-core/plugin-package-installation';
import type { PluginPackageLock } from '@qinglong/runtime-core/plugin-package-install';
import type { LocalAdoptedProfileAudit } from '@qinglong/local-admin/adopted-profile';
import type { LocalProfileStorageAudit } from '@qinglong/local-sqlite/profile';
import type {
BootstrapLocalAiFeatureApplicationOptions,
BootstrapLocalAiFeatureApplicationResult,
LocalAiFeatureApplicationAudit,
LocalAiFeatureDeploymentOptions,
} from '../application-runtime/aiFeatureApplication';
import type {
LocalApplicationActivationAudit,
LocalApplicationProductSurface,
LocalApplicationStopResult,
} from '../application-runtime/contract';
import {
loadLocalApplicationProcessConfig,
type LocalApplicationProcessConfig,
} from './processConfig';
import { verifyLocalApplicationCutoverCommitment } from './cutoverCommitment';
import { recordLocalApplicationShutdownReceipt } from './shutdownReceipt';
import { recordLocalApplicationStartupReceipt } from './startupReceipt';
export type LocalApplicationProcessSignal = 'SIGINT' | 'SIGTERM';
export interface LocalApplicationProcessEvent {
readonly schemaVersion: 1;
readonly component: 'qinglong3-local-application';
readonly level: 'info' | 'error';
readonly event: string;
readonly instanceId: string;
readonly profile: LocalApplicationProcessConfig['profile'];
readonly signal?: LocalApplicationProcessSignal;
readonly stopResult?: LocalApplicationStopResult;
readonly aiStatus?:
| 'deployment_excluded'
| 'schema_absent'
| 'inactive'
| 'active';
readonly dependencyActivation?: Readonly<{
scope: 'storage' | 'adoption';
state: string;
}>;
readonly applicationActivation?: LocalApplicationActivationAudit;
readonly aiActivation?: LocalAiFeatureApplicationAudit;
}
export interface LocalApplicationProcessSignalSource {
subscribe(
listener: (signal: LocalApplicationProcessSignal) => void,
): () => void;
}
export type LocalApplicationProductStarter = (
options: BootstrapLocalAiFeatureApplicationOptions,
) => Promise<BootstrapLocalAiFeatureApplicationResult>;
type InstalledAiLoader = Extract<
LocalAiFeatureDeploymentOptions,
{ deployment: 'installed' }
>['loadProviders'];
export interface ProductionLocalApplicationProcessOptions {
readonly configFilePath: string;
readonly signals: LocalApplicationProcessSignalSource;
readonly emit: (
event: Readonly<LocalApplicationProcessEvent>,
) => void | Promise<void>;
readonly stageProvider?: PluginPackageStageProvider;
readonly loadAiProviders?: InstalledAiLoader;
readonly start?: LocalApplicationProductStarter;
readonly productSurface?: LocalApplicationProductSurface;
readonly now?: () => number;
}
export class LocalApplicationProcessError extends Error {
readonly code:
| 'QL3_LOCAL_APPLICATION_PROCESS_AI_PROVIDER_UNAVAILABLE'
| 'QL3_LOCAL_APPLICATION_PROCESS_NOT_ACTIVE'
| 'QL3_LOCAL_APPLICATION_PLUGIN_SOURCE_UNAVAILABLE';
constructor(
code: LocalApplicationProcessError['code'],
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'LocalApplicationProcessError';
this.code = code;
}
}
function event(
config: Readonly<LocalApplicationProcessConfig>,
values: Omit<
LocalApplicationProcessEvent,
'schemaVersion' | 'component' | 'instanceId' | 'profile'
>,
): Readonly<LocalApplicationProcessEvent> {
return Object.freeze({
schemaVersion: 1,
component: 'qinglong3-local-application',
instanceId: config.instanceId,
profile: config.profile,
...values,
});
}
function unavailableStageProvider(): PluginPackageStageProvider {
return Object.freeze({
async stage() {
throw new LocalApplicationProcessError(
'QL3_LOCAL_APPLICATION_PLUGIN_SOURCE_UNAVAILABLE',
'No Plugin Package recovery source is configured for this process',
);
},
});
}
function configuredStageProvider(
config: Readonly<LocalApplicationProcessConfig>,
options: ProductionLocalApplicationProcessOptions,
): PluginPackageStageProvider {
if (options.stageProvider) return options.stageProvider;
if (config.pluginPackages.recoverySource.mode === 'disabled') {
return unavailableStageProvider();
}
const catalog = config.pluginPackages.recoverySource;
return Object.freeze({
async stage(lock: Readonly<PluginPackageLock>) {
const { createLocalPluginPackageRecoveryCatalogStageProvider } =
await import('./pluginPackageRecoveryCatalog.js');
return createLocalPluginPackageRecoveryCatalogStageProvider({
catalogRoot: catalog.catalogRoot,
bundleRoot: catalog.bundleRoot,
publisherTrustFilePath: catalog.publisherTrustFilePath,
stagingRoot: config.pluginPackages.stagingRoot,
}).stage(lock);
},
});
}
async function defaultStarter(
options: BootstrapLocalAiFeatureApplicationOptions,
): Promise<BootstrapLocalAiFeatureApplicationResult> {
const { bootstrapLocalAiFeatureApplication } = await import(
'../application-runtime/aiFeatureApplication.js'
);
return bootstrapLocalAiFeatureApplication(options);
}
function aiOptions(
config: Readonly<LocalApplicationProcessConfig>,
options: ProductionLocalApplicationProcessOptions,
): LocalAiFeatureDeploymentOptions {
const audit = (record: Readonly<LocalAiFeatureApplicationAudit>) =>
options.emit(
event(config, {
level: record.state === 'failed' ? 'error' : 'info',
event: 'ai_activation',
aiActivation: Object.freeze({ ...record }),
}),
);
if (config.ai.deployment === 'excluded') {
return Object.freeze({
deployment: 'excluded' as const,
audit,
});
}
if (typeof options.loadAiProviders !== 'function') {
throw new LocalApplicationProcessError(
'QL3_LOCAL_APPLICATION_PROCESS_AI_PROVIDER_UNAVAILABLE',
'Installed AI deployment requires a provider authority loader',
);
}
return Object.freeze({
deployment: 'installed' as const,
loadProviders: options.loadAiProviders,
audit,
...(config.ai.maxConcurrent === undefined
? {}
: { maxConcurrent: config.ai.maxConcurrent }),
...(config.ai.recoveryLimit === undefined
? {}
: { recoveryLimit: config.ai.recoveryLimit }),
...(config.ai.drainTimeoutMs === undefined
? {}
: { drainTimeoutMs: config.ai.drainTimeoutMs }),
...(config.ai.drainPollMs === undefined
? {}
: { drainPollMs: config.ai.drainPollMs }),
});
}
/**
* Owns one edge or standalone QingLong 3.0 process. Signal handling is
* installed before storage startup. The first signal withdraws scheduler
* admission, drains execution control, and finally releases the adoption
* fence through the application stop contract.
*/
export async function runProductionLocalApplicationProcess(
options: ProductionLocalApplicationProcessOptions,
): Promise<LocalApplicationStopResult> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
typeof options.configFilePath !== 'string' ||
typeof options.emit !== 'function' ||
typeof options.signals?.subscribe !== 'function' ||
(options.stageProvider !== undefined &&
typeof options.stageProvider?.stage !== 'function') ||
(options.loadAiProviders !== undefined &&
typeof options.loadAiProviders !== 'function') ||
(options.start !== undefined && typeof options.start !== 'function') ||
(options.productSurface !== undefined &&
typeof options.productSurface?.start !== 'function') ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new TypeError('Local application process options are invalid');
}
const config = loadLocalApplicationProcessConfig(options.configFilePath);
verifyLocalApplicationCutoverCommitment(config);
const selectedAi = aiOptions(config, options);
const start = options.start ?? defaultStarter;
const now = options.now ?? Date.now;
const stageProvider = configuredStageProvider(config, options);
let resolveSignal:
| ((signal: LocalApplicationProcessSignal) => void)
| undefined;
const requestedSignal = new Promise<LocalApplicationProcessSignal>(
(resolve) => {
resolveSignal = resolve;
},
);
let acceptedSignal = false;
const unsubscribe = options.signals.subscribe((signal) => {
if (acceptedSignal) return;
acceptedSignal = true;
resolveSignal?.(signal);
});
// Library lifecycles intentionally unref their timers so embedded callers
// can exit. The executable composition root must keep one referenced handle
// while it owns the process; this interval wakes at most once every ~24.8d.
const keepAlive = setInterval(() => undefined, 2_147_483_647);
try {
// Give the host event loop one turn to arm OS-level signal delivery before
// synchronous SQLite recovery can occupy the initial startup turn.
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
const application = await start({
application: {
enabled: true,
profile: config.profile,
...(config.storage.mode === 'fresh'
? {
storageMode: 'fresh' as const,
databasePath: config.storage.databasePath,
}
: {
storageMode: 'adopted' as const,
sourcePath: config.storage.sourcePath,
targetPath: config.storage.targetPath,
recoveryPath: config.storage.recoveryPath,
manifestPath: config.storage.manifestPath,
activationPath: config.storage.activationPath,
expectedActivationDigest: config.storage.expectedActivationDigest,
adoptionAudit(record: Readonly<LocalAdoptedProfileAudit>) {
return options.emit(
event(config, {
level: record.state === 'failed' ? 'error' : 'info',
event: 'dependency_activation',
dependencyActivation: Object.freeze({
scope: 'adoption' as const,
state: record.state,
}),
}),
);
},
}),
...(config.storage.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: config.storage.busyTimeoutMs }),
receiptRoot: config.runtime.receiptRoot,
artifactRoot: config.runtime.artifactRoot,
secretKeyringPath: config.runtime.secretKeyringPath,
pluginPackages: {
stageProvider,
stagingRoot: config.pluginPackages.stagingRoot,
activationRoot: config.pluginPackages.activationRoot,
now,
...(config.pluginPackages.pageSize === undefined
? {}
: { pageSize: config.pluginPackages.pageSize }),
...(config.pluginPackages.maxPages === undefined
? {}
: { maxPages: config.pluginPackages.maxPages }),
...(config.pluginPackages.taskPublicationPageSize === undefined
? {}
: {
taskPublicationPageSize:
config.pluginPackages.taskPublicationPageSize,
}),
...(config.pluginPackages.taskPublicationMaxPages === undefined
? {}
: {
taskPublicationMaxPages:
config.pluginPackages.taskPublicationMaxPages,
}),
},
...(options.productSurface === undefined
? {}
: { productSurface: options.productSurface }),
audit(record: Readonly<LocalProfileStorageAudit>) {
return options.emit(
event(config, {
level: record.state === 'failed' ? 'error' : 'info',
event: 'dependency_activation',
dependencyActivation: Object.freeze({
scope: 'storage' as const,
state: record.state,
}),
}),
);
},
applicationAudit(record) {
return options.emit(
event(config, {
level: record.state === 'failed' ? 'error' : 'info',
event: 'application_activation',
applicationActivation: Object.freeze({ ...record }),
}),
);
},
},
ai: selectedAi,
});
if (application.status !== 'active') {
throw new LocalApplicationProcessError(
'QL3_LOCAL_APPLICATION_PROCESS_NOT_ACTIVE',
'The local application process did not activate',
);
}
const startupReceipt = recordLocalApplicationStartupReceipt({
configFilePath: options.configFilePath,
instanceId: config.instanceId,
profile: config.profile,
aiStatus: application.ai.status,
});
await options.emit(
event(config, {
level: 'info',
event: 'active',
aiStatus: application.ai.status,
}),
);
const signal = await requestedSignal;
await options.emit(
event(config, {
level: 'info',
event: 'shutdown_requested',
signal,
}),
);
const stopResult = await application.stop();
if (stopResult === 'stopped' && startupReceipt !== undefined) {
recordLocalApplicationShutdownReceipt({
configFilePath: options.configFilePath,
instanceId: config.instanceId,
profile: config.profile,
signal,
startupReceiptDigest: startupReceipt.sha256,
});
}
await options.emit(
event(config, {
level: stopResult === 'stopped' ? 'info' : 'error',
event: 'stopped',
stopResult,
}),
);
return stopResult;
} finally {
clearInterval(keepAlive);
unsubscribe();
resolveSignal = undefined;
}
}
@@ -0,0 +1,591 @@
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE } from '@qinglong/runtime-core/plugin-package-install';
import {
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
} from '@qinglong/runtime-core/plugin-package-recovery';
import {
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES,
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE,
} from '@qinglong/runtime-core/plugin-package-task-publication';
import type { LocalApplicationProfile } from '../application-runtime/contract';
export const LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA =
'qinglong/local-application-process@v1' as const;
export const LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V2 =
'qinglong/local-application-process@v2' as const;
export const LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3 =
'qinglong/local-application-process@v3' as const;
const MAX_PATH_BYTES = 4_096;
const INSTANCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
export interface LocalApplicationProcessAdoptedStorageConfig {
readonly mode?: 'adopted';
readonly sourcePath: string;
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
readonly activationPath: string;
readonly expectedActivationDigest: string;
readonly busyTimeoutMs?: number;
}
export interface LocalApplicationProcessFreshStorageConfig {
readonly mode: 'fresh';
readonly databasePath: string;
readonly busyTimeoutMs?: number;
}
export type LocalApplicationProcessStorageConfig =
| LocalApplicationProcessAdoptedStorageConfig
| LocalApplicationProcessFreshStorageConfig;
export interface LocalApplicationProcessRuntimeConfig {
readonly receiptRoot: string;
readonly artifactRoot: string;
readonly secretKeyringPath: string;
}
export interface LocalApplicationProcessCutoverConfig {
readonly cutoverId: string;
readonly commitmentPath: string;
readonly expectedCommitmentDigest: string;
}
export type LocalApplicationProcessPluginPackageRecoverySourceConfig =
| Readonly<{ mode: 'disabled' }>
| Readonly<{
mode: 'materialized_catalog';
catalogRoot: string;
bundleRoot: string;
publisherTrustFilePath: string;
}>;
export interface LocalApplicationProcessPluginPackageConfig {
readonly stagingRoot: string;
readonly activationRoot: string;
readonly recoverySource: LocalApplicationProcessPluginPackageRecoverySourceConfig;
readonly pageSize?: number;
readonly maxPages?: number;
readonly taskPublicationPageSize?: number;
readonly taskPublicationMaxPages?: number;
}
export type LocalApplicationProcessAiConfig =
| Readonly<{ deployment: 'excluded' }>
| Readonly<{
deployment: 'installed';
maxConcurrent?: number;
recoveryLimit?: number;
drainTimeoutMs?: number;
drainPollMs?: number;
}>;
export interface LocalApplicationProcessConfig {
readonly schema:
| typeof LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA
| typeof LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V2
| typeof LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3;
readonly instanceId: string;
readonly profile: LocalApplicationProfile;
readonly storage: Readonly<LocalApplicationProcessStorageConfig>;
readonly runtime: Readonly<LocalApplicationProcessRuntimeConfig>;
readonly pluginPackages: Readonly<LocalApplicationProcessPluginPackageConfig>;
readonly ai: LocalApplicationProcessAiConfig;
readonly cutover?: Readonly<LocalApplicationProcessCutoverConfig>;
}
export class LocalApplicationProcessConfigError extends TypeError {
readonly code = 'QL3_LOCAL_APPLICATION_PROCESS_CONFIG_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Local application process configuration is invalid: ${message}`, options);
this.name = 'LocalApplicationProcessConfigError';
}
}
function record(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 LocalApplicationProcessConfigError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new LocalApplicationProcessConfigError(`${label} shape is invalid`);
}
}
function absolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES ||
value.includes('\0') ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value
) {
throw new LocalApplicationProcessConfigError(
`${label} must be a normalized bounded absolute non-root path`,
);
}
return value;
}
function optionalInteger(
value: unknown,
minimum: number,
maximum: number,
label: string,
): number | undefined {
if (value === undefined) return undefined;
if (
!Number.isSafeInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
throw new LocalApplicationProcessConfigError(`${label} is invalid`);
}
return value as number;
}
function storageConfig(
value: unknown,
schema:
| typeof LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA
| typeof LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V2
| typeof LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3,
): Readonly<LocalApplicationProcessStorageConfig> {
const storage = record(value, 'storage');
if (schema !== LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA) {
if (storage.mode === 'fresh') {
const optionalKeys = Object.hasOwn(storage, 'busyTimeoutMs')
? ['busyTimeoutMs']
: [];
exactKeys(
storage,
['databasePath', 'mode', ...optionalKeys],
'fresh storage',
);
const busyTimeoutMs = optionalInteger(
storage.busyTimeoutMs,
100,
30_000,
'busyTimeoutMs',
);
return Object.freeze({
mode: 'fresh' as const,
databasePath: absolutePath(storage.databasePath, 'databasePath'),
...(busyTimeoutMs === undefined ? {} : { busyTimeoutMs }),
});
}
if (storage.mode !== 'adopted') {
throw new LocalApplicationProcessConfigError(
'storage mode must be fresh or adopted',
);
}
}
const optionalKeys = Object.hasOwn(storage, 'busyTimeoutMs')
? ['busyTimeoutMs']
: [];
exactKeys(
storage,
[
'activationPath',
'expectedActivationDigest',
'manifestPath',
'recoveryPath',
'sourcePath',
'targetPath',
...(schema !== LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA
? ['mode']
: []),
...optionalKeys,
],
'storage',
);
const expectedActivationDigest = storage.expectedActivationDigest;
if (
typeof expectedActivationDigest !== 'string' ||
!DIGEST_PATTERN.test(expectedActivationDigest)
) {
throw new LocalApplicationProcessConfigError(
'expectedActivationDigest is invalid',
);
}
const busyTimeoutMs = optionalInteger(
storage.busyTimeoutMs,
100,
30_000,
'busyTimeoutMs',
);
const result: LocalApplicationProcessStorageConfig = {
...(schema !== LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA
? { mode: 'adopted' as const }
: {}),
sourcePath: absolutePath(storage.sourcePath, 'sourcePath'),
targetPath: absolutePath(storage.targetPath, 'targetPath'),
recoveryPath: absolutePath(storage.recoveryPath, 'recoveryPath'),
manifestPath: absolutePath(storage.manifestPath, 'manifestPath'),
activationPath: absolutePath(storage.activationPath, 'activationPath'),
expectedActivationDigest,
...(busyTimeoutMs === undefined ? {} : { busyTimeoutMs }),
};
const authorityPaths = [
result.sourcePath,
result.targetPath,
result.recoveryPath,
result.manifestPath,
result.activationPath,
];
if (new Set(authorityPaths).size !== authorityPaths.length) {
throw new LocalApplicationProcessConfigError(
'storage authority paths must be distinct',
);
}
return Object.freeze(result);
}
function cutoverConfig(
value: unknown,
): Readonly<LocalApplicationProcessCutoverConfig> {
const cutover = record(value, 'cutover');
exactKeys(
cutover,
['commitmentPath', 'cutoverId', 'expectedCommitmentDigest'],
'cutover',
);
if (
typeof cutover.cutoverId !== 'string' ||
!INSTANCE_ID_PATTERN.test(cutover.cutoverId)
) {
throw new LocalApplicationProcessConfigError('cutoverId is invalid');
}
if (
typeof cutover.expectedCommitmentDigest !== 'string' ||
!DIGEST_PATTERN.test(cutover.expectedCommitmentDigest)
) {
throw new LocalApplicationProcessConfigError(
'expectedCommitmentDigest is invalid',
);
}
return Object.freeze({
cutoverId: cutover.cutoverId,
commitmentPath: absolutePath(cutover.commitmentPath, 'commitmentPath'),
expectedCommitmentDigest: cutover.expectedCommitmentDigest,
});
}
function runtimeConfig(
value: unknown,
): Readonly<LocalApplicationProcessRuntimeConfig> {
const runtime = record(value, 'runtime');
exactKeys(
runtime,
['artifactRoot', 'receiptRoot', 'secretKeyringPath'],
'runtime',
);
const result = {
receiptRoot: absolutePath(runtime.receiptRoot, 'receiptRoot'),
artifactRoot: absolutePath(runtime.artifactRoot, 'artifactRoot'),
secretKeyringPath: absolutePath(
runtime.secretKeyringPath,
'secretKeyringPath',
),
};
if (new Set(Object.values(result)).size !== Object.keys(result).length) {
throw new LocalApplicationProcessConfigError(
'runtime authority paths must be distinct',
);
}
return Object.freeze(result);
}
function pluginPackageConfig(
value: unknown,
): Readonly<LocalApplicationProcessPluginPackageConfig> {
const pluginPackages = record(value, 'pluginPackages');
const optionalKeys = [
'maxPages',
'pageSize',
'taskPublicationMaxPages',
'taskPublicationPageSize',
].filter((key) => Object.hasOwn(pluginPackages, key));
exactKeys(
pluginPackages,
['activationRoot', 'recoverySource', 'stagingRoot', ...optionalKeys],
'pluginPackages',
);
const stagingRoot = absolutePath(pluginPackages.stagingRoot, 'stagingRoot');
const activationRoot = absolutePath(
pluginPackages.activationRoot,
'activationRoot',
);
if (stagingRoot === activationRoot) {
throw new LocalApplicationProcessConfigError(
'Plugin Package authority roots must be distinct',
);
}
const recoverySourceValue = record(
pluginPackages.recoverySource,
'Plugin Package recovery source',
);
let recoverySource: LocalApplicationProcessPluginPackageRecoverySourceConfig;
if (recoverySourceValue.mode === 'disabled') {
exactKeys(
recoverySourceValue,
['mode'],
'disabled Plugin Package recovery source',
);
recoverySource = Object.freeze({ mode: 'disabled' as const });
} else if (recoverySourceValue.mode === 'materialized_catalog') {
exactKeys(
recoverySourceValue,
['bundleRoot', 'catalogRoot', 'mode', 'publisherTrustFilePath'],
'materialized Plugin Package recovery source',
);
const catalogRoot = absolutePath(
recoverySourceValue.catalogRoot,
'Plugin Package catalogRoot',
);
const publisherTrustFilePath = absolutePath(
recoverySourceValue.publisherTrustFilePath,
'Plugin Package publisherTrustFilePath',
);
const bundleRoot = absolutePath(
recoverySourceValue.bundleRoot,
'Plugin Package bundleRoot',
);
if (
new Set([
stagingRoot,
activationRoot,
catalogRoot,
bundleRoot,
publisherTrustFilePath,
]).size !== 5
) {
throw new LocalApplicationProcessConfigError(
'Plugin Package recovery authorities must be distinct',
);
}
recoverySource = Object.freeze({
mode: 'materialized_catalog' as const,
catalogRoot,
bundleRoot,
publisherTrustFilePath,
});
} else {
throw new LocalApplicationProcessConfigError(
'Plugin Package recovery source mode is invalid',
);
}
const pageSize = optionalInteger(
pluginPackages.pageSize,
1,
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE,
'Plugin Package recovery pageSize',
);
const maxPages = optionalInteger(
pluginPackages.maxPages,
1,
MAX_PLUGIN_PACKAGE_RECOVERY_PAGES,
'Plugin Package recovery maxPages',
);
const taskPublicationPageSize = optionalInteger(
pluginPackages.taskPublicationPageSize,
1,
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE,
'Plugin Package Task publication pageSize',
);
const taskPublicationMaxPages = optionalInteger(
pluginPackages.taskPublicationMaxPages,
1,
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES,
'Plugin Package Task publication maxPages',
);
return Object.freeze({
stagingRoot,
activationRoot,
recoverySource,
...(pageSize === undefined ? {} : { pageSize }),
...(maxPages === undefined ? {} : { maxPages }),
...(taskPublicationPageSize === undefined
? {}
: { taskPublicationPageSize }),
...(taskPublicationMaxPages === undefined
? {}
: { taskPublicationMaxPages }),
});
}
function aiConfig(value: unknown): LocalApplicationProcessAiConfig {
const ai = record(value, 'ai');
if (ai.deployment === 'excluded') {
exactKeys(ai, ['deployment'], 'excluded AI deployment');
return Object.freeze({ deployment: 'excluded' as const });
}
if (ai.deployment !== 'installed') {
throw new LocalApplicationProcessConfigError(
'AI deployment must be excluded or installed',
);
}
const optionalKeys = [
'drainPollMs',
'drainTimeoutMs',
'maxConcurrent',
'recoveryLimit',
].filter((key) => Object.hasOwn(ai, key));
exactKeys(ai, ['deployment', ...optionalKeys], 'installed AI deployment');
const maxConcurrent = optionalInteger(
ai.maxConcurrent,
1,
64,
'AI maxConcurrent',
);
const recoveryLimit = optionalInteger(
ai.recoveryLimit,
1,
128,
'AI recoveryLimit',
);
const drainTimeoutMs = optionalInteger(
ai.drainTimeoutMs,
100,
60_000,
'AI drainTimeoutMs',
);
const drainPollMs = optionalInteger(
ai.drainPollMs,
10,
1_000,
'AI drainPollMs',
);
return Object.freeze({
deployment: 'installed' as const,
...(maxConcurrent === undefined ? {} : { maxConcurrent }),
...(recoveryLimit === undefined ? {} : { recoveryLimit }),
...(drainTimeoutMs === undefined ? {} : { drainTimeoutMs }),
...(drainPollMs === undefined ? {} : { drainPollMs }),
});
}
export function normalizeLocalApplicationProcessConfig(
value: unknown,
): Readonly<LocalApplicationProcessConfig> {
const config = record(value, 'configuration');
if (
config.schema !== LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA &&
config.schema !== LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V2 &&
config.schema !== LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3
) {
throw new LocalApplicationProcessConfigError('schema is invalid');
}
exactKeys(
config,
[
'ai',
...(config.schema === LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3
? ['cutover']
: []),
'instanceId',
'pluginPackages',
'profile',
'runtime',
'schema',
'storage',
],
'configuration',
);
if (
typeof config.instanceId !== 'string' ||
!INSTANCE_ID_PATTERN.test(config.instanceId)
) {
throw new LocalApplicationProcessConfigError('instanceId is invalid');
}
if (config.profile !== 'edge' && config.profile !== 'standalone') {
throw new LocalApplicationProcessConfigError('profile is invalid');
}
if (
config.schema === LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3 &&
record(config.storage, 'storage').mode !== 'adopted'
) {
throw new LocalApplicationProcessConfigError(
'v3 configuration requires adopted storage',
);
}
const normalized = {
schema: config.schema,
instanceId: config.instanceId,
profile: config.profile,
storage: storageConfig(config.storage, config.schema),
runtime: runtimeConfig(config.runtime),
pluginPackages: pluginPackageConfig(config.pluginPackages),
ai: aiConfig(config.ai),
...(config.schema === LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3
? { cutover: cutoverConfig(config.cutover) }
: {}),
} as const;
const authorityPaths = [
...(normalized.storage.mode === 'fresh'
? [normalized.storage.databasePath]
: [
normalized.storage.sourcePath,
normalized.storage.targetPath,
normalized.storage.recoveryPath,
normalized.storage.manifestPath,
normalized.storage.activationPath,
]),
normalized.runtime.receiptRoot,
normalized.runtime.artifactRoot,
normalized.runtime.secretKeyringPath,
normalized.pluginPackages.stagingRoot,
normalized.pluginPackages.activationRoot,
...(normalized.cutover === undefined
? []
: [normalized.cutover.commitmentPath]),
...(normalized.pluginPackages.recoverySource.mode ===
'materialized_catalog'
? [
normalized.pluginPackages.recoverySource.catalogRoot,
normalized.pluginPackages.recoverySource.bundleRoot,
normalized.pluginPackages.recoverySource.publisherTrustFilePath,
]
: []),
];
if (new Set(authorityPaths).size !== authorityPaths.length) {
throw new LocalApplicationProcessConfigError(
'process authority paths must be distinct',
);
}
return Object.freeze(normalized);
}
export function loadLocalApplicationProcessConfig(
configFilePath: string,
): Readonly<LocalApplicationProcessConfig> {
return normalizeLocalApplicationProcessConfig(
readPrivateLocalCommandFile(configFilePath),
);
}
@@ -0,0 +1,290 @@
import crypto from 'node:crypto';
import path from 'node:path';
import type { LocalApplicationProfile } from '../application-runtime/contract';
import { publishLocalApplicationLifecycleReceiptFile } from './lifecycleReceiptFile';
import {
observeLocalApplicationStartup,
type LocalApplicationStartupObservation,
} from './startupReceipt';
export const LOCAL_APPLICATION_SHUTDOWN_RECEIPT_SCHEMA =
'qinglong/local-application-shutdown-receipt@v1' as const;
export const MAX_LOCAL_APPLICATION_SHUTDOWN_RECEIPT_BYTES = 4096;
const MAX_PATH_BYTES = 4096;
const BOOT_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const START_TICKS_PATTERN = /^[1-9][0-9]{0,19}$/;
export type LocalApplicationShutdownSignal = 'SIGINT' | 'SIGTERM';
export interface LocalApplicationShutdownObservation
extends Omit<LocalApplicationStartupObservation, 'activeBootAgeMs'> {
readonly stoppedBootAgeMs: number;
}
export interface LocalApplicationShutdownReceipt
extends LocalApplicationShutdownObservation {
readonly schemaVersion: 1;
readonly schema: typeof LOCAL_APPLICATION_SHUTDOWN_RECEIPT_SCHEMA;
readonly instanceId: string;
readonly profile: LocalApplicationProfile;
readonly signal: LocalApplicationShutdownSignal;
readonly stopResult: 'stopped';
readonly startupReceiptDigest: string;
readonly sha256: string;
}
export class LocalApplicationShutdownReceiptError extends Error {
readonly code = 'QL3_LOCAL_APPLICATION_SHUTDOWN_RECEIPT_UNAVAILABLE';
constructor(message: string, options?: ErrorOptions) {
super(
`Local application shutdown receipt is unavailable: ${message}`,
options,
);
this.name = 'LocalApplicationShutdownReceiptError';
}
}
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 boundedAbsolutePath(value: string, label: string): string {
if (
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') < 1 ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new LocalApplicationShutdownReceiptError(
`${label} must be a normalized bounded absolute path`,
);
}
return value;
}
function canonicalDigest(
value: Omit<LocalApplicationShutdownReceipt, 'sha256'>,
): string {
return crypto
.createHash('sha256')
.update('qinglong.local-application-shutdown-receipt.v1\0', 'utf8')
.update(JSON.stringify(value), 'utf8')
.digest('hex');
}
function withoutDigest(
receipt: Readonly<LocalApplicationShutdownReceipt>,
): Omit<LocalApplicationShutdownReceipt, 'sha256'> {
return Object.freeze({
schemaVersion: receipt.schemaVersion,
schema: receipt.schema,
instanceId: receipt.instanceId,
profile: receipt.profile,
signal: receipt.signal,
stopResult: receipt.stopResult,
startupReceiptDigest: receipt.startupReceiptDigest,
bootId: receipt.bootId,
stoppedBootAgeMs: receipt.stoppedBootAgeMs,
processId: receipt.processId,
processStartTicks: receipt.processStartTicks,
nodeExecutable: receipt.nodeExecutable,
nodeVersion: receipt.nodeVersion,
});
}
export function observeLocalApplicationShutdown(
procRoot = '/proc',
): Readonly<LocalApplicationShutdownObservation> | undefined {
const observed = observeLocalApplicationStartup(procRoot);
if (observed === undefined) return undefined;
const { activeBootAgeMs, ...identity } = observed;
return Object.freeze({ ...identity, stoppedBootAgeMs: activeBootAgeMs });
}
export function buildLocalApplicationShutdownReceipt(options: {
readonly instanceId: string;
readonly profile: LocalApplicationProfile;
readonly signal: LocalApplicationShutdownSignal;
readonly startupReceiptDigest: string;
readonly observation: Readonly<LocalApplicationShutdownObservation>;
}): Readonly<LocalApplicationShutdownReceipt> {
const body = Object.freeze({
schemaVersion: 1 as const,
schema: LOCAL_APPLICATION_SHUTDOWN_RECEIPT_SCHEMA,
instanceId: options.instanceId,
profile: options.profile,
signal: options.signal,
stopResult: 'stopped' as const,
startupReceiptDigest: options.startupReceiptDigest,
bootId: options.observation.bootId,
stoppedBootAgeMs: options.observation.stoppedBootAgeMs,
processId: options.observation.processId,
processStartTicks: options.observation.processStartTicks,
nodeExecutable: options.observation.nodeExecutable,
nodeVersion: options.observation.nodeVersion,
});
return Object.freeze({ ...body, sha256: canonicalDigest(body) });
}
export function localApplicationShutdownReceiptPath(
configFilePath: string,
): string {
return `${boundedAbsolutePath(
configFilePath,
'application configuration path',
)}.stopped.json`;
}
export function parseLocalApplicationShutdownReceipt(
contents: string,
): Readonly<LocalApplicationShutdownReceipt> {
if (
typeof contents !== 'string' ||
Buffer.byteLength(contents, 'utf8') < 1 ||
Buffer.byteLength(contents, 'utf8') >
MAX_LOCAL_APPLICATION_SHUTDOWN_RECEIPT_BYTES
) {
throw new LocalApplicationShutdownReceiptError(
'receipt is outside its byte limit',
);
}
let value: unknown;
try {
value = JSON.parse(contents);
} catch (error) {
throw new LocalApplicationShutdownReceiptError('receipt is not JSON', {
cause: error,
});
}
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'bootId',
'instanceId',
'nodeExecutable',
'nodeVersion',
'processId',
'processStartTicks',
'profile',
'schema',
'schemaVersion',
'sha256',
'signal',
'stoppedBootAgeMs',
'stopResult',
'startupReceiptDigest',
])
) {
throw new LocalApplicationShutdownReceiptError('receipt shape is invalid');
}
const candidate = value as Record<string, unknown>;
if (
candidate.schemaVersion !== 1 ||
candidate.schema !== LOCAL_APPLICATION_SHUTDOWN_RECEIPT_SCHEMA ||
typeof candidate.instanceId !== 'string' ||
candidate.instanceId.length < 1 ||
Buffer.byteLength(candidate.instanceId, 'utf8') > 128 ||
(candidate.profile !== 'edge' && candidate.profile !== 'standalone') ||
(candidate.signal !== 'SIGINT' && candidate.signal !== 'SIGTERM') ||
candidate.stopResult !== 'stopped' ||
typeof candidate.startupReceiptDigest !== 'string' ||
!SHA256_PATTERN.test(candidate.startupReceiptDigest) ||
typeof candidate.bootId !== 'string' ||
!BOOT_ID_PATTERN.test(candidate.bootId) ||
!Number.isSafeInteger(candidate.stoppedBootAgeMs) ||
(candidate.stoppedBootAgeMs as number) < 0 ||
(candidate.stoppedBootAgeMs as number) > 31_536_000_000 ||
!Number.isSafeInteger(candidate.processId) ||
(candidate.processId as number) < 1 ||
(candidate.processId as number) > 4_194_304 ||
typeof candidate.processStartTicks !== 'string' ||
!START_TICKS_PATTERN.test(candidate.processStartTicks) ||
typeof candidate.nodeExecutable !== 'string' ||
typeof candidate.nodeVersion !== 'string' ||
!/^v24\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(
candidate.nodeVersion,
) ||
typeof candidate.sha256 !== 'string' ||
!SHA256_PATTERN.test(candidate.sha256)
) {
throw new LocalApplicationShutdownReceiptError(
'receipt values are invalid',
);
}
boundedAbsolutePath(candidate.nodeExecutable, 'Node executable');
const receipt = Object.freeze({
schemaVersion: 1 as const,
schema: LOCAL_APPLICATION_SHUTDOWN_RECEIPT_SCHEMA,
instanceId: candidate.instanceId,
profile: candidate.profile,
signal: candidate.signal,
stopResult: 'stopped' as const,
startupReceiptDigest: candidate.startupReceiptDigest,
bootId: candidate.bootId,
stoppedBootAgeMs: candidate.stoppedBootAgeMs as number,
processId: candidate.processId as number,
processStartTicks: candidate.processStartTicks,
nodeExecutable: candidate.nodeExecutable,
nodeVersion: candidate.nodeVersion,
sha256: candidate.sha256,
});
if (canonicalDigest(withoutDigest(receipt)) !== receipt.sha256) {
throw new LocalApplicationShutdownReceiptError('receipt digest is invalid');
}
return receipt;
}
export function publishLocalApplicationShutdownReceipt(
configFilePath: string,
receipt: Readonly<LocalApplicationShutdownReceipt>,
): string {
const targetPath = localApplicationShutdownReceiptPath(configFilePath);
const normalized = parseLocalApplicationShutdownReceipt(
`${JSON.stringify(receipt)}\n`,
);
return publishLocalApplicationLifecycleReceiptFile({
targetPath,
contents: `${JSON.stringify(normalized)}\n`,
maximumBytes: MAX_LOCAL_APPLICATION_SHUTDOWN_RECEIPT_BYTES,
isFailure: (error) => error instanceof LocalApplicationShutdownReceiptError,
fail(message, cause) {
throw new LocalApplicationShutdownReceiptError(
message,
cause === undefined ? undefined : { cause },
);
},
});
}
export function recordLocalApplicationShutdownReceipt(options: {
readonly configFilePath: string;
readonly instanceId: string;
readonly profile: LocalApplicationProfile;
readonly signal: LocalApplicationShutdownSignal;
readonly startupReceiptDigest: string;
}): Readonly<LocalApplicationShutdownReceipt> | undefined {
const observation = observeLocalApplicationShutdown();
if (observation === undefined) return undefined;
const receipt = buildLocalApplicationShutdownReceipt({
instanceId: options.instanceId,
profile: options.profile,
signal: options.signal,
startupReceiptDigest: options.startupReceiptDigest,
observation,
});
publishLocalApplicationShutdownReceipt(options.configFilePath, receipt);
return receipt;
}
@@ -0,0 +1,374 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { LocalApplicationProfile } from '../application-runtime/contract';
import { publishLocalApplicationLifecycleReceiptFile } from './lifecycleReceiptFile';
export const LOCAL_APPLICATION_STARTUP_RECEIPT_SCHEMA =
'qinglong/local-application-startup-receipt@v1' as const;
export const MAX_LOCAL_APPLICATION_STARTUP_RECEIPT_BYTES = 4096;
const MAX_PATH_BYTES = 4096;
const BOOT_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const START_TICKS_PATTERN = /^[1-9][0-9]{0,19}$/;
export type LocalApplicationStartupAiStatus =
| 'deployment_excluded'
| 'schema_absent'
| 'inactive'
| 'active';
export interface LocalApplicationStartupObservation {
readonly bootId: string;
readonly activeBootAgeMs: number;
readonly processId: number;
readonly processStartTicks: string;
readonly nodeExecutable: string;
readonly nodeVersion: string;
}
export interface LocalApplicationStartupReceipt
extends LocalApplicationStartupObservation {
readonly schemaVersion: 1;
readonly schema: typeof LOCAL_APPLICATION_STARTUP_RECEIPT_SCHEMA;
readonly instanceId: string;
readonly profile: LocalApplicationProfile;
readonly aiStatus: LocalApplicationStartupAiStatus;
readonly sha256: string;
}
export class LocalApplicationStartupReceiptError extends Error {
readonly code = 'QL3_LOCAL_APPLICATION_STARTUP_RECEIPT_UNAVAILABLE';
constructor(message: string, options?: ErrorOptions) {
super(
`Local application startup receipt is unavailable: ${message}`,
options,
);
this.name = 'LocalApplicationStartupReceiptError';
}
}
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 boundedAbsolutePath(value: string, label: string): string {
if (
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') < 1 ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new LocalApplicationStartupReceiptError(
`${label} must be a normalized bounded absolute path`,
);
}
return value;
}
function canonicalDigest(
value: Omit<LocalApplicationStartupReceipt, 'sha256'>,
): string {
return crypto
.createHash('sha256')
.update('qinglong.local-application-startup-receipt.v1\0', 'utf8')
.update(JSON.stringify(value), 'utf8')
.digest('hex');
}
function receiptWithoutDigest(
receipt: Readonly<LocalApplicationStartupReceipt>,
): Omit<LocalApplicationStartupReceipt, 'sha256'> {
return Object.freeze({
schemaVersion: receipt.schemaVersion,
schema: receipt.schema,
instanceId: receipt.instanceId,
profile: receipt.profile,
aiStatus: receipt.aiStatus,
bootId: receipt.bootId,
activeBootAgeMs: receipt.activeBootAgeMs,
processId: receipt.processId,
processStartTicks: receipt.processStartTicks,
nodeExecutable: receipt.nodeExecutable,
nodeVersion: receipt.nodeVersion,
});
}
export function parseLinuxProcessStartTicks(contents: string): string {
if (
typeof contents !== 'string' ||
contents.length < 8 ||
Buffer.byteLength(contents, 'utf8') > 4096
) {
throw new LocalApplicationStartupReceiptError(
'Linux process stat is invalid',
);
}
const commandEnd = contents.lastIndexOf(') ');
if (commandEnd < 2) {
throw new LocalApplicationStartupReceiptError(
'Linux process stat is invalid',
);
}
const fields = contents
.slice(commandEnd + 2)
.trim()
.split(/\s+/u);
const startTicks = fields[19];
if (!startTicks || !START_TICKS_PATTERN.test(startTicks)) {
throw new LocalApplicationStartupReceiptError(
'Linux process start ticks are invalid',
);
}
return startTicks;
}
function readBoundedUtf8(filePath: string, maximumBytes: number): string {
const material = fs.readFileSync(filePath);
try {
if (material.byteLength < 1 || material.byteLength > maximumBytes) {
throw new LocalApplicationStartupReceiptError(
'Linux startup observation is outside its byte limit',
);
}
return new TextDecoder('utf-8', { fatal: true }).decode(material).trim();
} catch (error) {
if (error instanceof LocalApplicationStartupReceiptError) throw error;
throw new LocalApplicationStartupReceiptError(
'Linux startup observation is not UTF-8',
{ cause: error },
);
} finally {
material.fill(0);
}
}
export function observeLocalApplicationStartup(
procRoot = '/proc',
): Readonly<LocalApplicationStartupObservation> | undefined {
if (process.platform !== 'linux') return undefined;
try {
const bootId = readBoundedUtf8(
path.join(procRoot, 'sys/kernel/random/boot_id'),
128,
).toLowerCase();
if (!BOOT_ID_PATTERN.test(bootId)) {
throw new LocalApplicationStartupReceiptError(
'Linux boot identity is invalid',
);
}
const uptimeValue = readBoundedUtf8(
path.join(procRoot, 'uptime'),
256,
).split(/\s+/u)[0];
const uptimeSeconds =
uptimeValue === undefined ? Number.NaN : Number(uptimeValue);
const activeBootAgeMs = Math.round(uptimeSeconds * 1000);
if (
!Number.isSafeInteger(activeBootAgeMs) ||
activeBootAgeMs < 0 ||
activeBootAgeMs > 31_536_000_000
) {
throw new LocalApplicationStartupReceiptError(
'Linux boot age is invalid',
);
}
const processId = process.pid;
const processStartTicks = parseLinuxProcessStartTicks(
readBoundedUtf8(path.join(procRoot, String(processId), 'stat'), 4096),
);
const nodeExecutable = boundedAbsolutePath(
fs.realpathSync(path.join(procRoot, String(processId), 'exe')),
'Node executable',
);
return Object.freeze({
bootId,
activeBootAgeMs,
processId,
processStartTicks,
nodeExecutable,
nodeVersion: process.version,
});
} catch (error) {
if (error instanceof LocalApplicationStartupReceiptError) throw error;
throw new LocalApplicationStartupReceiptError(
'Linux startup observation cannot be read',
{ cause: error },
);
}
}
export function buildLocalApplicationStartupReceipt(options: {
readonly instanceId: string;
readonly profile: LocalApplicationProfile;
readonly aiStatus: LocalApplicationStartupAiStatus;
readonly observation: Readonly<LocalApplicationStartupObservation>;
}): Readonly<LocalApplicationStartupReceipt> {
const withoutDigest = Object.freeze({
schemaVersion: 1 as const,
schema: LOCAL_APPLICATION_STARTUP_RECEIPT_SCHEMA,
instanceId: options.instanceId,
profile: options.profile,
aiStatus: options.aiStatus,
...options.observation,
});
return Object.freeze({
...withoutDigest,
sha256: canonicalDigest(withoutDigest),
});
}
export function localApplicationStartupReceiptPath(
configFilePath: string,
): string {
const configPath = boundedAbsolutePath(
configFilePath,
'application configuration path',
);
return `${configPath}.active.json`;
}
export function publishLocalApplicationStartupReceipt(
configFilePath: string,
receipt: Readonly<LocalApplicationStartupReceipt>,
): string {
const targetPath = localApplicationStartupReceiptPath(configFilePath);
const normalized = parseLocalApplicationStartupReceipt(
`${JSON.stringify(receipt)}\n`,
);
return publishLocalApplicationLifecycleReceiptFile({
targetPath,
contents: `${JSON.stringify(normalized)}\n`,
maximumBytes: MAX_LOCAL_APPLICATION_STARTUP_RECEIPT_BYTES,
isFailure: (error) => error instanceof LocalApplicationStartupReceiptError,
fail(message, cause) {
throw new LocalApplicationStartupReceiptError(
message,
cause === undefined ? undefined : { cause },
);
},
});
}
export function parseLocalApplicationStartupReceipt(
contents: string,
): Readonly<LocalApplicationStartupReceipt> {
if (
typeof contents !== 'string' ||
Buffer.byteLength(contents, 'utf8') < 1 ||
Buffer.byteLength(contents, 'utf8') >
MAX_LOCAL_APPLICATION_STARTUP_RECEIPT_BYTES
) {
throw new LocalApplicationStartupReceiptError(
'receipt is outside its byte limit',
);
}
let value: unknown;
try {
value = JSON.parse(contents);
} catch (error) {
throw new LocalApplicationStartupReceiptError('receipt is not JSON', {
cause: error,
});
}
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'activeBootAgeMs',
'aiStatus',
'bootId',
'instanceId',
'nodeExecutable',
'nodeVersion',
'processId',
'processStartTicks',
'profile',
'schema',
'schemaVersion',
'sha256',
])
) {
throw new LocalApplicationStartupReceiptError('receipt shape is invalid');
}
const candidate = value as Record<string, unknown>;
if (
candidate.schemaVersion !== 1 ||
candidate.schema !== LOCAL_APPLICATION_STARTUP_RECEIPT_SCHEMA ||
typeof candidate.instanceId !== 'string' ||
candidate.instanceId.length < 1 ||
Buffer.byteLength(candidate.instanceId, 'utf8') > 128 ||
(candidate.profile !== 'edge' && candidate.profile !== 'standalone') ||
(candidate.aiStatus !== 'deployment_excluded' &&
candidate.aiStatus !== 'schema_absent' &&
candidate.aiStatus !== 'inactive' &&
candidate.aiStatus !== 'active') ||
typeof candidate.bootId !== 'string' ||
!BOOT_ID_PATTERN.test(candidate.bootId) ||
!Number.isSafeInteger(candidate.activeBootAgeMs) ||
(candidate.activeBootAgeMs as number) < 0 ||
(candidate.activeBootAgeMs as number) > 31_536_000_000 ||
!Number.isSafeInteger(candidate.processId) ||
(candidate.processId as number) < 1 ||
(candidate.processId as number) > 4_194_304 ||
typeof candidate.processStartTicks !== 'string' ||
!START_TICKS_PATTERN.test(candidate.processStartTicks) ||
typeof candidate.nodeExecutable !== 'string' ||
typeof candidate.nodeVersion !== 'string' ||
!/^v24\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(
candidate.nodeVersion,
) ||
typeof candidate.sha256 !== 'string' ||
!SHA256_PATTERN.test(candidate.sha256)
) {
throw new LocalApplicationStartupReceiptError('receipt values are invalid');
}
boundedAbsolutePath(candidate.nodeExecutable, 'Node executable');
const receipt = Object.freeze({
schemaVersion: 1 as const,
schema: LOCAL_APPLICATION_STARTUP_RECEIPT_SCHEMA,
instanceId: candidate.instanceId,
profile: candidate.profile,
aiStatus: candidate.aiStatus,
bootId: candidate.bootId,
activeBootAgeMs: candidate.activeBootAgeMs as number,
processId: candidate.processId as number,
processStartTicks: candidate.processStartTicks,
nodeExecutable: candidate.nodeExecutable,
nodeVersion: candidate.nodeVersion,
sha256: candidate.sha256,
});
if (canonicalDigest(receiptWithoutDigest(receipt)) !== receipt.sha256) {
throw new LocalApplicationStartupReceiptError('receipt digest is invalid');
}
return receipt;
}
export function recordLocalApplicationStartupReceipt(options: {
readonly configFilePath: string;
readonly instanceId: string;
readonly profile: LocalApplicationProfile;
readonly aiStatus: LocalApplicationStartupAiStatus;
}): Readonly<LocalApplicationStartupReceipt> | undefined {
const observation = observeLocalApplicationStartup();
if (observation === undefined) return undefined;
const receipt = buildLocalApplicationStartupReceipt({
instanceId: options.instanceId,
profile: options.profile,
aiStatus: options.aiStatus,
observation,
});
publishLocalApplicationStartupReceipt(options.configFilePath, receipt);
return receipt;
}