mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@qinglong/local-application",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 local application activation composition root",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./ai-feature": {
|
||||
"types": "./dist/application-runtime/aiFeatureApplication.d.ts",
|
||||
"require": "./dist/application-runtime/aiFeatureApplication.js",
|
||||
"default": "./dist/application-runtime/aiFeatureApplication.js"
|
||||
},
|
||||
"./process-config": {
|
||||
"types": "./dist/production-process/processConfig.d.ts",
|
||||
"require": "./dist/production-process/processConfig.js",
|
||||
"default": "./dist/production-process/processConfig.js"
|
||||
},
|
||||
"./plugin-package-recovery-catalog": {
|
||||
"types": "./dist/production-process/pluginPackageRecoveryCatalog.d.ts",
|
||||
"require": "./dist/production-process/pluginPackageRecoveryCatalog.js",
|
||||
"default": "./dist/production-process/pluginPackageRecoveryCatalog.js"
|
||||
},
|
||||
"./process": {
|
||||
"types": "./dist/production-process/processApplication.d.ts",
|
||||
"require": "./dist/production-process/processApplication.js",
|
||||
"default": "./dist/production-process/processApplication.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"bin": {
|
||||
"ql3-local-application": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qinglong/local-admin": "workspace:*",
|
||||
"@qinglong/local-command-file": "workspace:*",
|
||||
"@qinglong/local-execution": "workspace:*",
|
||||
"@qinglong/local-process": "workspace:*",
|
||||
"@qinglong/local-secret": "workspace:*",
|
||||
"@qinglong/local-sqlite": "workspace:*",
|
||||
"@qinglong/runtime-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@qinglong/ai": "workspace:*",
|
||||
"@qinglong/local-owner-cli": "workspace:*",
|
||||
"@qinglong/local-owner-console": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import { LocalExecutionCoordinator } from '@qinglong/local-execution/execution';
|
||||
import {
|
||||
LocalCompletionReceiptProcessor,
|
||||
LocalExecutionControlCoordinator,
|
||||
LocalExecutionControlLifecycle,
|
||||
LocalExecutionControlScanner,
|
||||
type LocalExecutionControlCycleSummary,
|
||||
} from '@qinglong/local-execution/control';
|
||||
import {
|
||||
LocalDispatchPlanMaterializer,
|
||||
LocalFileArtifactAllocator,
|
||||
LocalRunDispatcher,
|
||||
localArtifactCapacityPolicyForProfile,
|
||||
} from '@qinglong/local-execution/dispatch';
|
||||
import {
|
||||
LocalRunStartupRecoveryCoordinator,
|
||||
LocalWorkflowTaskStartupRecoveryCoordinator,
|
||||
type LocalRunStartupRecoverySummary,
|
||||
type LocalWorkflowTaskStartupRecoverySummary,
|
||||
} from '@qinglong/local-execution/recovery';
|
||||
import {
|
||||
LocalSchedulerCoordinator,
|
||||
LocalSchedulerLifecycle,
|
||||
LocalWorkflowSchedulerCoordinator,
|
||||
} from '@qinglong/local-execution/scheduler';
|
||||
import {
|
||||
CompletionReceiptFileStore,
|
||||
LocalCompletionReceiptCleanupScanner,
|
||||
LocalProcessController,
|
||||
LocalProcessPersistedExecutionInspector,
|
||||
LocalProcessLauncher,
|
||||
type LocalCompletionReceiptCleanupSummary,
|
||||
} from '@qinglong/local-process';
|
||||
import {
|
||||
EncryptedLocalSecretService,
|
||||
LocalSecretKeyringFileProvider,
|
||||
} from '@qinglong/local-secret';
|
||||
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 LocalApplicationActivationAudit,
|
||||
type LocalApplicationBootstrapOptions,
|
||||
type LocalApplicationBootstrapResult,
|
||||
type LocalApplicationEnabledBootstrapOptions,
|
||||
type LocalApplicationProfile,
|
||||
type LocalApplicationProductSurfaceLifecycle,
|
||||
type LocalApplicationStopResult,
|
||||
} from './contract';
|
||||
import {
|
||||
recoverLocalApplicationPluginPackages,
|
||||
type LocalApplicationPluginPackageStartup,
|
||||
} from './pluginPackageStartup';
|
||||
import {
|
||||
openLocalApplicationStorage,
|
||||
type LocalApplicationReadyStorage,
|
||||
} from './storageActivation';
|
||||
import { LocalApplicationStartupRecoveryRequiredError } from './startupErrors';
|
||||
export {
|
||||
LocalApplicationPluginPackageAutomationPublicationRequiredError,
|
||||
LocalApplicationPluginPackageRecoveryRequiredError,
|
||||
LocalApplicationPluginPackageTaskPublicationRequiredError,
|
||||
LocalApplicationPluginPackageToolSnapshotRequiredError,
|
||||
LocalApplicationStartupRecoveryRequiredError,
|
||||
} from './startupErrors';
|
||||
|
||||
const EXECUTION_CONTROL_POLICIES = Object.freeze({
|
||||
edge: Object.freeze({
|
||||
cleanupIntervalMs: 5 * 60_000,
|
||||
cleanupPageSize: 8,
|
||||
controlIntervalMs: 5_000,
|
||||
controlPageSize: 4,
|
||||
maxDrainPages: 2,
|
||||
retentionMs: 24 * 60 * 60_000,
|
||||
stopTimeoutMs: 5_000,
|
||||
}),
|
||||
standalone: Object.freeze({
|
||||
cleanupIntervalMs: 60_000,
|
||||
cleanupPageSize: 32,
|
||||
controlIntervalMs: 1_000,
|
||||
controlPageSize: 32,
|
||||
maxDrainPages: 8,
|
||||
retentionMs: 60 * 60_000,
|
||||
stopTimeoutMs: 10_000,
|
||||
}),
|
||||
});
|
||||
|
||||
function assertProfile(
|
||||
profile: unknown,
|
||||
): asserts profile is LocalApplicationProfile {
|
||||
if (profile !== 'edge' && profile !== 'standalone') {
|
||||
throw new TypeError('Local application Profile is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function assertEnabledBoundary(
|
||||
options: LocalApplicationBootstrapOptions,
|
||||
): void {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError('Local application bootstrap options are invalid');
|
||||
}
|
||||
if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
|
||||
throw new TypeError('Local application enabled flag is invalid');
|
||||
}
|
||||
assertProfile(options.profile);
|
||||
if (typeof options.applicationAudit !== 'function') {
|
||||
throw new TypeError('Local application audit sink is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function assertActiveBoundary(
|
||||
options: LocalApplicationBootstrapOptions,
|
||||
): asserts options is LocalApplicationEnabledBootstrapOptions {
|
||||
if (options.enabled !== true) {
|
||||
throw new TypeError('Local application enabled configuration is invalid');
|
||||
}
|
||||
if (typeof options.audit !== 'function') {
|
||||
throw new TypeError('Local application storage audit sink is invalid');
|
||||
}
|
||||
if (
|
||||
options.storageMode !== 'fresh' &&
|
||||
typeof options.adoptionAudit !== 'function'
|
||||
) {
|
||||
throw new TypeError('Local application adoption audit sink is invalid');
|
||||
}
|
||||
if (
|
||||
options.storageMode === 'fresh' &&
|
||||
typeof options.databasePath !== 'string'
|
||||
) {
|
||||
throw new TypeError('Local application fresh database path is invalid');
|
||||
}
|
||||
if (typeof options.receiptRoot !== 'string') {
|
||||
throw new TypeError('Local application receipt root is invalid');
|
||||
}
|
||||
if (typeof options.artifactRoot !== 'string') {
|
||||
throw new TypeError('Local application Artifact root is invalid');
|
||||
}
|
||||
if (typeof options.secretKeyringPath !== 'string') {
|
||||
throw new TypeError('Local application Secret keyring path is invalid');
|
||||
}
|
||||
if (
|
||||
options.productSurface !== undefined &&
|
||||
typeof options.productSurface?.start !== 'function'
|
||||
) {
|
||||
throw new TypeError('Local application product surface is invalid');
|
||||
}
|
||||
const pluginPackages = options.pluginPackages;
|
||||
if (
|
||||
!pluginPackages ||
|
||||
typeof pluginPackages !== 'object' ||
|
||||
Array.isArray(pluginPackages) ||
|
||||
Object.keys(pluginPackages).some(
|
||||
(key) =>
|
||||
![
|
||||
'stageProvider',
|
||||
'stagingRoot',
|
||||
'activationRoot',
|
||||
'now',
|
||||
'pageSize',
|
||||
'maxPages',
|
||||
'taskPublicationPageSize',
|
||||
'taskPublicationMaxPages',
|
||||
].includes(key),
|
||||
) ||
|
||||
!pluginPackages.stageProvider ||
|
||||
typeof pluginPackages.stageProvider.stage !== 'function' ||
|
||||
typeof pluginPackages.stagingRoot !== 'string' ||
|
||||
typeof pluginPackages.activationRoot !== 'string' ||
|
||||
typeof pluginPackages.now !== 'function' ||
|
||||
(pluginPackages.pageSize !== undefined &&
|
||||
(!Number.isSafeInteger(pluginPackages.pageSize) ||
|
||||
pluginPackages.pageSize < 1 ||
|
||||
pluginPackages.pageSize >
|
||||
MAX_PLUGIN_PACKAGE_INSTALL_RECOVERY_PAGE_SIZE)) ||
|
||||
(pluginPackages.maxPages !== undefined &&
|
||||
(!Number.isSafeInteger(pluginPackages.maxPages) ||
|
||||
pluginPackages.maxPages < 1 ||
|
||||
pluginPackages.maxPages > MAX_PLUGIN_PACKAGE_RECOVERY_PAGES)) ||
|
||||
(pluginPackages.taskPublicationPageSize !== undefined &&
|
||||
(!Number.isSafeInteger(pluginPackages.taskPublicationPageSize) ||
|
||||
pluginPackages.taskPublicationPageSize < 1 ||
|
||||
pluginPackages.taskPublicationPageSize >
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE)) ||
|
||||
(pluginPackages.taskPublicationMaxPages !== undefined &&
|
||||
(!Number.isSafeInteger(pluginPackages.taskPublicationMaxPages) ||
|
||||
pluginPackages.taskPublicationMaxPages < 1 ||
|
||||
pluginPackages.taskPublicationMaxPages >
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES))
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local application Plugin Package recovery configuration is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function oneOrAggregate(errors: unknown[], message: string): unknown {
|
||||
return errors.length === 1 ? errors[0] : new AggregateError(errors, message);
|
||||
}
|
||||
|
||||
async function bestEffortAudit(
|
||||
options: LocalApplicationBootstrapOptions,
|
||||
record: LocalApplicationActivationAudit,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await options.applicationAudit(record);
|
||||
} catch {
|
||||
// Diagnostics cannot replace the activation or shutdown result.
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapLocalApplication(
|
||||
options: LocalApplicationBootstrapOptions,
|
||||
): Promise<LocalApplicationBootstrapResult> {
|
||||
assertEnabledBoundary(options);
|
||||
if (options.enabled !== true) {
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'disabled',
|
||||
});
|
||||
return Object.freeze({
|
||||
status: 'disabled' as const,
|
||||
profile: options.profile,
|
||||
stop: async () => 'stopped' as const,
|
||||
});
|
||||
}
|
||||
assertActiveBoundary(options);
|
||||
|
||||
let storage: LocalApplicationReadyStorage | undefined;
|
||||
let schedulerLifecycle: LocalSchedulerLifecycle | undefined;
|
||||
let executionControlLifecycle: LocalExecutionControlLifecycle | undefined;
|
||||
let runRecovery: LocalRunStartupRecoverySummary | undefined;
|
||||
let workflowTaskRecovery: LocalWorkflowTaskStartupRecoverySummary | undefined;
|
||||
let receiptCleanup: LocalCompletionReceiptCleanupSummary | undefined;
|
||||
let executionControl: LocalExecutionControlCycleSummary | undefined;
|
||||
let pluginPackageStartup: LocalApplicationPluginPackageStartup | undefined;
|
||||
let productSurfaceLifecycle:
|
||||
| Readonly<LocalApplicationProductSurfaceLifecycle>
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
storage = await openLocalApplicationStorage(options);
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'storage_ready',
|
||||
});
|
||||
|
||||
pluginPackageStartup = await recoverLocalApplicationPluginPackages(
|
||||
options,
|
||||
storage,
|
||||
);
|
||||
|
||||
const secretKeys = new LocalSecretKeyringFileProvider(
|
||||
options.secretKeyringPath,
|
||||
);
|
||||
const activeSecretKey = await secretKeys.active();
|
||||
activeSecretKey.key.fill(0);
|
||||
const localSecrets = new EncryptedLocalSecretService(
|
||||
storage.localSecrets,
|
||||
secretKeys,
|
||||
);
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'secrets_ready',
|
||||
});
|
||||
|
||||
const executionPolicy = EXECUTION_CONTROL_POLICIES[options.profile];
|
||||
const receipts = new CompletionReceiptFileStore(options.receiptRoot);
|
||||
const localProcessLauncher = new LocalProcessLauncher(
|
||||
storage.completionReceipts,
|
||||
{
|
||||
receiptRoot: options.receiptRoot,
|
||||
},
|
||||
);
|
||||
const localProcessController = new LocalProcessController();
|
||||
const workflowRuntime = await storage.pluginPackageWorkflowRuntime();
|
||||
const localProcess = new LocalExecutionCoordinator(
|
||||
storage.runs,
|
||||
localProcessLauncher,
|
||||
localProcessController,
|
||||
{ workflowTasks: workflowRuntime.executions },
|
||||
);
|
||||
const completionProcessor = new LocalCompletionReceiptProcessor(
|
||||
storage.runs,
|
||||
receipts,
|
||||
{
|
||||
journal: storage.completionReceipts,
|
||||
quarantineRetentionMs: executionPolicy.retentionMs,
|
||||
workflowTasks: workflowRuntime.executions,
|
||||
},
|
||||
);
|
||||
executionControlLifecycle = new LocalExecutionControlLifecycle(
|
||||
completionProcessor,
|
||||
new LocalExecutionControlScanner(
|
||||
storage.executionControl,
|
||||
new LocalExecutionControlCoordinator(
|
||||
storage.runs,
|
||||
completionProcessor,
|
||||
localProcessController,
|
||||
{ workflowTasks: workflowRuntime.executions },
|
||||
),
|
||||
),
|
||||
new LocalCompletionReceiptCleanupScanner(
|
||||
storage.completionReceipts,
|
||||
receipts,
|
||||
{
|
||||
terminalMissingRetentionMs: executionPolicy.retentionMs,
|
||||
},
|
||||
),
|
||||
{
|
||||
intervalMs: executionPolicy.controlIntervalMs,
|
||||
pageSize: executionPolicy.controlPageSize,
|
||||
cleanupIntervalMs: executionPolicy.cleanupIntervalMs,
|
||||
cleanupPageSize: executionPolicy.cleanupPageSize,
|
||||
stopTimeoutMs: executionPolicy.stopTimeoutMs,
|
||||
maxDrainPages: executionPolicy.maxDrainPages,
|
||||
onDiagnostic: async (error) => {
|
||||
if (error === undefined) return;
|
||||
await bestEffortAudit(options, {
|
||||
profile: options.profile,
|
||||
state: 'execution_control_degraded',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
const localProcessDispatcher = new LocalRunDispatcher(
|
||||
storage.dispatch,
|
||||
new LocalDispatchPlanMaterializer(
|
||||
storage.dispatch,
|
||||
new LocalFileArtifactAllocator(
|
||||
options.artifactRoot,
|
||||
localArtifactCapacityPolicyForProfile(options.profile),
|
||||
),
|
||||
localSecrets,
|
||||
),
|
||||
localProcess,
|
||||
{
|
||||
pageSize: options.profile === 'edge' ? 4 : 16,
|
||||
maxPages: 1,
|
||||
onCompletion: (attemptId) => {
|
||||
executionControlLifecycle?.notifyCompletion(attemptId);
|
||||
},
|
||||
},
|
||||
);
|
||||
const scheduler = new LocalSchedulerCoordinator(storage.schedules, {
|
||||
pageSize: options.profile === 'edge' ? 4 : 16,
|
||||
misfireGraceMs: options.profile === 'edge' ? 30_000 : 5_000,
|
||||
});
|
||||
const workflowScheduler = new LocalWorkflowSchedulerCoordinator(
|
||||
scheduler,
|
||||
workflowRuntime.cancellation,
|
||||
workflowRuntime.frontier,
|
||||
workflowRuntime.taskAttempts,
|
||||
localProcessDispatcher,
|
||||
{
|
||||
cancellationPageSize: options.profile === 'edge' ? 4 : 32,
|
||||
cancellationMaxPages: options.profile === 'edge' ? 1 : 4,
|
||||
frontierPageSize: options.profile === 'edge' ? 1 : 16,
|
||||
frontierMaxPages: options.profile === 'edge' ? 1 : 4,
|
||||
taskAttemptPageSize: options.profile === 'edge' ? 1 : 16,
|
||||
taskAttemptMaxPages: options.profile === 'edge' ? 1 : 4,
|
||||
maxDispatches: options.profile === 'edge' ? 1 : 4,
|
||||
},
|
||||
);
|
||||
schedulerLifecycle = new LocalSchedulerLifecycle(workflowScheduler, {
|
||||
intervalMs: options.profile === 'edge' ? 5_000 : 1_000,
|
||||
stopTimeoutMs: executionPolicy.stopTimeoutMs,
|
||||
onDiagnostic: async (error) => {
|
||||
if (error === undefined) return;
|
||||
await bestEffortAudit(options, {
|
||||
profile: options.profile,
|
||||
state: 'scheduler_degraded',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
runRecovery = await new LocalRunStartupRecoveryCoordinator(
|
||||
storage.runs,
|
||||
storage.startupRecovery,
|
||||
receipts,
|
||||
new LocalProcessPersistedExecutionInspector(),
|
||||
{
|
||||
receiptPublishGraceMs: options.profile === 'edge' ? 50 : 100,
|
||||
journal: storage.completionReceipts,
|
||||
quarantineRetentionMs: executionPolicy.retentionMs,
|
||||
completionProcessor,
|
||||
},
|
||||
).recover();
|
||||
if (!runRecovery.safe) {
|
||||
throw new LocalApplicationStartupRecoveryRequiredError(
|
||||
Math.max(
|
||||
runRecovery.remaining + runRecovery.failed,
|
||||
runRecovery.scanned - runRecovery.recovered,
|
||||
),
|
||||
runRecovery.truncated,
|
||||
);
|
||||
}
|
||||
workflowTaskRecovery =
|
||||
await new LocalWorkflowTaskStartupRecoveryCoordinator(
|
||||
storage.runs,
|
||||
workflowRuntime.executions,
|
||||
workflowRuntime.executions,
|
||||
completionProcessor,
|
||||
new LocalProcessPersistedExecutionInspector(),
|
||||
{
|
||||
receiptPublishGraceMs: options.profile === 'edge' ? 50 : 100,
|
||||
},
|
||||
).recover();
|
||||
if (!workflowTaskRecovery.safe) {
|
||||
throw new LocalApplicationStartupRecoveryRequiredError(
|
||||
workflowTaskRecovery.remaining + workflowTaskRecovery.failed,
|
||||
workflowTaskRecovery.truncated,
|
||||
);
|
||||
}
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'runs_recovered',
|
||||
runRecovery,
|
||||
workflowTaskRecovery,
|
||||
});
|
||||
executionControl = await executionControlLifecycle.runOnce(true);
|
||||
receiptCleanup = executionControl.cleanup;
|
||||
if (!receiptCleanup) {
|
||||
throw new Error('Local completion receipt cleanup did not run');
|
||||
}
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'receipts_reconciled',
|
||||
runRecovery,
|
||||
workflowTaskRecovery,
|
||||
receiptCleanup,
|
||||
executionControl,
|
||||
});
|
||||
await schedulerLifecycle.runOnce();
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'recovered',
|
||||
runRecovery,
|
||||
workflowTaskRecovery,
|
||||
receiptCleanup,
|
||||
executionControl,
|
||||
});
|
||||
|
||||
executionControlLifecycle.start();
|
||||
schedulerLifecycle.start();
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'lifecycles_started',
|
||||
runRecovery,
|
||||
workflowTaskRecovery,
|
||||
receiptCleanup,
|
||||
executionControl,
|
||||
});
|
||||
|
||||
if (options.productSurface) {
|
||||
const [stepRuns, runCancellation, taskStart] = await Promise.all([
|
||||
storage.stepRunReader(),
|
||||
storage.runCancellationRepository(),
|
||||
storage.taskStartRepository(),
|
||||
]);
|
||||
productSurfaceLifecycle = await options.productSurface.start(
|
||||
Object.freeze({
|
||||
profile: options.profile,
|
||||
runs: storage.runs,
|
||||
stepRuns,
|
||||
runCancellation,
|
||||
taskStart,
|
||||
taskDefinitions: storage.taskDefinitions,
|
||||
apiCredentials: storage.apiCredentials,
|
||||
ownerPepper: storage.ownerPepper,
|
||||
projectPolicy: storage.projectPolicy,
|
||||
securityAudit: storage.securityAudit,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
!productSurfaceLifecycle ||
|
||||
typeof productSurfaceLifecycle.stopAndDrain !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local application product surface lifecycle is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'active',
|
||||
runRecovery,
|
||||
workflowTaskRecovery,
|
||||
receiptCleanup,
|
||||
executionControl,
|
||||
});
|
||||
|
||||
let stopPromise: Promise<LocalApplicationStopResult> | undefined;
|
||||
const activeStorage = storage;
|
||||
const activeSchedulerLifecycle = schedulerLifecycle;
|
||||
const activeExecutionControlLifecycle = executionControlLifecycle;
|
||||
const activeRunRecovery = runRecovery;
|
||||
const activeWorkflowTaskRecovery = workflowTaskRecovery;
|
||||
const activeReceiptCleanup = receiptCleanup;
|
||||
const activeExecutionControl = executionControl;
|
||||
const activePluginPackageStartup = pluginPackageStartup;
|
||||
const activeProductSurfaceLifecycle = productSurfaceLifecycle;
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
profile: options.profile,
|
||||
evidence: activeStorage.evidence,
|
||||
runs: activeStorage.runs,
|
||||
runRecovery: activeRunRecovery,
|
||||
workflowTaskRecovery: activeWorkflowTaskRecovery,
|
||||
receiptCleanup: activeReceiptCleanup,
|
||||
executionControl: activeExecutionControl,
|
||||
pluginPackageRecovery: activePluginPackageStartup.pluginPackageRecovery,
|
||||
pluginPackageTaskPublicationRecovery:
|
||||
activePluginPackageStartup.pluginPackageTaskPublicationRecovery,
|
||||
pluginPackageAutomationPublicationRecovery:
|
||||
activePluginPackageStartup.pluginPackageAutomationPublicationRecovery,
|
||||
pluginPackageToolSnapshotRecovery:
|
||||
activePluginPackageStartup.pluginPackageToolSnapshotRecovery,
|
||||
stop() {
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = (async () => {
|
||||
const errors: unknown[] = [];
|
||||
let timedOut = false;
|
||||
await bestEffortAudit(options, {
|
||||
profile: options.profile,
|
||||
state: 'draining',
|
||||
runRecovery: activeRunRecovery,
|
||||
workflowTaskRecovery: activeWorkflowTaskRecovery,
|
||||
receiptCleanup: activeReceiptCleanup,
|
||||
executionControl: activeExecutionControl,
|
||||
});
|
||||
if (activeProductSurfaceLifecycle) {
|
||||
try {
|
||||
const surfaceStop =
|
||||
await activeProductSurfaceLifecycle.stopAndDrain();
|
||||
timedOut = surfaceStop === 'timed_out' || timedOut;
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const schedulerStop = await activeSchedulerLifecycle.stopAndDrain();
|
||||
timedOut = schedulerStop.status === 'timed_out' || timedOut;
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
const executionStop =
|
||||
await activeExecutionControlLifecycle.stopAndDrain();
|
||||
timedOut = executionStop.status === 'timed_out' || timedOut;
|
||||
await bestEffortAudit(options, {
|
||||
profile: options.profile,
|
||||
state: 'draining',
|
||||
runRecovery: activeRunRecovery,
|
||||
workflowTaskRecovery: activeWorkflowTaskRecovery,
|
||||
receiptCleanup: executionStop.cleanup ?? activeReceiptCleanup,
|
||||
executionControl: activeExecutionControl,
|
||||
...(executionStop.drain === undefined
|
||||
? {}
|
||||
: { executionDrain: executionStop.drain }),
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
await activeStorage.stop();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
await bestEffortAudit(options, {
|
||||
profile: options.profile,
|
||||
state: 'stopped',
|
||||
runRecovery: activeRunRecovery,
|
||||
workflowTaskRecovery: activeWorkflowTaskRecovery,
|
||||
receiptCleanup: activeReceiptCleanup,
|
||||
executionControl: activeExecutionControl,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
throw oneOrAggregate(errors, 'Local application stop failed');
|
||||
}
|
||||
return timedOut ? 'timed_out' : 'stopped';
|
||||
})();
|
||||
return stopPromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const cleanupErrors: unknown[] = [];
|
||||
if (productSurfaceLifecycle) {
|
||||
try {
|
||||
await productSurfaceLifecycle.stopAndDrain();
|
||||
} catch (cleanupError) {
|
||||
cleanupErrors.push(cleanupError);
|
||||
}
|
||||
}
|
||||
if (schedulerLifecycle) {
|
||||
try {
|
||||
await schedulerLifecycle.stopAndDrain();
|
||||
} catch (cleanupError) {
|
||||
cleanupErrors.push(cleanupError);
|
||||
}
|
||||
}
|
||||
if (executionControlLifecycle) {
|
||||
try {
|
||||
await executionControlLifecycle.stopAndDrain();
|
||||
} catch (cleanupError) {
|
||||
cleanupErrors.push(cleanupError);
|
||||
}
|
||||
}
|
||||
if (storage) {
|
||||
try {
|
||||
await storage.stop();
|
||||
} catch (cleanupError) {
|
||||
cleanupErrors.push(cleanupError);
|
||||
}
|
||||
}
|
||||
await bestEffortAudit(options, {
|
||||
profile: options.profile,
|
||||
state: 'failed',
|
||||
...(runRecovery ? { runRecovery } : {}),
|
||||
...(workflowTaskRecovery ? { workflowTaskRecovery } : {}),
|
||||
...(receiptCleanup ? { receiptCleanup } : {}),
|
||||
...(executionControl ? { executionControl } : {}),
|
||||
});
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...cleanupErrors],
|
||||
'Local application activation failed and cleanup was incomplete',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import type {
|
||||
ActiveModelGatewayCapability,
|
||||
ModelGatewayProfileAudit,
|
||||
ModelGatewayProviderAuthority,
|
||||
} from '@qinglong/ai/profile';
|
||||
import type { LocalModelInvocationFeatureTransition } from '@qinglong/ai/local-feature-activation';
|
||||
import type { PluginPackagePromptExecutor } from '@qinglong/ai/plugin-package-prompt-executor';
|
||||
import type {
|
||||
PluginPackagePromptOutputArtifactKeyProvider,
|
||||
PluginPackagePromptOutputArtifactReadAuthorizer,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-artifact';
|
||||
import type { PluginPackagePromptOutputCompletionCapability } from '@qinglong/ai/plugin-package-prompt-output-completion';
|
||||
import type {
|
||||
PluginPackagePromptOutputArtifactRetentionStateReader,
|
||||
PluginPackagePromptOutputReadService,
|
||||
} from '@qinglong/ai/plugin-package-prompt-output-read';
|
||||
import type { PluginPackagePromptExecutionOutputReadService } from '@qinglong/ai/plugin-package-prompt-execution-output-read';
|
||||
|
||||
import { bootstrapLocalApplication } from './activation';
|
||||
import type {
|
||||
LocalApplicationBootstrapOptions,
|
||||
LocalApplicationBootstrapResult,
|
||||
LocalApplicationProfile,
|
||||
LocalApplicationStopResult,
|
||||
} from './contract';
|
||||
|
||||
const MIN_DRAIN_TIMEOUT_MS = 100;
|
||||
const MAX_DRAIN_TIMEOUT_MS = 60_000;
|
||||
const MIN_DRAIN_POLL_MS = 10;
|
||||
const MAX_DRAIN_POLL_MS = 1_000;
|
||||
|
||||
export const LOCAL_AI_FEATURE_APPLICATION_STATES = [
|
||||
'application_disabled',
|
||||
'deployment_excluded',
|
||||
'schema_absent',
|
||||
'feature_inactive',
|
||||
'feature_active',
|
||||
'storage_ready',
|
||||
'recovery_ready',
|
||||
'active',
|
||||
'draining',
|
||||
'drain_timed_out',
|
||||
'stopped',
|
||||
'failed',
|
||||
] as const;
|
||||
|
||||
export type LocalAiFeatureApplicationState =
|
||||
(typeof LOCAL_AI_FEATURE_APPLICATION_STATES)[number];
|
||||
|
||||
export interface LocalAiFeatureApplicationAudit {
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly state: LocalAiFeatureApplicationState;
|
||||
readonly generation?: number;
|
||||
readonly recovered?: number;
|
||||
readonly alreadyCompleted?: number;
|
||||
}
|
||||
|
||||
export type LocalAiFeatureDeploymentOptions =
|
||||
| Readonly<{
|
||||
deployment: 'excluded';
|
||||
audit: (
|
||||
record: Readonly<LocalAiFeatureApplicationAudit>,
|
||||
) => void | Promise<void>;
|
||||
}>
|
||||
| Readonly<{
|
||||
deployment: 'installed';
|
||||
loadProviders: () => Promise<ModelGatewayProviderAuthority>;
|
||||
audit: (
|
||||
record: Readonly<LocalAiFeatureApplicationAudit>,
|
||||
) => void | Promise<void>;
|
||||
maxConcurrent?: number;
|
||||
recoveryLimit?: number;
|
||||
drainTimeoutMs?: number;
|
||||
drainPollMs?: number;
|
||||
now?: () => number;
|
||||
promptOutputKeys?: PluginPackagePromptOutputArtifactKeyProvider;
|
||||
promptOutputRead?: Readonly<{
|
||||
authorizer: PluginPackagePromptOutputArtifactReadAuthorizer;
|
||||
retention: PluginPackagePromptOutputArtifactRetentionStateReader;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export interface BootstrapLocalAiFeatureApplicationOptions {
|
||||
readonly application: LocalApplicationBootstrapOptions;
|
||||
readonly ai: LocalAiFeatureDeploymentOptions;
|
||||
}
|
||||
|
||||
type ActiveLocalApplication = Extract<
|
||||
LocalApplicationBootstrapResult,
|
||||
{ status: 'active' }
|
||||
>;
|
||||
|
||||
export type LocalAiFeatureStartupResult =
|
||||
| Readonly<{
|
||||
status: 'deployment_excluded' | 'schema_absent';
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'inactive';
|
||||
generation: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'active';
|
||||
generation: number;
|
||||
capability: ActiveModelGatewayCapability;
|
||||
prompts: PluginPackagePromptExecutor;
|
||||
promptOutputs?: PluginPackagePromptOutputReadService;
|
||||
promptExecutionOutputs?: PluginPackagePromptExecutionOutputReadService;
|
||||
}>;
|
||||
|
||||
export type BootstrapLocalAiFeatureApplicationResult =
|
||||
| Readonly<{
|
||||
status: 'disabled';
|
||||
profile: LocalApplicationProfile;
|
||||
ai: Readonly<{ status: 'application_disabled' }>;
|
||||
stop(): Promise<'stopped'>;
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'active';
|
||||
profile: LocalApplicationProfile;
|
||||
application: Readonly<Omit<ActiveLocalApplication, 'stop'>>;
|
||||
ai: LocalAiFeatureStartupResult;
|
||||
stop(): Promise<LocalApplicationStopResult>;
|
||||
}>;
|
||||
|
||||
export class LocalAiFeatureApplicationUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_AI_FEATURE_APPLICATION_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('The local AI feature application is unavailable', options);
|
||||
this.name = 'LocalAiFeatureApplicationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !== [...expectedKeys].sort().join('\0')
|
||||
) {
|
||||
throw new TypeError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (value === undefined) return fallback;
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function assertOptions(value: BootstrapLocalAiFeatureApplicationOptions): void {
|
||||
exactObject(value, ['ai', 'application'], 'Local AI feature application');
|
||||
const ai = value.ai;
|
||||
if (!ai || typeof ai !== 'object' || Array.isArray(ai)) {
|
||||
throw new TypeError('Local AI feature deployment is invalid');
|
||||
}
|
||||
if (ai.deployment === 'excluded') {
|
||||
exactObject(ai, ['audit', 'deployment'], 'Excluded local AI feature');
|
||||
} else if (ai.deployment === 'installed') {
|
||||
const optionalKeys = [
|
||||
'drainPollMs',
|
||||
'drainTimeoutMs',
|
||||
'maxConcurrent',
|
||||
'now',
|
||||
'promptOutputKeys',
|
||||
'promptOutputRead',
|
||||
'recoveryLimit',
|
||||
].filter((key) => Object.hasOwn(ai, key));
|
||||
exactObject(
|
||||
ai,
|
||||
['audit', 'deployment', 'loadProviders', ...optionalKeys],
|
||||
'Installed local AI feature',
|
||||
);
|
||||
if (typeof ai.loadProviders !== 'function') {
|
||||
throw new TypeError('Local AI provider loader is invalid');
|
||||
}
|
||||
boundedInteger(
|
||||
ai.drainTimeoutMs,
|
||||
5_000,
|
||||
MIN_DRAIN_TIMEOUT_MS,
|
||||
MAX_DRAIN_TIMEOUT_MS,
|
||||
'Local AI drain timeout',
|
||||
);
|
||||
boundedInteger(
|
||||
ai.drainPollMs,
|
||||
25,
|
||||
MIN_DRAIN_POLL_MS,
|
||||
MAX_DRAIN_POLL_MS,
|
||||
'Local AI drain poll interval',
|
||||
);
|
||||
if (
|
||||
ai.maxConcurrent !== undefined &&
|
||||
(!Number.isSafeInteger(ai.maxConcurrent) ||
|
||||
ai.maxConcurrent < 1 ||
|
||||
ai.maxConcurrent > 64)
|
||||
) {
|
||||
throw new TypeError('Local AI concurrency is invalid');
|
||||
}
|
||||
if (
|
||||
ai.recoveryLimit !== undefined &&
|
||||
(!Number.isSafeInteger(ai.recoveryLimit) ||
|
||||
ai.recoveryLimit < 1 ||
|
||||
ai.recoveryLimit > 128)
|
||||
) {
|
||||
throw new TypeError('Local AI recovery limit is invalid');
|
||||
}
|
||||
if (ai.now !== undefined && typeof ai.now !== 'function') {
|
||||
throw new TypeError('Local AI clock is invalid');
|
||||
}
|
||||
if (
|
||||
ai.promptOutputKeys !== undefined &&
|
||||
(!ai.promptOutputKeys ||
|
||||
typeof ai.promptOutputKeys !== 'object' ||
|
||||
typeof ai.promptOutputKeys.active !== 'function' ||
|
||||
typeof ai.promptOutputKeys.resolve !== 'function')
|
||||
) {
|
||||
throw new TypeError('Local Prompt output key provider is invalid');
|
||||
}
|
||||
if (ai.promptOutputRead !== undefined) {
|
||||
exactObject(
|
||||
ai.promptOutputRead,
|
||||
['authorizer', 'retention'],
|
||||
'Local Prompt output read capability',
|
||||
);
|
||||
if (
|
||||
ai.promptOutputKeys === undefined ||
|
||||
!ai.promptOutputRead.authorizer ||
|
||||
typeof ai.promptOutputRead.authorizer.authorize !== 'function' ||
|
||||
!ai.promptOutputRead.retention ||
|
||||
typeof ai.promptOutputRead.retention.inspect !== 'function'
|
||||
) {
|
||||
throw new TypeError('Local Prompt output read capability is invalid');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new TypeError('Local AI deployment state is invalid');
|
||||
}
|
||||
if (typeof ai.audit !== 'function') {
|
||||
throw new TypeError('Local AI application audit sink is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async function bestEffortAudit(
|
||||
audit: LocalAiFeatureDeploymentOptions['audit'],
|
||||
record: Readonly<LocalAiFeatureApplicationAudit>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await audit(record);
|
||||
} catch {
|
||||
// Diagnostics cannot replace activation or shutdown results.
|
||||
}
|
||||
}
|
||||
|
||||
interface RawFeatureHead {
|
||||
readonly state: 'schema_absent' | 'inactive' | 'active';
|
||||
readonly generation: number;
|
||||
readonly transitionDigest: string | null;
|
||||
}
|
||||
|
||||
function rawFeatureHead(client: {
|
||||
prepare(sql: string): {
|
||||
all(...values: unknown[]): Record<string, unknown>[];
|
||||
get(...values: unknown[]): Record<string, unknown> | undefined;
|
||||
};
|
||||
}): Readonly<RawFeatureHead> {
|
||||
const schema = client
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_schema
|
||||
WHERE type = 'table'
|
||||
AND name IN (
|
||||
'ModelInvocationFeatureHead',
|
||||
'ModelInvocationFeatureTransitions'
|
||||
)
|
||||
ORDER BY name`,
|
||||
)
|
||||
.all();
|
||||
if (schema.length === 0) {
|
||||
return Object.freeze({
|
||||
state: 'schema_absent',
|
||||
generation: 0,
|
||||
transitionDigest: null,
|
||||
});
|
||||
}
|
||||
if (
|
||||
schema.length !== 2 ||
|
||||
schema[0]?.name !== 'ModelInvocationFeatureHead' ||
|
||||
schema[1]?.name !== 'ModelInvocationFeatureTransitions'
|
||||
) {
|
||||
throw new LocalAiFeatureApplicationUnavailableError();
|
||||
}
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT generation, state, transition_digest AS "transitionDigest"
|
||||
FROM "ModelInvocationFeatureHead"
|
||||
WHERE feature_id = 'model-invocation'
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all();
|
||||
if (rows.length === 0) {
|
||||
return Object.freeze({
|
||||
state: 'schema_absent',
|
||||
generation: 0,
|
||||
transitionDigest: null,
|
||||
});
|
||||
}
|
||||
const row = rows[0];
|
||||
if (
|
||||
rows.length !== 1 ||
|
||||
!row ||
|
||||
!Number.isSafeInteger(row.generation) ||
|
||||
(row.generation as number) < 1 ||
|
||||
(row.state !== 'active' && row.state !== 'inactive') ||
|
||||
typeof row.transitionDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(row.transitionDigest)
|
||||
) {
|
||||
throw new LocalAiFeatureApplicationUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
state: row.state,
|
||||
generation: row.generation as number,
|
||||
transitionDigest: row.transitionDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function sameActivation(
|
||||
expected: Readonly<LocalModelInvocationFeatureTransition>,
|
||||
observed: Readonly<LocalModelInvocationFeatureTransition> | null,
|
||||
): boolean {
|
||||
return (
|
||||
observed?.state === 'active' &&
|
||||
observed.generation === expected.generation &&
|
||||
observed.transitionDigest === expected.transitionDigest
|
||||
);
|
||||
}
|
||||
|
||||
function applicationView(
|
||||
application: ActiveLocalApplication,
|
||||
): Readonly<Omit<ActiveLocalApplication, 'stop'>> {
|
||||
const { stop: _stop, ...view } = application;
|
||||
void _stop;
|
||||
return Object.freeze(view);
|
||||
}
|
||||
|
||||
function oneOrAggregate(errors: unknown[], message: string): unknown {
|
||||
return errors.length === 1 ? errors[0] : new AggregateError(errors, message);
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, milliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Product composition for an optional local AI deployment. The base
|
||||
* application remains AI-free. Only an installed deployment with a durable
|
||||
* active head imports the AI runtime, recovers it, and reaches providers.
|
||||
*/
|
||||
export async function bootstrapLocalAiFeatureApplication(
|
||||
options: BootstrapLocalAiFeatureApplicationOptions,
|
||||
): Promise<BootstrapLocalAiFeatureApplicationResult> {
|
||||
assertOptions(options);
|
||||
const application = await bootstrapLocalApplication(options.application);
|
||||
if (application.status === 'disabled') {
|
||||
await options.ai.audit({
|
||||
profile: application.profile,
|
||||
state: 'application_disabled',
|
||||
});
|
||||
return Object.freeze({
|
||||
status: 'disabled',
|
||||
profile: application.profile,
|
||||
ai: Object.freeze({ status: 'application_disabled' as const }),
|
||||
stop: application.stop,
|
||||
});
|
||||
}
|
||||
if (options.application.enabled !== true) {
|
||||
await application.stop();
|
||||
throw new LocalAiFeatureApplicationUnavailableError();
|
||||
}
|
||||
const applicationOptions = options.application;
|
||||
if (options.ai.deployment === 'excluded') {
|
||||
await options.ai.audit({
|
||||
profile: application.profile,
|
||||
state: 'deployment_excluded',
|
||||
});
|
||||
return Object.freeze({
|
||||
status: 'active',
|
||||
profile: application.profile,
|
||||
application: applicationView(application),
|
||||
ai: Object.freeze({ status: 'deployment_excluded' as const }),
|
||||
stop: application.stop,
|
||||
});
|
||||
}
|
||||
|
||||
const aiOptions = options.ai;
|
||||
let featureDatabase:
|
||||
| Awaited<
|
||||
ReturnType<
|
||||
typeof import('@qinglong/local-sqlite/optional-feature-runtime')['openLocalSqliteOptionalFeatureRuntimeDatabase']
|
||||
>
|
||||
>
|
||||
| undefined;
|
||||
let featureOwnedByProfile = false;
|
||||
try {
|
||||
const { openLocalSqliteOptionalFeatureRuntimeDatabase } = await import(
|
||||
'@qinglong/local-sqlite/optional-feature-runtime'
|
||||
);
|
||||
featureDatabase = await openLocalSqliteOptionalFeatureRuntimeDatabase({
|
||||
databasePath:
|
||||
applicationOptions.storageMode === 'fresh'
|
||||
? applicationOptions.databasePath
|
||||
: applicationOptions.targetPath,
|
||||
profile: applicationOptions.profile,
|
||||
...(applicationOptions.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: applicationOptions.busyTimeoutMs }),
|
||||
});
|
||||
const head = rawFeatureHead(featureDatabase.authority.client);
|
||||
if (head.state !== 'active') {
|
||||
await featureDatabase.close();
|
||||
featureDatabase = undefined;
|
||||
await aiOptions.audit({
|
||||
profile: application.profile,
|
||||
state:
|
||||
head.state === 'schema_absent' ? 'schema_absent' : 'feature_inactive',
|
||||
...(head.generation === 0 ? {} : { generation: head.generation }),
|
||||
});
|
||||
const ai: LocalAiFeatureStartupResult =
|
||||
head.state === 'schema_absent'
|
||||
? Object.freeze({ status: 'schema_absent' as const })
|
||||
: Object.freeze({
|
||||
status: 'inactive' as const,
|
||||
generation: head.generation,
|
||||
});
|
||||
return Object.freeze({
|
||||
status: 'active',
|
||||
profile: application.profile,
|
||||
application: applicationView(application),
|
||||
ai,
|
||||
stop: application.stop,
|
||||
});
|
||||
}
|
||||
|
||||
const [
|
||||
{ LocalModelInvocationFeatureActivationRepository },
|
||||
{ LocalModelInvocationRepository },
|
||||
{ LocalModelPriceCatalogRepository },
|
||||
{ bootstrapModelGatewayProfile },
|
||||
{ LocalPluginPackagePromptAdmissionRepository },
|
||||
{ PluginPackagePromptExecutor },
|
||||
{ PluginPackagePromptOutputCompletionCoordinator },
|
||||
] = await Promise.all([
|
||||
import('@qinglong/ai/local-feature-activation'),
|
||||
import('@qinglong/ai/local-model-invocation-storage'),
|
||||
import('@qinglong/ai/local-price-catalog-storage'),
|
||||
import('@qinglong/ai/profile'),
|
||||
import('@qinglong/ai/local-plugin-package-prompt-admission-storage'),
|
||||
import('@qinglong/ai/plugin-package-prompt-executor'),
|
||||
import('@qinglong/ai/plugin-package-prompt-output-completion'),
|
||||
]);
|
||||
const activationRepository =
|
||||
new LocalModelInvocationFeatureActivationRepository(
|
||||
featureDatabase.authority.client,
|
||||
);
|
||||
const activation = activationRepository.findCurrent();
|
||||
if (
|
||||
!activation ||
|
||||
activation.state !== 'active' ||
|
||||
activation.generation !== head.generation ||
|
||||
activation.transitionDigest !== head.transitionDigest
|
||||
) {
|
||||
throw new LocalAiFeatureApplicationUnavailableError();
|
||||
}
|
||||
await aiOptions.audit({
|
||||
profile: application.profile,
|
||||
state: 'feature_active',
|
||||
generation: activation.generation,
|
||||
});
|
||||
const repository = new LocalModelInvocationRepository(
|
||||
featureDatabase.authority,
|
||||
);
|
||||
const pricing = new LocalModelPriceCatalogRepository(
|
||||
featureDatabase.authority,
|
||||
);
|
||||
const activeDatabase = featureDatabase;
|
||||
let durableOutput:
|
||||
| PluginPackagePromptOutputCompletionCapability
|
||||
| undefined;
|
||||
const gateway = await bootstrapModelGatewayProfile({
|
||||
enabled: true,
|
||||
profile: application.profile,
|
||||
loadStorage: async () => {
|
||||
featureOwnedByProfile = true;
|
||||
return Object.freeze({
|
||||
repository,
|
||||
pricing,
|
||||
close: () => activeDatabase.close(),
|
||||
});
|
||||
},
|
||||
loadProviders: aiOptions.loadProviders,
|
||||
...(aiOptions.promptOutputKeys === undefined
|
||||
? {}
|
||||
: {
|
||||
createSuccessfulCompletion: (coordinator) => {
|
||||
durableOutput =
|
||||
new PluginPackagePromptOutputCompletionCoordinator({
|
||||
coordinator,
|
||||
keys: aiOptions.promptOutputKeys!,
|
||||
...(aiOptions.now === undefined ? {} : { now: aiOptions.now }),
|
||||
});
|
||||
return durableOutput;
|
||||
},
|
||||
}),
|
||||
confirmActive: async () => {
|
||||
await activeDatabase.authority.enqueue(
|
||||
async () => {
|
||||
if (
|
||||
!sameActivation(activation, activationRepository.findCurrent())
|
||||
) {
|
||||
throw new LocalAiFeatureApplicationUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalAiFeatureApplicationUnavailableError(),
|
||||
);
|
||||
},
|
||||
audit: async (record: Readonly<ModelGatewayProfileAudit>) => {
|
||||
if (record.state === 'disabled') {
|
||||
throw new LocalAiFeatureApplicationUnavailableError();
|
||||
}
|
||||
await aiOptions.audit({
|
||||
profile: application.profile,
|
||||
state: record.state,
|
||||
generation: activation.generation,
|
||||
...(record.recovered === undefined
|
||||
? {}
|
||||
: { recovered: record.recovered }),
|
||||
...(record.alreadyCompleted === undefined
|
||||
? {}
|
||||
: { alreadyCompleted: record.alreadyCompleted }),
|
||||
});
|
||||
},
|
||||
...(aiOptions.maxConcurrent === undefined
|
||||
? {}
|
||||
: { maxConcurrent: aiOptions.maxConcurrent }),
|
||||
...(aiOptions.recoveryLimit === undefined
|
||||
? {}
|
||||
: { recoveryLimit: aiOptions.recoveryLimit }),
|
||||
...(aiOptions.now === undefined ? {} : { now: aiOptions.now }),
|
||||
});
|
||||
if (gateway.status !== 'active') {
|
||||
throw new LocalAiFeatureApplicationUnavailableError();
|
||||
}
|
||||
const capability = gateway.capability;
|
||||
const prompts = new PluginPackagePromptExecutor({
|
||||
admissions: new LocalPluginPackagePromptAdmissionRepository(
|
||||
activeDatabase.authority,
|
||||
),
|
||||
invocations: repository,
|
||||
gateway: capability,
|
||||
...(durableOutput === undefined ? {} : { durableOutput }),
|
||||
});
|
||||
let promptOutputs: PluginPackagePromptOutputReadService | undefined;
|
||||
let promptExecutionOutputs:
|
||||
| PluginPackagePromptExecutionOutputReadService
|
||||
| undefined;
|
||||
if (aiOptions.promptOutputRead !== undefined) {
|
||||
const [
|
||||
{ LocalPluginPackagePromptOutputArtifactRepository },
|
||||
{ PluginPackagePromptOutputReadService },
|
||||
{ PluginPackagePromptExecutionOutputReadService },
|
||||
{ LocalPluginPackagePromptExecutionOutputReferenceRepository },
|
||||
] = await Promise.all([
|
||||
import(
|
||||
'@qinglong/ai/local-plugin-package-prompt-output-artifact-storage'
|
||||
),
|
||||
import('@qinglong/ai/plugin-package-prompt-output-read'),
|
||||
import('@qinglong/ai/plugin-package-prompt-execution-output-read'),
|
||||
import(
|
||||
'@qinglong/ai/local-plugin-package-prompt-execution-output-reference-storage'
|
||||
),
|
||||
]);
|
||||
promptOutputs = new PluginPackagePromptOutputReadService({
|
||||
artifacts: new LocalPluginPackagePromptOutputArtifactRepository(
|
||||
activeDatabase.authority,
|
||||
),
|
||||
authorizer: aiOptions.promptOutputRead.authorizer,
|
||||
retention: aiOptions.promptOutputRead.retention,
|
||||
keys: aiOptions.promptOutputKeys!,
|
||||
...(aiOptions.now === undefined ? {} : { now: aiOptions.now }),
|
||||
});
|
||||
promptExecutionOutputs =
|
||||
new PluginPackagePromptExecutionOutputReadService({
|
||||
references:
|
||||
new LocalPluginPackagePromptExecutionOutputReferenceRepository(
|
||||
activeDatabase.authority,
|
||||
),
|
||||
outputs: promptOutputs,
|
||||
});
|
||||
}
|
||||
const drainTimeoutMs = boundedInteger(
|
||||
aiOptions.drainTimeoutMs,
|
||||
5_000,
|
||||
MIN_DRAIN_TIMEOUT_MS,
|
||||
MAX_DRAIN_TIMEOUT_MS,
|
||||
'Local AI drain timeout',
|
||||
);
|
||||
const drainPollMs = boundedInteger(
|
||||
aiOptions.drainPollMs,
|
||||
25,
|
||||
MIN_DRAIN_POLL_MS,
|
||||
MAX_DRAIN_POLL_MS,
|
||||
'Local AI drain poll interval',
|
||||
);
|
||||
let stopPromise: Promise<LocalApplicationStopResult> | undefined;
|
||||
return Object.freeze({
|
||||
status: 'active',
|
||||
profile: application.profile,
|
||||
application: applicationView(application),
|
||||
ai: Object.freeze({
|
||||
status: 'active' as const,
|
||||
generation: activation.generation,
|
||||
capability,
|
||||
prompts,
|
||||
...(promptOutputs === undefined ? {} : { promptOutputs }),
|
||||
...(promptExecutionOutputs === undefined
|
||||
? {}
|
||||
: { promptExecutionOutputs }),
|
||||
}),
|
||||
stop() {
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = (async () => {
|
||||
const errors: unknown[] = [];
|
||||
let timedOut = false;
|
||||
await bestEffortAudit(aiOptions.audit, {
|
||||
profile: application.profile,
|
||||
state: 'draining',
|
||||
generation: activation.generation,
|
||||
});
|
||||
try {
|
||||
const deadline = performance.now() + drainTimeoutMs;
|
||||
let result = await capability.stop();
|
||||
while (result === 'draining' && performance.now() < deadline) {
|
||||
await delay(
|
||||
Math.min(
|
||||
drainPollMs,
|
||||
Math.max(1, deadline - performance.now()),
|
||||
),
|
||||
);
|
||||
result = await capability.stop();
|
||||
}
|
||||
timedOut = result === 'draining';
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
timedOut = (await application.stop()) === 'timed_out' || timedOut;
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
await bestEffortAudit(aiOptions.audit, {
|
||||
profile: application.profile,
|
||||
state: timedOut ? 'drain_timed_out' : 'stopped',
|
||||
generation: activation.generation,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
throw oneOrAggregate(
|
||||
errors,
|
||||
'Local AI feature application stop failed',
|
||||
);
|
||||
}
|
||||
return timedOut ? 'timed_out' : 'stopped';
|
||||
})();
|
||||
return stopPromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const cleanupErrors: unknown[] = [];
|
||||
if (featureDatabase && !featureOwnedByProfile) {
|
||||
try {
|
||||
await featureDatabase.close();
|
||||
} catch (cleanupError) {
|
||||
cleanupErrors.push(cleanupError);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await application.stop();
|
||||
} catch (cleanupError) {
|
||||
cleanupErrors.push(cleanupError);
|
||||
}
|
||||
await bestEffortAudit(aiOptions.audit, {
|
||||
profile: application.profile,
|
||||
state: 'failed',
|
||||
});
|
||||
const cause =
|
||||
error instanceof LocalAiFeatureApplicationUnavailableError
|
||||
? error
|
||||
: new LocalAiFeatureApplicationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[cause, ...cleanupErrors],
|
||||
'Local AI feature activation failed and cleanup was incomplete',
|
||||
);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
LocalAdoptedProfileBootstrapOptions,
|
||||
LocalAdoptedProfileBootstrapResult,
|
||||
} from '@qinglong/local-admin/adopted-profile';
|
||||
import type {
|
||||
LocalProfileStorageAudit,
|
||||
LocalProfileStorageBootstrapResult,
|
||||
} from '@qinglong/local-sqlite/profile';
|
||||
import type { LocalRunStartupRecoverySummary } from '@qinglong/local-execution/recovery';
|
||||
import type { LocalWorkflowTaskStartupRecoverySummary } from '@qinglong/local-execution/recovery';
|
||||
import type { LocalCompletionReceiptCleanupSummary } from '@qinglong/local-process';
|
||||
import type {
|
||||
LocalExecutionControlCycleSummary,
|
||||
LocalExecutionDrainSummary,
|
||||
} from '@qinglong/local-execution/control';
|
||||
import type { PluginPackageStageProvider } from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import type { PluginPackageRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-recovery';
|
||||
import type { PluginPackageAutomationPublicationRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import type { PluginPackageTaskPublicationRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import type { ProjectToolDefinitionSnapshotRecoveryCycleResult } from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
|
||||
export type LocalApplicationProfile = 'edge' | 'standalone';
|
||||
|
||||
export type LocalApplicationActivationState =
|
||||
| 'disabled'
|
||||
| 'storage_ready'
|
||||
| 'plugin_packages_recovered'
|
||||
| 'plugin_package_tasks_published'
|
||||
| 'plugin_package_automations_published'
|
||||
| 'plugin_package_tools_snapshotted'
|
||||
| 'secrets_ready'
|
||||
| 'runs_recovered'
|
||||
| 'receipts_reconciled'
|
||||
| 'execution_control_degraded'
|
||||
| 'scheduler_degraded'
|
||||
| 'recovered'
|
||||
| 'lifecycles_started'
|
||||
| 'active'
|
||||
| 'draining'
|
||||
| 'failed'
|
||||
| 'stopped';
|
||||
|
||||
export type LocalApplicationStopResult = 'stopped' | 'timed_out';
|
||||
|
||||
export interface LocalApplicationProductSurfaceAuthority {
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly runs: Pick<
|
||||
ReadyFreshStorage['runs'],
|
||||
'findRunById' | 'listEvents' | 'listRunsByProject'
|
||||
>;
|
||||
readonly stepRuns: Awaited<ReturnType<ReadyFreshStorage['stepRunReader']>>;
|
||||
readonly runCancellation: Awaited<
|
||||
ReturnType<ReadyFreshStorage['runCancellationRepository']>
|
||||
>;
|
||||
readonly taskStart: Awaited<
|
||||
ReturnType<ReadyFreshStorage['taskStartRepository']>
|
||||
>;
|
||||
readonly taskDefinitions: Pick<
|
||||
ReadyFreshStorage['taskDefinitions'],
|
||||
'findCurrentTaskDefinition' | 'listTaskDefinitions'
|
||||
>;
|
||||
readonly apiCredentials: ReadyFreshStorage['apiCredentials'];
|
||||
readonly ownerPepper: ReadyFreshStorage['ownerPepper'];
|
||||
readonly projectPolicy: ReadyFreshStorage['projectPolicy'];
|
||||
readonly securityAudit: ReadyFreshStorage['securityAudit'];
|
||||
}
|
||||
|
||||
export interface LocalApplicationProductSurfaceLifecycle {
|
||||
stopAndDrain(): Promise<LocalApplicationStopResult>;
|
||||
}
|
||||
|
||||
export interface LocalApplicationProductSurface {
|
||||
start(
|
||||
authority: Readonly<LocalApplicationProductSurfaceAuthority>,
|
||||
): Promise<Readonly<LocalApplicationProductSurfaceLifecycle>>;
|
||||
}
|
||||
|
||||
export interface LocalApplicationPluginPackageRecoveryOptions {
|
||||
readonly stageProvider: PluginPackageStageProvider;
|
||||
readonly stagingRoot: string;
|
||||
readonly activationRoot: string;
|
||||
readonly now: () => number;
|
||||
readonly pageSize?: number;
|
||||
readonly maxPages?: number;
|
||||
readonly taskPublicationPageSize?: number;
|
||||
readonly taskPublicationMaxPages?: number;
|
||||
}
|
||||
|
||||
type ReadyAdoptedStorage = Extract<
|
||||
LocalAdoptedProfileBootstrapResult,
|
||||
{ status: 'adopted_storage_ready' }
|
||||
>;
|
||||
type ReadyFreshStorage = Extract<
|
||||
LocalProfileStorageBootstrapResult,
|
||||
{ status: 'storage_ready' }
|
||||
>;
|
||||
|
||||
export interface LocalApplicationActivationAudit {
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly state: LocalApplicationActivationState;
|
||||
readonly runRecovery?: LocalRunStartupRecoverySummary;
|
||||
readonly workflowTaskRecovery?: LocalWorkflowTaskStartupRecoverySummary;
|
||||
readonly receiptCleanup?: LocalCompletionReceiptCleanupSummary;
|
||||
readonly executionControl?: LocalExecutionControlCycleSummary;
|
||||
readonly executionDrain?: LocalExecutionDrainSummary;
|
||||
readonly pluginPackageRecovery?: PluginPackageRecoveryCycleResult;
|
||||
readonly pluginPackageTaskPublicationRecovery?: PluginPackageTaskPublicationRecoveryCycleResult;
|
||||
readonly pluginPackageAutomationPublicationRecovery?: PluginPackageAutomationPublicationRecoveryCycleResult;
|
||||
readonly pluginPackageToolSnapshotRecovery?: ProjectToolDefinitionSnapshotRecoveryCycleResult;
|
||||
}
|
||||
|
||||
export interface LocalApplicationDisabledBootstrapOptions {
|
||||
readonly enabled?: false;
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly applicationAudit: (
|
||||
record: LocalApplicationActivationAudit,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface LocalApplicationEnabledBootstrapCommon {
|
||||
readonly enabled: true;
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly receiptRoot: string;
|
||||
readonly artifactRoot: string;
|
||||
readonly secretKeyringPath: string;
|
||||
readonly pluginPackages: LocalApplicationPluginPackageRecoveryOptions;
|
||||
readonly productSurface?: LocalApplicationProductSurface;
|
||||
readonly applicationAudit: (
|
||||
record: LocalApplicationActivationAudit,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalApplicationAdoptedBootstrapOptions
|
||||
extends LocalApplicationEnabledBootstrapCommon,
|
||||
Omit<LocalAdoptedProfileBootstrapOptions, 'enabled' | 'profile'> {
|
||||
readonly storageMode?: 'adopted';
|
||||
}
|
||||
|
||||
export interface LocalApplicationFreshBootstrapOptions
|
||||
extends LocalApplicationEnabledBootstrapCommon {
|
||||
readonly storageMode: 'fresh';
|
||||
readonly databasePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
readonly audit: (record: LocalProfileStorageAudit) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type LocalApplicationEnabledBootstrapOptions =
|
||||
| LocalApplicationAdoptedBootstrapOptions
|
||||
| LocalApplicationFreshBootstrapOptions;
|
||||
|
||||
export type LocalApplicationBootstrapOptions =
|
||||
| LocalApplicationDisabledBootstrapOptions
|
||||
| LocalApplicationEnabledBootstrapOptions;
|
||||
|
||||
export type LocalApplicationBootstrapResult =
|
||||
| {
|
||||
readonly status: 'disabled';
|
||||
readonly profile: LocalApplicationProfile;
|
||||
stop(): Promise<'stopped'>;
|
||||
}
|
||||
| {
|
||||
readonly status: 'active';
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly evidence:
|
||||
| ReadyAdoptedStorage['evidence']
|
||||
| ReadyFreshStorage['evidence'];
|
||||
readonly runs: ReadyAdoptedStorage['runs'] | ReadyFreshStorage['runs'];
|
||||
readonly runRecovery: LocalRunStartupRecoverySummary;
|
||||
readonly workflowTaskRecovery: LocalWorkflowTaskStartupRecoverySummary;
|
||||
readonly receiptCleanup: LocalCompletionReceiptCleanupSummary;
|
||||
readonly executionControl: LocalExecutionControlCycleSummary;
|
||||
readonly pluginPackageRecovery: PluginPackageRecoveryCycleResult;
|
||||
readonly pluginPackageTaskPublicationRecovery: PluginPackageTaskPublicationRecoveryCycleResult;
|
||||
readonly pluginPackageAutomationPublicationRecovery: PluginPackageAutomationPublicationRecoveryCycleResult;
|
||||
readonly pluginPackageToolSnapshotRecovery: ProjectToolDefinitionSnapshotRecoveryCycleResult;
|
||||
stop(): Promise<LocalApplicationStopResult>;
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import { LocalPluginPackageActivationPublisher } from '@qinglong/local-admin/package-activation';
|
||||
import { LocalPluginPackageResourceByteSource } from '@qinglong/local-admin/package-resource-materialization';
|
||||
import {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
type PluginPackageRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-recovery';
|
||||
import {
|
||||
PluginPackageAutomationPublicationCoordinator,
|
||||
PluginPackageAutomationPublicationRecoveryCoordinator,
|
||||
type PluginPackageAutomationPublicationRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES,
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE,
|
||||
PluginPackageTaskPublicationCoordinator,
|
||||
PluginPackageTaskPublicationRecoveryCoordinator,
|
||||
type PluginPackageTaskPublicationRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import {
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGES,
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_SOURCE_PAGE_SIZE,
|
||||
ProjectToolDefinitionSnapshotPublicationCoordinator,
|
||||
ProjectToolDefinitionSnapshotRecoveryCoordinator,
|
||||
type ProjectToolDefinitionSnapshotRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import { createBuiltInTaskSpecSemanticRegistry } from '@qinglong/runtime-core/task-spec-semantic';
|
||||
import type { LocalApplicationEnabledBootstrapOptions } from './contract';
|
||||
import type { LocalApplicationReadyStorage } from './storageActivation';
|
||||
import {
|
||||
LocalApplicationPluginPackageAutomationPublicationRequiredError,
|
||||
LocalApplicationPluginPackageRecoveryRequiredError,
|
||||
LocalApplicationPluginPackageTaskPublicationRequiredError,
|
||||
LocalApplicationPluginPackageToolSnapshotRequiredError,
|
||||
} from './startupErrors';
|
||||
|
||||
export interface LocalApplicationPluginPackageStartup {
|
||||
readonly pluginPackageRecovery: Readonly<PluginPackageRecoveryCycleResult>;
|
||||
readonly pluginPackageTaskPublicationRecovery: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>;
|
||||
readonly pluginPackageAutomationPublicationRecovery: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>;
|
||||
readonly pluginPackageToolSnapshotRecovery: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>;
|
||||
}
|
||||
|
||||
export async function recoverLocalApplicationPluginPackages(
|
||||
options: LocalApplicationEnabledBootstrapOptions,
|
||||
storage: LocalApplicationReadyStorage,
|
||||
): Promise<LocalApplicationPluginPackageStartup> {
|
||||
const pluginPackageInstalls = await storage.pluginPackageInstalls();
|
||||
const pluginPackageActivation = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: options.pluginPackages.stagingRoot,
|
||||
activationRoot: options.pluginPackages.activationRoot,
|
||||
now: options.pluginPackages.now,
|
||||
});
|
||||
const pluginPackageRecovery = await new PluginPackageRecoveryCoordinator({
|
||||
repository: pluginPackageInstalls,
|
||||
stageProvider: options.pluginPackages.stageProvider,
|
||||
publisher: pluginPackageActivation,
|
||||
now: options.pluginPackages.now,
|
||||
}).recover({
|
||||
...(options.pluginPackages.pageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pluginPackages.pageSize }),
|
||||
...(options.pluginPackages.maxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.pluginPackages.maxPages }),
|
||||
});
|
||||
if (!pluginPackageRecovery.safeToAdmit) {
|
||||
throw new LocalApplicationPluginPackageRecoveryRequiredError(
|
||||
pluginPackageRecovery,
|
||||
);
|
||||
}
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'plugin_packages_recovered',
|
||||
pluginPackageRecovery,
|
||||
});
|
||||
|
||||
const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const pluginPackageTaskReconciliations =
|
||||
await storage.pluginPackageTaskReconciliations();
|
||||
const pluginPackageMaterializedRevisions =
|
||||
await storage.pluginPackageMaterializedRevisions();
|
||||
const pluginPackageTaskPublicationRecovery =
|
||||
await new PluginPackageTaskPublicationRecoveryCoordinator({
|
||||
source: pluginPackageTaskReconciliations,
|
||||
publisher: new PluginPackageTaskPublicationCoordinator({
|
||||
generationSource: pluginPackageActivation,
|
||||
lockSource: pluginPackageInstalls,
|
||||
byteSource: new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: options.pluginPackages.stagingRoot,
|
||||
}),
|
||||
materializedRepository: pluginPackageMaterializedRevisions,
|
||||
reconciliationRepository: pluginPackageTaskReconciliations,
|
||||
taskSpecSemanticRegistry,
|
||||
}),
|
||||
}).recover({
|
||||
...(options.pluginPackages.taskPublicationPageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pluginPackages.taskPublicationPageSize }),
|
||||
...(options.pluginPackages.taskPublicationMaxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.pluginPackages.taskPublicationMaxPages }),
|
||||
});
|
||||
if (!pluginPackageTaskPublicationRecovery.safeToAdmit) {
|
||||
throw new LocalApplicationPluginPackageTaskPublicationRequiredError(
|
||||
pluginPackageTaskPublicationRecovery,
|
||||
);
|
||||
}
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'plugin_package_tasks_published',
|
||||
pluginPackageRecovery,
|
||||
pluginPackageTaskPublicationRecovery,
|
||||
});
|
||||
|
||||
const pluginPackageAutomationPublications =
|
||||
await storage.pluginPackageAutomationPublications();
|
||||
const pluginPackageAutomationPublicationRecovery =
|
||||
await new PluginPackageAutomationPublicationRecoveryCoordinator({
|
||||
source: pluginPackageAutomationPublications,
|
||||
publisher: new PluginPackageAutomationPublicationCoordinator({
|
||||
generationSource: pluginPackageActivation,
|
||||
materializedRepository: pluginPackageMaterializedRevisions,
|
||||
repository: pluginPackageAutomationPublications,
|
||||
taskSpecSemanticRegistry,
|
||||
now: options.pluginPackages.now,
|
||||
}),
|
||||
}).recover({
|
||||
...(options.pluginPackages.taskPublicationPageSize === undefined
|
||||
? {}
|
||||
: { pageSize: options.pluginPackages.taskPublicationPageSize }),
|
||||
...(options.pluginPackages.taskPublicationMaxPages === undefined
|
||||
? {}
|
||||
: { maxPages: options.pluginPackages.taskPublicationMaxPages }),
|
||||
});
|
||||
if (!pluginPackageAutomationPublicationRecovery.safeToAdmit) {
|
||||
throw new LocalApplicationPluginPackageAutomationPublicationRequiredError(
|
||||
pluginPackageAutomationPublicationRecovery,
|
||||
);
|
||||
}
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'plugin_package_automations_published',
|
||||
pluginPackageRecovery,
|
||||
pluginPackageTaskPublicationRecovery,
|
||||
pluginPackageAutomationPublicationRecovery,
|
||||
});
|
||||
|
||||
const projectToolDefinitionSnapshots =
|
||||
await storage.projectToolDefinitionSnapshots();
|
||||
const pluginPackageToolSnapshotRecovery =
|
||||
await new ProjectToolDefinitionSnapshotRecoveryCoordinator({
|
||||
source: projectToolDefinitionSnapshots,
|
||||
publisher: new ProjectToolDefinitionSnapshotPublicationCoordinator({
|
||||
source: projectToolDefinitionSnapshots,
|
||||
materializedRepository: pluginPackageMaterializedRevisions,
|
||||
repository: projectToolDefinitionSnapshots,
|
||||
taskSpecSemanticRegistry,
|
||||
pageSize: Math.min(
|
||||
options.pluginPackages.taskPublicationPageSize ??
|
||||
(options.profile === 'edge' ? 4 : 16),
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_SOURCE_PAGE_SIZE,
|
||||
),
|
||||
}),
|
||||
}).recover({
|
||||
pageSize:
|
||||
options.pluginPackages.taskPublicationPageSize ??
|
||||
(options.profile === 'edge' ? 1 : 8),
|
||||
maxPages:
|
||||
options.pluginPackages.taskPublicationMaxPages ??
|
||||
MAX_PROJECT_TOOL_SNAPSHOT_RECOVERY_PAGES,
|
||||
});
|
||||
if (!pluginPackageToolSnapshotRecovery.safeToAdmit) {
|
||||
throw new LocalApplicationPluginPackageToolSnapshotRequiredError(
|
||||
pluginPackageToolSnapshotRecovery,
|
||||
);
|
||||
}
|
||||
await options.applicationAudit({
|
||||
profile: options.profile,
|
||||
state: 'plugin_package_tools_snapshotted',
|
||||
pluginPackageRecovery,
|
||||
pluginPackageTaskPublicationRecovery,
|
||||
pluginPackageAutomationPublicationRecovery,
|
||||
pluginPackageToolSnapshotRecovery,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
pluginPackageRecovery,
|
||||
pluginPackageTaskPublicationRecovery,
|
||||
pluginPackageAutomationPublicationRecovery,
|
||||
pluginPackageToolSnapshotRecovery,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PluginPackageRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-recovery';
|
||||
import type { PluginPackageAutomationPublicationRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import type { PluginPackageTaskPublicationRecoveryCycleResult } from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import type { ProjectToolDefinitionSnapshotRecoveryCycleResult } from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
|
||||
export class LocalApplicationStartupRecoveryRequiredError extends Error {
|
||||
constructor(
|
||||
readonly observedCandidates: number,
|
||||
readonly truncated: boolean,
|
||||
) {
|
||||
super('Local application has unresolved startup recovery candidates');
|
||||
this.name = 'LocalApplicationStartupRecoveryRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalApplicationPluginPackageRecoveryRequiredError extends Error {
|
||||
constructor(readonly recovery: Readonly<PluginPackageRecoveryCycleResult>) {
|
||||
super('Local application has unresolved Plugin Package recovery work');
|
||||
this.name = 'LocalApplicationPluginPackageRecoveryRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalApplicationPluginPackageTaskPublicationRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<PluginPackageTaskPublicationRecoveryCycleResult>,
|
||||
) {
|
||||
super(
|
||||
'Local application has unresolved Plugin Package Task publication work',
|
||||
);
|
||||
this.name = 'LocalApplicationPluginPackageTaskPublicationRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalApplicationPluginPackageAutomationPublicationRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<PluginPackageAutomationPublicationRecoveryCycleResult>,
|
||||
) {
|
||||
super(
|
||||
'Local application has unresolved Plugin Package Workflow/Prompt publication work',
|
||||
);
|
||||
this.name =
|
||||
'LocalApplicationPluginPackageAutomationPublicationRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalApplicationPluginPackageToolSnapshotRequiredError extends Error {
|
||||
constructor(
|
||||
readonly recovery: Readonly<ProjectToolDefinitionSnapshotRecoveryCycleResult>,
|
||||
) {
|
||||
super('Local application has unresolved Plugin Package Tool snapshot work');
|
||||
this.name = 'LocalApplicationPluginPackageToolSnapshotRequiredError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
bootstrapLocalAdoptedProfileStorage,
|
||||
type LocalAdoptedProfileBootstrapResult,
|
||||
} from '@qinglong/local-admin/adopted-profile';
|
||||
import {
|
||||
bootstrapLocalProfileStorage,
|
||||
type LocalProfileStorageBootstrapResult,
|
||||
} from '@qinglong/local-sqlite/profile';
|
||||
import type { LocalApplicationEnabledBootstrapOptions } from './contract';
|
||||
|
||||
export type LocalApplicationReadyStorage =
|
||||
| Extract<
|
||||
LocalAdoptedProfileBootstrapResult,
|
||||
{ status: 'adopted_storage_ready' }
|
||||
>
|
||||
| Extract<LocalProfileStorageBootstrapResult, { status: 'storage_ready' }>;
|
||||
|
||||
export async function openLocalApplicationStorage(
|
||||
options: LocalApplicationEnabledBootstrapOptions,
|
||||
): Promise<LocalApplicationReadyStorage> {
|
||||
const opened =
|
||||
options.storageMode === 'fresh'
|
||||
? await bootstrapLocalProfileStorage({
|
||||
enabled: true,
|
||||
profile: options.profile,
|
||||
databasePath: options.databasePath,
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||
audit: options.audit,
|
||||
})
|
||||
: await bootstrapLocalAdoptedProfileStorage({
|
||||
enabled: true,
|
||||
profile: options.profile,
|
||||
sourcePath: options.sourcePath,
|
||||
targetPath: options.targetPath,
|
||||
recoveryPath: options.recoveryPath,
|
||||
manifestPath: options.manifestPath,
|
||||
activationPath: options.activationPath,
|
||||
expectedActivationDigest: options.expectedActivationDigest,
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||
audit: options.audit,
|
||||
adoptionAudit: options.adoptionAudit,
|
||||
});
|
||||
if (
|
||||
opened.status !== 'adopted_storage_ready' &&
|
||||
opened.status !== 'storage_ready'
|
||||
) {
|
||||
throw new Error('Enabled local application storage did not become ready');
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
runProductionLocalApplicationProcess,
|
||||
type LocalApplicationProcessEvent,
|
||||
type LocalApplicationProcessSignal,
|
||||
type LocalApplicationProcessSignalSource,
|
||||
} from './production-process/processApplication';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-application --config /absolute/private-config.json';
|
||||
|
||||
const nodeSignals: LocalApplicationProcessSignalSource = Object.freeze({
|
||||
subscribe(
|
||||
listener: (signal: LocalApplicationProcessSignal) => void,
|
||||
): () => void {
|
||||
const handlers: Readonly<
|
||||
Record<LocalApplicationProcessSignal, () => void>
|
||||
> = Object.freeze({
|
||||
SIGINT: () => listener('SIGINT'),
|
||||
SIGTERM: () => listener('SIGTERM'),
|
||||
});
|
||||
process.on('SIGINT', handlers.SIGINT);
|
||||
process.on('SIGTERM', handlers.SIGTERM);
|
||||
return () => {
|
||||
process.off('SIGINT', handlers.SIGINT);
|
||||
process.off('SIGTERM', handlers.SIGTERM);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function emit(record: Readonly<LocalApplicationProcessEvent>): void {
|
||||
process.stdout.write(`${JSON.stringify(record)}\n`);
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as {
|
||||
readonly name?: unknown;
|
||||
readonly code?: unknown;
|
||||
};
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-local-application',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function configFileArgument(argv: readonly string[]): string | null {
|
||||
if (argv.length !== 2 || argv[0] !== '--config' || !argv[1]) return null;
|
||||
return argv[1];
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const configFilePath = configFileArgument(argv);
|
||||
if (configFilePath === null) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_LOCAL_APPLICATION_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stopResult = await runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: nodeSignals,
|
||||
emit,
|
||||
});
|
||||
if (stopResult !== 'stopped') process.exitCode = 1;
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './application-runtime/contract';
|
||||
export {
|
||||
bootstrapLocalApplication,
|
||||
LocalApplicationPluginPackageRecoveryRequiredError,
|
||||
LocalApplicationStartupRecoveryRequiredError,
|
||||
} from './application-runtime/activation';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+398
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,646 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash, generateKeyPairSync, sign } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalPluginPackageActivationPublisher,
|
||||
} = require('@qinglong/local-admin/package-activation');
|
||||
const {
|
||||
analyzeLocalPluginPackageRecoveryCatalogPublisherKey,
|
||||
collectLocalPluginPackageRecoveryCatalog,
|
||||
createLocalPluginPackagePublisherTrustRegistry,
|
||||
inspectLocalPluginPackageRecoveryCatalog,
|
||||
publishLocalPluginPackageRecoveryCatalogEntry,
|
||||
} = require('@qinglong/local-admin/package-recovery-catalog');
|
||||
const {
|
||||
localPluginPackagePublisherKeyRevocationImpactDigest,
|
||||
publishLocalPluginPackagePublisherTrust,
|
||||
proposeLocalPluginPackagePublisherKeyRevocation,
|
||||
} = require('@qinglong/local-admin/package-publisher-trust');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
pluginPackageContentTreeDigest,
|
||||
pluginPackagePublisherSignaturePayload,
|
||||
} = require('@qinglong/runtime-core/plugin-package-bundle');
|
||||
const {
|
||||
createPluginPackageInstall,
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallCreate,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
} = require('@qinglong/runtime-core/plugin-package-recovery');
|
||||
const {
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA,
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES,
|
||||
LocalPluginPackageRecoveryCatalogError,
|
||||
createLocalPluginPackageRecoveryCatalogStageProvider,
|
||||
} = require('../dist/production-process/pluginPackageRecoveryCatalog.js');
|
||||
|
||||
const PUBLISHER = 'packages.example.com';
|
||||
const KEY_ID = 'release-2026';
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function octal(value, bytes) {
|
||||
return Buffer.from(`${value.toString(8).padStart(bytes - 1, '0')}\0`);
|
||||
}
|
||||
|
||||
function tarHeader(entryPath, bytes) {
|
||||
const header = Buffer.alloc(512);
|
||||
Buffer.from(entryPath).copy(header, 0);
|
||||
Buffer.from('0000644\0').copy(header, 100);
|
||||
Buffer.from('0000000\0').copy(header, 108);
|
||||
Buffer.from('0000000\0').copy(header, 116);
|
||||
octal(bytes, 12).copy(header, 124);
|
||||
Buffer.from('00000000000\0').copy(header, 136);
|
||||
header.fill(0x20, 148, 156);
|
||||
Buffer.from('0').copy(header, 156);
|
||||
Buffer.from('ustar\0').copy(header, 257);
|
||||
Buffer.from('00').copy(header, 263);
|
||||
const checksum = header.reduce((total, byte) => total + byte, 0);
|
||||
Buffer.from(`${checksum.toString(8).padStart(6, '0')}\0 `).copy(header, 148);
|
||||
return header;
|
||||
}
|
||||
|
||||
function tar(entries) {
|
||||
const parts = [];
|
||||
for (const entry of entries) {
|
||||
parts.push(tarHeader(entry.path, entry.body.byteLength), entry.body);
|
||||
const padding = (512 - (entry.body.byteLength % 512)) % 512;
|
||||
if (padding > 0) parts.push(Buffer.alloc(padding));
|
||||
}
|
||||
parts.push(Buffer.alloc(1024));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.yaml'],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function packageFixture(kind) {
|
||||
const packageManifest = manifest();
|
||||
const manifestBody = Buffer.from(
|
||||
serializePluginPackageManifest(packageManifest),
|
||||
);
|
||||
const taskBody = Buffer.from(
|
||||
'apiVersion: qinglong.io/v1\nkind: Task\nmetadata:\n name: collect\n',
|
||||
);
|
||||
const artifact = tar([
|
||||
{ path: 'package.json', body: manifestBody },
|
||||
{ path: 'tasks/collect.yaml', body: taskBody },
|
||||
]);
|
||||
const artifactDigest = digest(artifact);
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(packageManifest, environment);
|
||||
const source = {
|
||||
kind,
|
||||
locator:
|
||||
kind === 'offline'
|
||||
? `offline:sha256:${artifactDigest}`
|
||||
: `oci://registry.example.com/qinglong/example-monitor@sha256:${'f'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
artifactDigest,
|
||||
artifactBytes: artifact.byteLength,
|
||||
contentDigest: pluginPackageContentTreeDigest([
|
||||
{
|
||||
path: 'tasks/collect.yaml',
|
||||
bytes: taskBody.byteLength,
|
||||
digest: digest(taskBody),
|
||||
},
|
||||
]),
|
||||
};
|
||||
const action = {
|
||||
lockId: `lock-${kind}-001`,
|
||||
projectId: 'default',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment,
|
||||
source,
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: `approval-${kind}-001`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `dispatch-${kind}-001`,
|
||||
actionDigest: pluginPackageInstallActionDigest(action),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 10_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const key = {
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }),
|
||||
notBeforeMs: 100,
|
||||
notAfterMs: 10_000,
|
||||
};
|
||||
const signature = {
|
||||
schema: PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
signature: sign(
|
||||
null,
|
||||
pluginPackagePublisherSignaturePayload(lock, PUBLISHER, KEY_ID),
|
||||
privateKey,
|
||||
).toString('base64url'),
|
||||
};
|
||||
return { artifact, key, lock, packageManifest, signature };
|
||||
}
|
||||
|
||||
function directories(t) {
|
||||
const unresolved = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-package-catalog-'),
|
||||
);
|
||||
const root = fs.realpathSync(unresolved);
|
||||
const catalogRoot = path.join(root, 'catalog');
|
||||
const bundleRoot = path.join(root, 'bundles');
|
||||
const stagingRoot = path.join(root, 'staging');
|
||||
const trustRoot = path.join(root, 'publisher-trust');
|
||||
const publisherTrustFilePath = path.join(trustRoot, 'current.json');
|
||||
fs.mkdirSync(catalogRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(bundleRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(stagingRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(trustRoot, { mode: 0o700 });
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return {
|
||||
root,
|
||||
catalogRoot,
|
||||
bundleRoot,
|
||||
stagingRoot,
|
||||
trustRoot,
|
||||
publisherTrustFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
function writePrivateJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
}
|
||||
|
||||
async function ensureTrust(filesystem, value) {
|
||||
if (fs.existsSync(filesystem.publisherTrustFilePath)) return;
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot: filesystem.trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'application-test-trust-v1',
|
||||
occurredAtMs: value.lock.createdAtMs,
|
||||
trust: {
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: [value.key],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function publish(filesystem, value) {
|
||||
const bundlePath = path.join(
|
||||
filesystem.bundleRoot,
|
||||
`${value.lock.source.artifactDigest}.bundle`,
|
||||
);
|
||||
fs.writeFileSync(bundlePath, value.artifact, { mode: 0o600 });
|
||||
fs.chmodSync(bundlePath, 0o600);
|
||||
await ensureTrust(filesystem, value);
|
||||
const sourcePath = path.join(
|
||||
filesystem.catalogRoot,
|
||||
`${value.lock.lockDigest}.json`,
|
||||
);
|
||||
writePrivateJson(sourcePath, {
|
||||
schema: LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA,
|
||||
lockDigest: value.lock.lockDigest,
|
||||
source: value.lock.source,
|
||||
bundlePath,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
});
|
||||
return { bundlePath, sourcePath };
|
||||
}
|
||||
|
||||
function provider(filesystem) {
|
||||
return createLocalPluginPackageRecoveryCatalogStageProvider({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
publisherTrustFilePath: filesystem.publisherTrustFilePath,
|
||||
stagingRoot: filesystem.stagingRoot,
|
||||
});
|
||||
}
|
||||
|
||||
test('consumes an entry published by the authenticated catalog boundary', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
const sourceBundlePath = path.join(filesystem.root, 'incoming.bundle');
|
||||
fs.writeFileSync(sourceBundlePath, value.artifact, { mode: 0o600 });
|
||||
fs.chmodSync(sourceBundlePath, 0o600);
|
||||
await ensureTrust(filesystem, value);
|
||||
|
||||
let publicationGuards = 0;
|
||||
const published = await publishLocalPluginPackageRecoveryCatalogEntry({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
sourceBundlePath,
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: createLocalPluginPackagePublisherTrustRegistry({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: [value.key],
|
||||
}),
|
||||
confirmPublicationAllowed() {
|
||||
publicationGuards += 1;
|
||||
assert.equal(
|
||||
fs
|
||||
.readdirSync(filesystem.catalogRoot)
|
||||
.filter((entry) => /^\.qlpkg-catalog-[0-9a-f]{32}\.tmp$/.test(entry))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(published.status, 'published');
|
||||
assert.equal(publicationGuards, 2);
|
||||
assert.equal(published.lockDigest, value.lock.lockDigest);
|
||||
assert.equal(
|
||||
fs.statSync(
|
||||
path.join(
|
||||
filesystem.bundleRoot,
|
||||
`${value.lock.source.artifactDigest}.bundle`,
|
||||
),
|
||||
).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
const staged = await provider(filesystem).stage(value.lock);
|
||||
assert.equal(staged.artifactDigest, value.lock.source.artifactDigest);
|
||||
assert.equal(staged.manifestDigest, value.lock.manifestDigest);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await publishLocalPluginPackageRecoveryCatalogEntry({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
sourceBundlePath,
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: createLocalPluginPackagePublisherTrustRegistry({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: [value.key],
|
||||
}),
|
||||
})
|
||||
).status,
|
||||
'existing',
|
||||
);
|
||||
assert.deepEqual(
|
||||
inspectLocalPluginPackageRecoveryCatalog({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
}),
|
||||
{
|
||||
lockDigests: [value.lock.lockDigest],
|
||||
entryCount: 1,
|
||||
bundleCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
analyzeLocalPluginPackageRecoveryCatalogPublisherKey({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
}),
|
||||
{
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
publishLocalPluginPackageRecoveryCatalogEntry({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
sourceBundlePath,
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: createLocalPluginPackagePublisherTrustRegistry({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: [value.key],
|
||||
}),
|
||||
confirmPublicationAllowed() {
|
||||
throw new Error('retirement intent won');
|
||||
},
|
||||
}),
|
||||
/catalog publication is unavailable/,
|
||||
);
|
||||
assert.equal(
|
||||
fs
|
||||
.readdirSync(filesystem.catalogRoot)
|
||||
.some((entry) => entry.endsWith('.tmp')),
|
||||
false,
|
||||
);
|
||||
|
||||
const catalogTransaction = path.join(
|
||||
filesystem.catalogRoot,
|
||||
`.qlpkg-catalog-${'a'.repeat(32)}.tmp`,
|
||||
);
|
||||
const bundleTransaction = path.join(
|
||||
filesystem.bundleRoot,
|
||||
`.qlpkg-bundle-${'b'.repeat(32)}.tmp`,
|
||||
);
|
||||
fs.writeFileSync(catalogTransaction, '', { mode: 0o600 });
|
||||
fs.writeFileSync(bundleTransaction, '', { mode: 0o600 });
|
||||
let deleteFences = 0;
|
||||
const collected = await collectLocalPluginPackageRecoveryCatalog({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
candidateLockDigests: [value.lock.lockDigest],
|
||||
maxDeletes: 4,
|
||||
beforeDelete() {
|
||||
deleteFences += 1;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(collected, {
|
||||
removedEntries: 1,
|
||||
removedBundles: 1,
|
||||
removedTransactions: 2,
|
||||
remaining: false,
|
||||
});
|
||||
assert.equal(deleteFences, 1);
|
||||
});
|
||||
|
||||
test('verifies a historical lock at its immutable creation time', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
await publish(filesystem, {
|
||||
...value,
|
||||
key: {
|
||||
...value.key,
|
||||
notAfterMs: value.lock.createdAtMs + 1,
|
||||
},
|
||||
});
|
||||
|
||||
const staged = await provider(filesystem).stage(value.lock);
|
||||
assert.equal(staged.artifactDigest, value.lock.source.artifactDigest);
|
||||
assert.equal(staged.manifestDigest, value.lock.manifestDigest);
|
||||
});
|
||||
|
||||
test('blocks queued staging as soon as a compromise proposal is durable', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
await publish(filesystem, value);
|
||||
const impactedLockDigests = [value.lock.lockDigest];
|
||||
const impact = {
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
impactedLockDigests,
|
||||
impactDigest: localPluginPackagePublisherKeyRevocationImpactDigest({
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
impactedLockDigests,
|
||||
}),
|
||||
};
|
||||
await proposeLocalPluginPackagePublisherKeyRevocation({
|
||||
trustRoot: filesystem.trustRoot,
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'application-test-revoke-v2',
|
||||
occurredAtMs: 300,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
proposerSubjectId: 'owner-a',
|
||||
impact,
|
||||
});
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/blocked by a durable lifecycle mutation/,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(filesystem.stagingRoot), []);
|
||||
});
|
||||
|
||||
for (const kind of ['offline', 'oci']) {
|
||||
test(`stages one exact ${kind} lock from the materialized catalog`, async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture(kind);
|
||||
const files = await publish(filesystem, value);
|
||||
const stageProvider = provider(filesystem);
|
||||
const staged = await stageProvider.stage(value.lock);
|
||||
assert.equal(staged.stageRef, `local-stage:${value.lock.lockDigest}`);
|
||||
assert.equal(staged.artifactDigest, value.lock.source.artifactDigest);
|
||||
assert.equal(staged.manifestDigest, value.lock.manifestDigest);
|
||||
assert.equal(staged.contentDigest, value.lock.source.contentDigest);
|
||||
|
||||
fs.unlinkSync(files.bundlePath);
|
||||
assert.deepEqual(await stageProvider.stage(value.lock), staged);
|
||||
});
|
||||
}
|
||||
|
||||
test('recovers one durable queued install to active through the catalog', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const activationRoot = path.join(filesystem.root, 'activation');
|
||||
fs.mkdirSync(activationRoot, { mode: 0o700 });
|
||||
const value = packageFixture('offline');
|
||||
await publish(filesystem, value);
|
||||
const database = new DatabaseSync(':memory:');
|
||||
database.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(database);
|
||||
t.after(() => database.close());
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(database);
|
||||
const queued = createPluginPackageInstall(value.lock, {
|
||||
installationId: 'catalog-recovery-install-001',
|
||||
mutationId: 'catalog-recovery-create-001',
|
||||
occurredAtMs: 300,
|
||||
});
|
||||
await repository.create(pluginPackageInstallCreate(value.lock, queued, null));
|
||||
const recovery = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: provider(filesystem),
|
||||
publisher: new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: filesystem.stagingRoot,
|
||||
activationRoot,
|
||||
now: () => 700,
|
||||
}),
|
||||
now: () => 600,
|
||||
});
|
||||
|
||||
const result = await recovery.recover({ pageSize: 1, maxPages: 2 });
|
||||
|
||||
assert.equal(result.safeToAdmit, true);
|
||||
assert.equal(result.settled, 1);
|
||||
const active = await repository.find(
|
||||
value.lock.projectId,
|
||||
value.lock.packageName,
|
||||
);
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(active.activeLockDigest, value.lock.lockDigest);
|
||||
});
|
||||
|
||||
test('construction is lazy and a missing locked entry fails closed', async (t) => {
|
||||
const root = path.join(directoryName(t), 'not-created');
|
||||
const stageProvider = createLocalPluginPackageRecoveryCatalogStageProvider({
|
||||
catalogRoot: root,
|
||||
bundleRoot: path.join(root, 'bundles'),
|
||||
publisherTrustFilePath: path.join(root, 'trust', 'current.json'),
|
||||
stagingRoot: path.join(root, 'staging'),
|
||||
});
|
||||
await assert.rejects(
|
||||
stageProvider.stage(packageFixture('offline').lock),
|
||||
LocalPluginPackageRecoveryCatalogError,
|
||||
);
|
||||
});
|
||||
|
||||
function directoryName(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-catalog-lazy-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return fs.realpathSync(root);
|
||||
}
|
||||
|
||||
test('rejects source drift, widened trust and unknown catalog entries', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
const files = await publish(filesystem, value);
|
||||
const entry = JSON.parse(fs.readFileSync(files.sourcePath, 'utf8'));
|
||||
entry.source.artifactDigest = 'f'.repeat(64);
|
||||
writePrivateJson(files.sourcePath, entry);
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/does not match its durable PackageLock/,
|
||||
);
|
||||
|
||||
await publish(filesystem, value);
|
||||
fs.chmodSync(filesystem.publisherTrustFilePath, 0o644);
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/trust file must be a bounded owner-only regular file/,
|
||||
);
|
||||
|
||||
fs.chmodSync(filesystem.publisherTrustFilePath, 0o600);
|
||||
fs.writeFileSync(path.join(filesystem.catalogRoot, 'unexpected'), '', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/unbounded or unknown entries/,
|
||||
);
|
||||
});
|
||||
|
||||
test('hard-caps catalog cardinality before reading a source', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
await publish(filesystem, value);
|
||||
for (
|
||||
let index = 0;
|
||||
index < MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES;
|
||||
index += 1
|
||||
) {
|
||||
const name = index.toString(16).padStart(64, '0');
|
||||
if (name === value.lock.lockDigest) continue;
|
||||
writePrivateJson(path.join(filesystem.catalogRoot, `${name}.json`), {});
|
||||
}
|
||||
const existing = fs.readdirSync(filesystem.catalogRoot).length;
|
||||
if (existing <= MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES) {
|
||||
writePrivateJson(
|
||||
path.join(filesystem.catalogRoot, `${'e'.repeat(64)}.json`),
|
||||
{},
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readdirSync(filesystem.catalogRoot).length >
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES,
|
||||
true,
|
||||
);
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/unbounded or unknown entries/,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes catalog authority only through its explicit subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/local-application/plugin-package-recovery-catalog');
|
||||
assert.equal(
|
||||
root.createLocalPluginPackageRecoveryCatalogStageProvider,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
subpath.createLocalPluginPackageRecoveryCatalogStageProvider,
|
||||
createLocalPluginPackageRecoveryCatalogStageProvider,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,746 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
inspectLegacySqlitePath,
|
||||
prepareLocalSqliteActivation,
|
||||
stageLocalSqliteAdoption,
|
||||
} = require('@qinglong/local-admin');
|
||||
const { provisionLocalSecretKeyring } = require('@qinglong/local-secret');
|
||||
const {
|
||||
LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA,
|
||||
LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V2,
|
||||
LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3,
|
||||
LocalApplicationProcessConfigError,
|
||||
loadLocalApplicationProcessConfig,
|
||||
} = require('../dist/production-process/processConfig.js');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalApplicationProcessError,
|
||||
runProductionLocalApplicationProcess,
|
||||
} = require('../dist/production-process/processApplication.js');
|
||||
const {
|
||||
localApplicationStartupReceiptPath,
|
||||
parseLocalApplicationStartupReceipt,
|
||||
} = require('../dist/production-process/startupReceipt.js');
|
||||
const {
|
||||
localApplicationShutdownReceiptPath,
|
||||
parseLocalApplicationShutdownReceipt,
|
||||
} = require('../dist/production-process/shutdownReceipt.js');
|
||||
|
||||
function directory(t, prefix = 'ql3-local-process-') {
|
||||
const value = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
t.after(() => fs.rmSync(value, { recursive: true, force: true }));
|
||||
return value;
|
||||
}
|
||||
|
||||
function configValue(root, overrides = {}) {
|
||||
const value = {
|
||||
schema: LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3,
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
storage: {
|
||||
mode: 'adopted',
|
||||
sourcePath: path.join(root, 'database.sqlite'),
|
||||
targetPath: path.join(root, 'qinglong3.sqlite'),
|
||||
recoveryPath: path.join(root, 'database.pre-ql3.sqlite'),
|
||||
manifestPath: path.join(root, 'qinglong3-adoption.json'),
|
||||
activationPath: path.join(root, 'qinglong3-activation.json'),
|
||||
expectedActivationDigest: 'a'.repeat(64),
|
||||
busyTimeoutMs: 100,
|
||||
},
|
||||
runtime: {
|
||||
receiptRoot: path.join(root, 'receipts'),
|
||||
artifactRoot: path.join(root, 'artifacts'),
|
||||
secretKeyringPath: path.join(root, 'secret-keyring.json'),
|
||||
},
|
||||
pluginPackages: {
|
||||
stagingRoot: path.join(root, 'plugin-staging'),
|
||||
activationRoot: path.join(root, 'plugin-activation'),
|
||||
recoverySource: { mode: 'disabled' },
|
||||
pageSize: 4,
|
||||
maxPages: 4,
|
||||
taskPublicationPageSize: 4,
|
||||
taskPublicationMaxPages: 4,
|
||||
},
|
||||
ai: { deployment: 'excluded' },
|
||||
...overrides,
|
||||
};
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-legacy-silence-commitment',
|
||||
state: 'legacy_stopped',
|
||||
cutoverId: 'cutover-test-1',
|
||||
profile: value.profile,
|
||||
instanceId: value.instanceId,
|
||||
activationDigest: value.storage.expectedActivationDigest,
|
||||
previousRecordDigest: 'b'.repeat(64),
|
||||
requestedAtMs: 1_000,
|
||||
observedAtMs: 1_000,
|
||||
controller: {
|
||||
kind: 'docker',
|
||||
endpointDigest: 'c'.repeat(64),
|
||||
legacyContainerId: 'd'.repeat(64),
|
||||
legacyContainerIdentityDigest: 'e'.repeat(64),
|
||||
legacySourceBindingDigest: 'f'.repeat(64),
|
||||
},
|
||||
};
|
||||
const commitmentDigest = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(payload), 'utf8')
|
||||
.digest('hex');
|
||||
return {
|
||||
...value,
|
||||
cutover: {
|
||||
cutoverId: payload.cutoverId,
|
||||
commitmentPath: path.join(root, 'legacy-stopped.json'),
|
||||
expectedCommitmentDigest: commitmentDigest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeCutoverCommitment(value) {
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-legacy-silence-commitment',
|
||||
state: 'legacy_stopped',
|
||||
cutoverId: value.cutover.cutoverId,
|
||||
profile: value.profile,
|
||||
instanceId: value.instanceId,
|
||||
activationDigest: value.storage.expectedActivationDigest,
|
||||
previousRecordDigest: 'b'.repeat(64),
|
||||
requestedAtMs: 1_000,
|
||||
observedAtMs: 1_000,
|
||||
controller: {
|
||||
kind: 'docker',
|
||||
endpointDigest: 'c'.repeat(64),
|
||||
legacyContainerId: 'd'.repeat(64),
|
||||
legacyContainerIdentityDigest: 'e'.repeat(64),
|
||||
legacySourceBindingDigest: 'f'.repeat(64),
|
||||
},
|
||||
};
|
||||
const commitment = {
|
||||
...payload,
|
||||
commitmentDigest: crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(payload), 'utf8')
|
||||
.digest('hex'),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
value.cutover.commitmentPath,
|
||||
`${JSON.stringify(commitment)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return commitment.commitmentDigest;
|
||||
}
|
||||
|
||||
function writeConfig(t, value = configValue(directory(t))) {
|
||||
const configFilePath = path.join(
|
||||
path.dirname(value.storage.sourcePath),
|
||||
'local-application.json',
|
||||
);
|
||||
if (value.schema === LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3) {
|
||||
value.cutover.expectedCommitmentDigest = writeCutoverCommitment(value);
|
||||
}
|
||||
fs.writeFileSync(configFilePath, `${JSON.stringify(value)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.chmodSync(configFilePath, 0o600);
|
||||
return { configFilePath, value };
|
||||
}
|
||||
|
||||
function freshConfigValue(root) {
|
||||
return {
|
||||
schema: LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V2,
|
||||
instanceId: 'fresh-edge-router-1',
|
||||
profile: 'edge',
|
||||
storage: {
|
||||
mode: 'fresh',
|
||||
databasePath: path.join(root, 'qinglong3.sqlite'),
|
||||
busyTimeoutMs: 100,
|
||||
},
|
||||
runtime: {
|
||||
receiptRoot: path.join(root, 'receipts'),
|
||||
artifactRoot: path.join(root, 'artifacts'),
|
||||
secretKeyringPath: path.join(root, 'secret-keyring.json'),
|
||||
},
|
||||
pluginPackages: {
|
||||
stagingRoot: path.join(root, 'plugin-staging'),
|
||||
activationRoot: path.join(root, 'plugin-activation'),
|
||||
recoverySource: { mode: 'disabled' },
|
||||
pageSize: 4,
|
||||
maxPages: 4,
|
||||
taskPublicationPageSize: 4,
|
||||
taskPublicationMaxPages: 4,
|
||||
},
|
||||
ai: { deployment: 'excluded' },
|
||||
};
|
||||
}
|
||||
|
||||
function writeFreshConfig(root, value = freshConfigValue(root)) {
|
||||
const configFilePath = path.join(root, 'local-application-fresh.json');
|
||||
fs.writeFileSync(configFilePath, `${JSON.stringify(value)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.chmodSync(configFilePath, 0o600);
|
||||
return { configFilePath, value };
|
||||
}
|
||||
|
||||
test('loads one exact private process configuration', (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath, value } = writeConfig(t, configValue(root));
|
||||
assert.deepEqual(loadLocalApplicationProcessConfig(configFilePath), value);
|
||||
|
||||
fs.chmodSync(configFilePath, 0o644);
|
||||
assert.throws(
|
||||
() => loadLocalApplicationProcessConfig(configFilePath),
|
||||
/private regular file/,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires an exact v3 commitment before adopted startup authority', async (t) => {
|
||||
const root = directory(t, 'ql3-local-cutover-config-');
|
||||
const v3 = configValue(root);
|
||||
const { cutover: _cutover, ...legacy } = v3;
|
||||
const v1 = {
|
||||
...legacy,
|
||||
schema: LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA,
|
||||
storage: Object.fromEntries(
|
||||
Object.entries(legacy.storage).filter(([key]) => key !== 'mode'),
|
||||
),
|
||||
};
|
||||
const legacyConfig = writeConfig(t, v1);
|
||||
let subscribed = false;
|
||||
await assert.rejects(
|
||||
runProductionLocalApplicationProcess({
|
||||
configFilePath: legacyConfig.configFilePath,
|
||||
signals: {
|
||||
subscribe() {
|
||||
subscribed = true;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
emit() {},
|
||||
async start() {
|
||||
throw new Error('must not start');
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error.code === 'QL3_LOCAL_APPLICATION_CUTOVER_COMMITMENT_INVALID',
|
||||
);
|
||||
assert.equal(subscribed, false);
|
||||
|
||||
const ready = writeConfig(t, v3);
|
||||
const commitment = JSON.parse(
|
||||
fs.readFileSync(v3.cutover.commitmentPath, 'utf8'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
v3.cutover.commitmentPath,
|
||||
`${JSON.stringify({ ...commitment, instanceId: 'other-instance' })}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await assert.rejects(
|
||||
runProductionLocalApplicationProcess({
|
||||
configFilePath: ready.configFilePath,
|
||||
signals: { subscribe: () => () => {} },
|
||||
emit() {},
|
||||
}),
|
||||
(error) =>
|
||||
error.code === 'QL3_LOCAL_APPLICATION_CUTOVER_COMMITMENT_INVALID',
|
||||
);
|
||||
});
|
||||
|
||||
test('loads an exact v2 fresh storage configuration', (t) => {
|
||||
const root = directory(t, 'ql3-local-fresh-config-');
|
||||
const { configFilePath, value } = writeFreshConfig(root);
|
||||
assert.deepEqual(loadLocalApplicationProcessConfig(configFilePath), value);
|
||||
|
||||
const widened = {
|
||||
...value,
|
||||
storage: { ...value.storage, sourcePath: path.join(root, 'legacy.sqlite') },
|
||||
};
|
||||
fs.writeFileSync(configFilePath, JSON.stringify(widened), { mode: 0o600 });
|
||||
assert.throws(
|
||||
() => loadLocalApplicationProcessConfig(configFilePath),
|
||||
LocalApplicationProcessConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
test('boots a migrated fresh database without an adoption fence', async (t) => {
|
||||
const root = directory(t, 'ql3-local-fresh-live-');
|
||||
const { configFilePath, value } = writeFreshConfig(root);
|
||||
await migrateLocalSqlitePath({
|
||||
databasePath: value.storage.databasePath,
|
||||
profile: value.profile,
|
||||
busyTimeoutMs: value.storage.busyTimeoutMs,
|
||||
});
|
||||
await provisionLocalSecretKeyring(value.runtime.secretKeyringPath);
|
||||
fs.mkdirSync(value.pluginPackages.stagingRoot, {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
fs.mkdirSync(value.pluginPackages.activationRoot, {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
|
||||
const events = [];
|
||||
let listener;
|
||||
const result = await runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe(receive) {
|
||||
listener = receive;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
emit(record) {
|
||||
events.push(record);
|
||||
if (record.event === 'active') setImmediate(() => listener('SIGTERM'));
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, 'stopped');
|
||||
assert.equal(
|
||||
events.some(({ event }) => event === 'active'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
events.some(
|
||||
({ dependencyActivation }) => dependencyActivation?.scope === 'adoption',
|
||||
),
|
||||
false,
|
||||
);
|
||||
const database = new DatabaseSync(value.storage.databasePath, {
|
||||
readonly: true,
|
||||
});
|
||||
assert.equal(
|
||||
database.prepare('PRAGMA integrity_check').get().integrity_check,
|
||||
'ok',
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('rejects widened, relative, aliased and unbounded process authority', (t) => {
|
||||
const root = directory(t);
|
||||
const cases = [
|
||||
{
|
||||
...configValue(root),
|
||||
unexpected: true,
|
||||
},
|
||||
{
|
||||
...configValue(root),
|
||||
storage: {
|
||||
...configValue(root).storage,
|
||||
sourcePath: 'database.sqlite',
|
||||
},
|
||||
},
|
||||
{
|
||||
...configValue(root),
|
||||
runtime: {
|
||||
...configValue(root).runtime,
|
||||
artifactRoot: path.join(root, 'receipts'),
|
||||
},
|
||||
},
|
||||
{
|
||||
...configValue(root),
|
||||
ai: { deployment: 'installed', maxConcurrent: 65 },
|
||||
},
|
||||
{
|
||||
...configValue(root),
|
||||
pluginPackages: {
|
||||
...configValue(root).pluginPackages,
|
||||
recoverySource: {
|
||||
mode: 'materialized_catalog',
|
||||
catalogRoot: path.join(root, 'plugin-staging'),
|
||||
bundleRoot: path.join(root, 'plugin-bundles'),
|
||||
publisherTrustFilePath: path.join(
|
||||
root,
|
||||
'publisher-trust',
|
||||
'current.json',
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const [index, value] of cases.entries()) {
|
||||
const configFilePath = path.join(root, `invalid-${index}.json`);
|
||||
fs.writeFileSync(configFilePath, JSON.stringify(value), { mode: 0o600 });
|
||||
assert.throws(
|
||||
() => loadLocalApplicationProcessConfig(configFilePath),
|
||||
LocalApplicationProcessConfigError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('subscribes before startup, accepts the first signal and drains once', async (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath, value } = writeConfig(t, configValue(root));
|
||||
const actions = [];
|
||||
const events = [];
|
||||
let listener;
|
||||
let stops = 0;
|
||||
const result = await runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe(receive) {
|
||||
actions.push('subscribe');
|
||||
listener = receive;
|
||||
return () => actions.push('unsubscribe');
|
||||
},
|
||||
},
|
||||
emit(record) {
|
||||
events.push(record);
|
||||
},
|
||||
async start(options) {
|
||||
actions.push('start');
|
||||
assert.equal('create' in options.application, false);
|
||||
assert.equal(options.application.profile, value.profile);
|
||||
assert.equal(
|
||||
typeof options.application.pluginPackages.stageProvider.stage,
|
||||
'function',
|
||||
);
|
||||
await options.application.applicationAudit({
|
||||
profile: value.profile,
|
||||
state: 'active',
|
||||
});
|
||||
listener('SIGTERM');
|
||||
listener('SIGINT');
|
||||
return {
|
||||
status: 'active',
|
||||
profile: value.profile,
|
||||
application: {},
|
||||
ai: { status: 'deployment_excluded' },
|
||||
async stop() {
|
||||
stops += 1;
|
||||
actions.push('stop');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, 'stopped');
|
||||
assert.equal(stops, 1);
|
||||
assert.deepEqual(actions, ['subscribe', 'start', 'stop', 'unsubscribe']);
|
||||
assert.deepEqual(
|
||||
events.map(({ event }) => event),
|
||||
['application_activation', 'active', 'shutdown_requested', 'stopped'],
|
||||
);
|
||||
assert.equal(events[2].signal, 'SIGTERM');
|
||||
const serialized = JSON.stringify(events);
|
||||
assert.equal(serialized.includes(root), false);
|
||||
assert.equal(
|
||||
serialized.includes(value.storage.expectedActivationDigest),
|
||||
false,
|
||||
);
|
||||
if (process.platform === 'linux') {
|
||||
const receipt = parseLocalApplicationStartupReceipt(
|
||||
fs.readFileSync(
|
||||
localApplicationStartupReceiptPath(configFilePath),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(receipt.instanceId, value.instanceId);
|
||||
assert.equal(receipt.profile, value.profile);
|
||||
assert.equal(receipt.aiStatus, 'deployment_excluded');
|
||||
assert.equal(receipt.processId, process.pid);
|
||||
}
|
||||
});
|
||||
|
||||
test('installed AI fails before storage startup without provider authority', async (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath } = writeConfig(
|
||||
t,
|
||||
configValue(root, { ai: { deployment: 'installed' } }),
|
||||
);
|
||||
let subscribed = false;
|
||||
let started = false;
|
||||
await assert.rejects(
|
||||
runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe() {
|
||||
subscribed = true;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
emit() {},
|
||||
async start() {
|
||||
started = true;
|
||||
throw new Error('must not start');
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof LocalApplicationProcessError &&
|
||||
error.code === 'QL3_LOCAL_APPLICATION_PROCESS_AI_PROVIDER_UNAVAILABLE',
|
||||
);
|
||||
assert.equal(subscribed, false);
|
||||
assert.equal(started, false);
|
||||
});
|
||||
|
||||
test('default Plugin Package recovery source fails closed and unsubscribes', async (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath } = writeConfig(t, configValue(root));
|
||||
let unsubscribed = false;
|
||||
await assert.rejects(
|
||||
runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe() {
|
||||
return () => {
|
||||
unsubscribed = true;
|
||||
};
|
||||
},
|
||||
},
|
||||
emit() {},
|
||||
async start(options) {
|
||||
await options.application.pluginPackages.stageProvider.stage({});
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof LocalApplicationProcessError &&
|
||||
error.code === 'QL3_LOCAL_APPLICATION_PLUGIN_SOURCE_UNAVAILABLE',
|
||||
);
|
||||
assert.equal(unsubscribed, true);
|
||||
});
|
||||
|
||||
test('materialized catalog stays unloaded when recovery has no queued source', async (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath } = writeConfig(
|
||||
t,
|
||||
configValue(root, {
|
||||
pluginPackages: {
|
||||
...configValue(root).pluginPackages,
|
||||
recoverySource: {
|
||||
mode: 'materialized_catalog',
|
||||
catalogRoot: path.join(root, 'plugin-catalog'),
|
||||
bundleRoot: path.join(root, 'plugin-bundles'),
|
||||
publisherTrustFilePath: path.join(
|
||||
root,
|
||||
'publisher-trust',
|
||||
'current.json',
|
||||
),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const catalogModule = require.resolve(
|
||||
'../dist/production-process/pluginPackageRecoveryCatalog.js',
|
||||
);
|
||||
assert.equal(require.cache[catalogModule], undefined);
|
||||
let listener;
|
||||
const result = await runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe(receive) {
|
||||
listener = receive;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
emit() {},
|
||||
async start(options) {
|
||||
assert.equal(
|
||||
typeof options.application.pluginPackages.stageProvider.stage,
|
||||
'function',
|
||||
);
|
||||
listener('SIGTERM');
|
||||
return {
|
||||
status: 'active',
|
||||
profile: 'edge',
|
||||
application: {},
|
||||
ai: { status: 'deployment_excluded' },
|
||||
async stop() {
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(result, 'stopped');
|
||||
assert.equal(require.cache[catalogModule], undefined);
|
||||
});
|
||||
|
||||
test('CLI exposes bounded usage and redacted configuration failures', (t) => {
|
||||
const cli = path.resolve(__dirname, '../dist/cli.js');
|
||||
const help = spawnSync(process.execPath, [cli, '--help'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(help.status, 0, help.stderr);
|
||||
assert.equal(
|
||||
help.stdout,
|
||||
'Usage: ql3-local-application --config /absolute/private-config.json\n',
|
||||
);
|
||||
|
||||
const usage = spawnSync(process.execPath, [cli], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 64);
|
||||
assert.equal(
|
||||
JSON.parse(usage.stderr).code,
|
||||
'QL3_LOCAL_APPLICATION_CLI_USAGE_INVALID',
|
||||
);
|
||||
|
||||
const root = directory(t, 'ql3-local-cli-failure-');
|
||||
const { configFilePath } = writeConfig(
|
||||
t,
|
||||
configValue(root, { ai: { deployment: 'installed' } }),
|
||||
);
|
||||
const failed = spawnSync(
|
||||
process.execPath,
|
||||
[cli, '--config', configFilePath],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(failed.status, 1, failed.stdout);
|
||||
assert.equal(
|
||||
JSON.parse(failed.stderr).code,
|
||||
'QL3_LOCAL_APPLICATION_PROCESS_AI_PROVIDER_UNAVAILABLE',
|
||||
);
|
||||
assert.equal(failed.stderr.includes(root), false);
|
||||
});
|
||||
|
||||
async function prepareCliFixture(t) {
|
||||
const root = directory(t, 'ql3-local-cli-live-');
|
||||
const value = configValue(root);
|
||||
const source = new DatabaseSync(value.storage.sourcePath);
|
||||
source.exec(`
|
||||
CREATE TABLE "Auths" (id INTEGER PRIMARY KEY, type TEXT, info TEXT);
|
||||
CREATE TABLE "Crontabs" (
|
||||
id INTEGER PRIMARY KEY, command TEXT NOT NULL, schedule TEXT
|
||||
);
|
||||
CREATE TABLE "Envs" (
|
||||
id INTEGER PRIMARY KEY, name TEXT, value TEXT
|
||||
);
|
||||
INSERT INTO "Crontabs" (id, command, schedule)
|
||||
VALUES (1, 'echo legacy', '0 0 * * *');
|
||||
`);
|
||||
source.close();
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: value.storage.sourcePath,
|
||||
profile: value.profile,
|
||||
});
|
||||
const adoption = await stageLocalSqliteAdoption({
|
||||
sourcePath: value.storage.sourcePath,
|
||||
targetPath: value.storage.targetPath,
|
||||
recoveryPath: value.storage.recoveryPath,
|
||||
manifestPath: value.storage.manifestPath,
|
||||
profile: value.profile,
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
});
|
||||
const activation = await prepareLocalSqliteActivation({
|
||||
sourcePath: value.storage.sourcePath,
|
||||
targetPath: value.storage.targetPath,
|
||||
recoveryPath: value.storage.recoveryPath,
|
||||
manifestPath: value.storage.manifestPath,
|
||||
activationPath: value.storage.activationPath,
|
||||
expectedManifestDigest: adoption.manifestDigest,
|
||||
});
|
||||
await provisionLocalSecretKeyring(value.runtime.secretKeyringPath);
|
||||
fs.mkdirSync(value.pluginPackages.stagingRoot, {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
fs.mkdirSync(value.pluginPackages.activationRoot, {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
const ready = {
|
||||
...value,
|
||||
storage: {
|
||||
...value.storage,
|
||||
expectedActivationDigest: activation.activationDigest,
|
||||
},
|
||||
};
|
||||
return { root, ...writeConfig(t, ready) };
|
||||
}
|
||||
|
||||
test('CLI boots the real headless runtime and releases it on SIGTERM', async (t) => {
|
||||
const { configFilePath, value } = await prepareCliFixture(t);
|
||||
const cli = path.resolve(__dirname, '../dist/cli.js');
|
||||
const child = spawn(process.execPath, [cli, '--config', configFilePath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const events = [];
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let signalled = false;
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
while (stdout.includes('\n')) {
|
||||
const index = stdout.indexOf('\n');
|
||||
const line = stdout.slice(0, index);
|
||||
stdout = stdout.slice(index + 1);
|
||||
if (!line) continue;
|
||||
const record = JSON.parse(line);
|
||||
events.push(record);
|
||||
if (record.event === 'active' && !signalled) {
|
||||
signalled = true;
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
});
|
||||
const timeout = setTimeout(() => child.kill('SIGKILL'), 15_000);
|
||||
timeout.unref();
|
||||
const [code, signal] = await new Promise((resolve) => {
|
||||
child.once('exit', (...args) => resolve(args));
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify({ stderr, signal, events }));
|
||||
assert.equal(signal, null);
|
||||
assert.equal(signalled, true);
|
||||
assert.equal(
|
||||
events.some(({ event }) => event === 'active'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
events.some(
|
||||
({ event, signal: observed }) =>
|
||||
event === 'shutdown_requested' && observed === 'SIGTERM',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
events.some(
|
||||
({ event, stopResult }) =>
|
||||
event === 'stopped' && stopResult === 'stopped',
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.platform === 'linux') {
|
||||
const receipt = parseLocalApplicationStartupReceipt(
|
||||
fs.readFileSync(
|
||||
localApplicationStartupReceiptPath(configFilePath),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(receipt.processId, child.pid);
|
||||
assert.equal(receipt.aiStatus, 'deployment_excluded');
|
||||
const shutdown = parseLocalApplicationShutdownReceipt(
|
||||
fs.readFileSync(
|
||||
localApplicationShutdownReceiptPath(configFilePath),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(shutdown.processId, child.pid);
|
||||
assert.equal(shutdown.processStartTicks, receipt.processStartTicks);
|
||||
assert.equal(shutdown.bootId, receipt.bootId);
|
||||
assert.equal(shutdown.signal, 'SIGTERM');
|
||||
assert.equal(shutdown.stopResult, 'stopped');
|
||||
assert.equal(shutdown.startupReceiptDigest, receipt.sha256);
|
||||
assert.ok(shutdown.stoppedBootAgeMs >= receipt.activeBootAgeMs);
|
||||
}
|
||||
const writer = new DatabaseSync(value.storage.sourcePath, { timeout: 100 });
|
||||
writer
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo released');
|
||||
writer.close();
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalApplicationShutdownReceiptError,
|
||||
buildLocalApplicationShutdownReceipt,
|
||||
localApplicationShutdownReceiptPath,
|
||||
observeLocalApplicationShutdown,
|
||||
parseLocalApplicationShutdownReceipt,
|
||||
publishLocalApplicationShutdownReceipt,
|
||||
} = require('../dist/production-process/shutdownReceipt.js');
|
||||
|
||||
function directory(t) {
|
||||
const value = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-stop-receipt-'));
|
||||
t.after(() => fs.rmSync(value, { recursive: true, force: true }));
|
||||
return value;
|
||||
}
|
||||
|
||||
function receipt(stoppedBootAgeMs = 2_500, processId = 41) {
|
||||
return buildLocalApplicationShutdownReceipt({
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
signal: 'SIGTERM',
|
||||
startupReceiptDigest: 'a'.repeat(64),
|
||||
observation: {
|
||||
bootId: '12345678-1234-4abc-8def-123456789abc',
|
||||
stoppedBootAgeMs,
|
||||
processId,
|
||||
processStartTicks: String(100 + processId),
|
||||
nodeExecutable: '/usr/bin/node',
|
||||
nodeVersion: 'v24.18.0',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('publishes one bounded graceful shutdown receipt', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const target = publishLocalApplicationShutdownReceipt(
|
||||
configFilePath,
|
||||
receipt(),
|
||||
);
|
||||
assert.equal(target, localApplicationShutdownReceiptPath(configFilePath));
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
|
||||
assert.equal(fs.statSync(target).nlink, 1);
|
||||
assert.equal(fs.existsSync(`${target}.stage`), false);
|
||||
const parsed = parseLocalApplicationShutdownReceipt(
|
||||
fs.readFileSync(target, 'utf8'),
|
||||
);
|
||||
assert.equal(parsed.signal, 'SIGTERM');
|
||||
assert.equal(parsed.stopResult, 'stopped');
|
||||
assert.equal(parsed.startupReceiptDigest, 'a'.repeat(64));
|
||||
});
|
||||
|
||||
test('atomically replaces the prior process shutdown receipt', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const target = publishLocalApplicationShutdownReceipt(
|
||||
configFilePath,
|
||||
receipt(),
|
||||
);
|
||||
const replacement = receipt(3_000, 42);
|
||||
assert.equal(
|
||||
publishLocalApplicationShutdownReceipt(configFilePath, replacement),
|
||||
target,
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseLocalApplicationShutdownReceipt(fs.readFileSync(target, 'utf8')),
|
||||
replacement,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalizes the Linux runtime observation property order', () => {
|
||||
const runtimeOrderedObservation = {
|
||||
bootId: '12345678-1234-4abc-8def-123456789abc',
|
||||
processId: 41,
|
||||
processStartTicks: '141',
|
||||
nodeExecutable: '/usr/bin/node',
|
||||
nodeVersion: 'v24.18.0',
|
||||
stoppedBootAgeMs: 2_500,
|
||||
};
|
||||
const built = buildLocalApplicationShutdownReceipt({
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
signal: 'SIGTERM',
|
||||
startupReceiptDigest: 'a'.repeat(64),
|
||||
observation: runtimeOrderedObservation,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseLocalApplicationShutdownReceipt(JSON.stringify(built)),
|
||||
built,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a forged digest and unsafe deterministic stage', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const valid = receipt();
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLocalApplicationShutdownReceipt(
|
||||
JSON.stringify({
|
||||
...valid,
|
||||
stoppedBootAgeMs: valid.stoppedBootAgeMs + 1,
|
||||
}),
|
||||
),
|
||||
/digest is invalid/,
|
||||
);
|
||||
const target = localApplicationShutdownReceiptPath(configFilePath);
|
||||
const outside = path.join(root, 'outside');
|
||||
fs.writeFileSync(outside, 'do-not-replace', { mode: 0o600 });
|
||||
fs.symlinkSync(outside, `${target}.stage`);
|
||||
assert.throws(
|
||||
() => publishLocalApplicationShutdownReceipt(configFilePath, valid),
|
||||
LocalApplicationShutdownReceiptError,
|
||||
);
|
||||
assert.equal(fs.readFileSync(outside, 'utf8'), 'do-not-replace');
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
});
|
||||
|
||||
test(
|
||||
'observes the still-live Linux process after application shutdown',
|
||||
{ skip: process.platform !== 'linux' },
|
||||
() => {
|
||||
const observed = observeLocalApplicationShutdown();
|
||||
assert.equal(observed.processId, process.pid);
|
||||
assert.match(observed.bootId, /^[0-9a-f-]{36}$/);
|
||||
assert.ok(observed.stoppedBootAgeMs >= 0);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,115 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalApplicationStartupReceiptError,
|
||||
buildLocalApplicationStartupReceipt,
|
||||
localApplicationStartupReceiptPath,
|
||||
observeLocalApplicationStartup,
|
||||
parseLinuxProcessStartTicks,
|
||||
parseLocalApplicationStartupReceipt,
|
||||
publishLocalApplicationStartupReceipt,
|
||||
} = require('../dist/production-process/startupReceipt.js');
|
||||
|
||||
function directory(t) {
|
||||
const value = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-startup-receipt-'));
|
||||
t.after(() => fs.rmSync(value, { recursive: true, force: true }));
|
||||
return value;
|
||||
}
|
||||
|
||||
function receipt(activeBootAgeMs = 1_250, processId = 41) {
|
||||
return buildLocalApplicationStartupReceipt({
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
aiStatus: 'deployment_excluded',
|
||||
observation: {
|
||||
bootId: '12345678-1234-4abc-8def-123456789abc',
|
||||
activeBootAgeMs,
|
||||
processId,
|
||||
processStartTicks: String(100 + processId),
|
||||
nodeExecutable: '/usr/bin/node',
|
||||
nodeVersion: 'v24.18.0',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('parses Linux stat after the final command delimiter', () => {
|
||||
const fields = [
|
||||
'S',
|
||||
...Array.from({ length: 18 }, (_, index) => String(index + 1)),
|
||||
'987654',
|
||||
'21',
|
||||
];
|
||||
assert.equal(
|
||||
parseLinuxProcessStartTicks(`41 (node worker) name) ${fields.join(' ')}`),
|
||||
'987654',
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLinuxProcessStartTicks('41 invalid'),
|
||||
LocalApplicationStartupReceiptError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes one bounded current receipt with atomic replacement', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const first = receipt();
|
||||
const target = publishLocalApplicationStartupReceipt(configFilePath, first);
|
||||
assert.equal(target, localApplicationStartupReceiptPath(configFilePath));
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
|
||||
assert.equal(fs.existsSync(`${target}.stage`), false);
|
||||
assert.deepEqual(
|
||||
parseLocalApplicationStartupReceipt(fs.readFileSync(target, 'utf8')),
|
||||
first,
|
||||
);
|
||||
|
||||
const second = receipt(1_500, 42);
|
||||
assert.equal(
|
||||
publishLocalApplicationStartupReceipt(configFilePath, second),
|
||||
target,
|
||||
);
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
|
||||
assert.equal(fs.statSync(target).nlink, 1);
|
||||
assert.deepEqual(
|
||||
parseLocalApplicationStartupReceipt(fs.readFileSync(target, 'utf8')),
|
||||
second,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a forged digest and an unsafe deterministic stage', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const target = localApplicationStartupReceiptPath(configFilePath);
|
||||
const valid = receipt();
|
||||
const forged = { ...valid, activeBootAgeMs: valid.activeBootAgeMs + 1 };
|
||||
assert.throws(
|
||||
() => parseLocalApplicationStartupReceipt(JSON.stringify(forged)),
|
||||
/digest is invalid/,
|
||||
);
|
||||
|
||||
const outside = path.join(root, 'outside');
|
||||
fs.writeFileSync(outside, 'do-not-replace', { mode: 0o600 });
|
||||
fs.symlinkSync(outside, `${target}.stage`);
|
||||
assert.throws(
|
||||
() => publishLocalApplicationStartupReceipt(configFilePath, valid),
|
||||
/existing receipt stage is not a private regular file/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(outside, 'utf8'), 'do-not-replace');
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
});
|
||||
|
||||
test(
|
||||
'observes the live Linux boot and direct Node process when available',
|
||||
{ skip: process.platform !== 'linux' },
|
||||
() => {
|
||||
const observed = observeLocalApplicationStartup();
|
||||
assert.equal(observed.processId, process.pid);
|
||||
assert.match(observed.bootId, /^[0-9a-f-]{36}$/);
|
||||
assert.match(observed.processStartTicks, /^[1-9][0-9]+$/);
|
||||
assert.equal(path.isAbsolute(observed.nodeExecutable), true);
|
||||
assert.equal(observed.nodeVersion, process.version);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user