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,161 @@
|
||||
{
|
||||
"name": "@qinglong/local-owner-cli",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 short-lived Local Owner product command-file CLI",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/application-command/localOwnerCommand.js",
|
||||
"types": "dist/application-command/localOwnerCommand.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/application-command/localOwnerCommand.d.ts",
|
||||
"require": "./dist/application-command/localOwnerCommand.js",
|
||||
"default": "./dist/application-command/localOwnerCommand.js"
|
||||
},
|
||||
"./approval-command": {
|
||||
"types": "./dist/approval-management/approvalCommand.d.ts",
|
||||
"require": "./dist/approval-management/approvalCommand.js",
|
||||
"default": "./dist/approval-management/approvalCommand.js"
|
||||
},
|
||||
"./adoption": {
|
||||
"types": "./dist/lifecycle/adoption.d.ts",
|
||||
"require": "./dist/lifecycle/adoption.js",
|
||||
"default": "./dist/lifecycle/adoption.js"
|
||||
},
|
||||
"./package-command": {
|
||||
"types": "./dist/plugin-package/pluginPackageCommand.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageCommand.js",
|
||||
"default": "./dist/plugin-package/pluginPackageCommand.js"
|
||||
},
|
||||
"./model-price-command": {
|
||||
"types": "./dist/ai-management/modelPriceCatalogCommand.d.ts",
|
||||
"require": "./dist/ai-management/modelPriceCatalogCommand.js",
|
||||
"default": "./dist/ai-management/modelPriceCatalogCommand.js"
|
||||
},
|
||||
"./model-provider-credential-command": {
|
||||
"types": "./dist/ai-management/modelProviderCredentialCommand.d.ts",
|
||||
"require": "./dist/ai-management/modelProviderCredentialCommand.js",
|
||||
"default": "./dist/ai-management/modelProviderCredentialCommand.js"
|
||||
},
|
||||
"./plugin-package-prompt-command": {
|
||||
"types": "./dist/plugin-package/pluginPackagePromptCommand.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackagePromptCommand.js",
|
||||
"default": "./dist/plugin-package/pluginPackagePromptCommand.js"
|
||||
},
|
||||
"./ai-feature-command": {
|
||||
"types": "./dist/ai-management/aiFeatureCommand.d.ts",
|
||||
"require": "./dist/ai-management/aiFeatureCommand.js",
|
||||
"default": "./dist/ai-management/aiFeatureCommand.js"
|
||||
},
|
||||
"./secret-command": {
|
||||
"types": "./dist/security-management/secretCommand.d.ts",
|
||||
"require": "./dist/security-management/secretCommand.js",
|
||||
"default": "./dist/security-management/secretCommand.js"
|
||||
},
|
||||
"./task-definition-command": {
|
||||
"types": "./dist/automation-management/taskDefinitionCommand.d.ts",
|
||||
"require": "./dist/automation-management/taskDefinitionCommand.js",
|
||||
"default": "./dist/automation-management/taskDefinitionCommand.js"
|
||||
},
|
||||
"./plugin-package-workflow-command": {
|
||||
"types": "./dist/plugin-package/pluginPackageWorkflowCommand.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageWorkflowCommand.js",
|
||||
"default": "./dist/plugin-package/pluginPackageWorkflowCommand.js"
|
||||
},
|
||||
"./trigger-command": {
|
||||
"types": "./dist/automation-management/triggerCommand.d.ts",
|
||||
"require": "./dist/automation-management/triggerCommand.js",
|
||||
"default": "./dist/automation-management/triggerCommand.js"
|
||||
},
|
||||
"./project-policy-command": {
|
||||
"types": "./dist/security-management/projectPolicyCommand.d.ts",
|
||||
"require": "./dist/security-management/projectPolicyCommand.js",
|
||||
"default": "./dist/security-management/projectPolicyCommand.js"
|
||||
},
|
||||
"./security-audit-query-command": {
|
||||
"types": "./dist/security-management/securityAuditQueryCommand.d.ts",
|
||||
"require": "./dist/security-management/securityAuditQueryCommand.js",
|
||||
"default": "./dist/security-management/securityAuditQueryCommand.js"
|
||||
},
|
||||
"./identity-credential-command": {
|
||||
"types": "./dist/security-management/identityCredentialCommand.d.ts",
|
||||
"require": "./dist/security-management/identityCredentialCommand.js",
|
||||
"default": "./dist/security-management/identityCredentialCommand.js"
|
||||
},
|
||||
"./package-catalog-command": {
|
||||
"types": "./dist/plugin-package/pluginPackageCatalogCommand.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageCatalogCommand.js",
|
||||
"default": "./dist/plugin-package/pluginPackageCatalogCommand.js"
|
||||
},
|
||||
"./package-publisher-trust-command": {
|
||||
"types": "./dist/plugin-package/pluginPackagePublisherTrustCommand.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackagePublisherTrustCommand.js",
|
||||
"default": "./dist/plugin-package/pluginPackagePublisherTrustCommand.js"
|
||||
},
|
||||
"./local-setup": {
|
||||
"types": "./dist/lifecycle/localSetup.d.ts",
|
||||
"require": "./dist/lifecycle/localSetup.js",
|
||||
"default": "./dist/lifecycle/localSetup.js"
|
||||
},
|
||||
"./local-readiness": {
|
||||
"types": "./dist/lifecycle/localReadiness.d.ts",
|
||||
"require": "./dist/lifecycle/localReadiness.js",
|
||||
"default": "./dist/lifecycle/localReadiness.js"
|
||||
},
|
||||
"./local-deployment": {
|
||||
"types": "./dist/deployment/localDeployment.d.ts",
|
||||
"require": "./dist/deployment/localDeployment.js",
|
||||
"default": "./dist/deployment/localDeployment.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"ql3": "dist/product-cli/cli.js",
|
||||
"ql3-approval": "dist/approval-management/approvalCli.js",
|
||||
"ql3-owner": "dist/cli.js",
|
||||
"ql3-adoption": "dist/lifecycle/adoptionCli.js",
|
||||
"ql3-package": "dist/plugin-package/pluginPackageCli.js",
|
||||
"ql3-package-catalog": "dist/plugin-package/pluginPackageCatalogCli.js",
|
||||
"ql3-package-trust": "dist/plugin-package/pluginPackagePublisherTrustCli.js",
|
||||
"ql3-model-price": "dist/ai-management/modelPriceCatalogCli.js",
|
||||
"ql3-model-credential": "dist/ai-management/modelProviderCredentialCli.js",
|
||||
"ql3-prompt": "dist/plugin-package/pluginPackagePromptCli.js",
|
||||
"ql3-ai-feature": "dist/ai-management/aiFeatureCli.js",
|
||||
"ql3-secret": "dist/security-management/secretCli.js",
|
||||
"ql3-task": "dist/automation-management/taskDefinitionCli.js",
|
||||
"ql3-workflow": "dist/plugin-package/pluginPackageWorkflowCli.js",
|
||||
"ql3-trigger": "dist/automation-management/triggerCli.js",
|
||||
"ql3-policy": "dist/security-management/projectPolicyCli.js",
|
||||
"ql3-audit": "dist/security-management/securityAuditQueryCli.js",
|
||||
"ql3-identity": "dist/security-management/identityCredentialCli.js",
|
||||
"ql3-local-setup": "dist/lifecycle/localSetupCli.js",
|
||||
"ql3-local-readiness": "dist/lifecycle/localReadinessCli.js",
|
||||
"ql3-local-deploy": "dist/deployment/localDeploymentCli.js",
|
||||
"ql3-service-bridge": "dist/deployment/service-manager/serviceBridgeCli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"readiness": "node ../../scripts/ql3-build-package-closure.cjs && node dist/lifecycle/localReadinessCli.js",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qinglong/ai": "workspace:*",
|
||||
"@qinglong/local-admin": "workspace:*",
|
||||
"@qinglong/local-command-file": "workspace:*",
|
||||
"@qinglong/local-secret": "workspace:*",
|
||||
"@qinglong/local-sqlite": "workspace:*",
|
||||
"@qinglong/local-owner-console": "workspace:*",
|
||||
"@qinglong/runtime-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalAiFeatureCommandFile } from './aiFeatureCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-ai-feature run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_AI_FEATURE_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalAiFeatureCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_AI_FEATURE_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local AI feature command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,734 @@
|
||||
// AI management owns explicit feature lifecycle commands.
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
assertLocalModelInvocationFeatureReady,
|
||||
migrateLocalModelInvocationFeature,
|
||||
} from '@qinglong/ai/model-invocation-migration';
|
||||
import {
|
||||
LocalModelInvocationFeatureActivationRepository,
|
||||
LocalModelInvocationFeatureTransitionConflictError,
|
||||
LocalModelInvocationFeatureTransitionUnavailableError,
|
||||
createLocalModelInvocationFeatureTransitionCommand,
|
||||
type LocalModelInvocationFeatureState,
|
||||
type LocalModelInvocationFeatureTransition,
|
||||
} from '@qinglong/ai/local-feature-activation';
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
LocalSqliteAuthenticatedManagementOwnerError,
|
||||
openLocalSqliteAuthenticatedManagementDatabase,
|
||||
type LocalSqliteAuthenticatedManagementDatabase,
|
||||
type LocalSqliteProfile,
|
||||
} from '@qinglong/local-sqlite/authenticated-management';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface LocalAiFeatureCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface BaseRequest {
|
||||
readonly requestId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export interface InspectLocalAiFeatureCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'ai-feature.inspect';
|
||||
readonly options: LocalAiFeatureCommandOptions;
|
||||
readonly request: BaseRequest;
|
||||
}
|
||||
|
||||
export interface ActivateLocalAiFeatureCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'ai-feature.activate';
|
||||
readonly options: LocalAiFeatureCommandOptions;
|
||||
readonly request: BaseRequest & {
|
||||
readonly mutationId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly expectedState: 'inactive' | null;
|
||||
readonly expectedMigrationDigest: string;
|
||||
readonly safety:
|
||||
| Readonly<{
|
||||
readonly mode: 'fresh_database';
|
||||
readonly backupEvidenceDigest: null;
|
||||
}>
|
||||
| Readonly<{
|
||||
readonly mode: 'backup_verified';
|
||||
readonly backupEvidenceDigest: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeactivateLocalAiFeatureCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'ai-feature.deactivate';
|
||||
readonly options: LocalAiFeatureCommandOptions;
|
||||
readonly request: BaseRequest & {
|
||||
readonly mutationId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly expectedState: 'active';
|
||||
readonly expectedMigrationDigest: string;
|
||||
readonly safety: Readonly<{
|
||||
readonly mode: 'preserve_existing';
|
||||
readonly backupEvidenceDigest: null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalAiFeatureCommand =
|
||||
| InspectLocalAiFeatureCommand
|
||||
| ActivateLocalAiFeatureCommand
|
||||
| DeactivateLocalAiFeatureCommand;
|
||||
|
||||
export type LocalAiFeatureSchemaState =
|
||||
| 'absent'
|
||||
| 'partial_or_drifted'
|
||||
| 'ready';
|
||||
|
||||
export type LocalAiFeatureCommandResult = Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: LocalAiFeatureCommand['operation'];
|
||||
migrationPlanDigest: string;
|
||||
schemaState: LocalAiFeatureSchemaState;
|
||||
activation: Readonly<{
|
||||
generation: number;
|
||||
state: LocalModelInvocationFeatureState;
|
||||
transitionDigest: string;
|
||||
committedAtMs: number;
|
||||
}> | null;
|
||||
runtimeAction: 'none' | 'restart_required';
|
||||
status?: 'created' | 'existing';
|
||||
}>;
|
||||
|
||||
export interface LocalAiFeatureCommandRunner {
|
||||
run(commandFilePath: string): Promise<Readonly<LocalAiFeatureCommandResult>>;
|
||||
}
|
||||
|
||||
export interface LocalAiFeatureCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteAuthenticatedManagementDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly migrate: typeof migrateLocalModelInvocationFeature;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalAiFeatureCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_AI_FEATURE_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local AI feature command configuration is invalid: ${message}`);
|
||||
this.name = 'LocalAiFeatureCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalAiFeatureDataSafetyError extends Error {
|
||||
readonly code = 'LOCAL_AI_FEATURE_DATA_SAFETY_REJECTED';
|
||||
|
||||
constructor() {
|
||||
super('Local AI feature data-safety evidence was rejected');
|
||||
this.name = 'LocalAiFeatureDataSafetyError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalAiFeatureInFlightInvocationError extends Error {
|
||||
readonly code = 'LOCAL_AI_FEATURE_IN_FLIGHT_INVOCATION';
|
||||
|
||||
constructor() {
|
||||
super('Local AI feature has an in-flight invocation');
|
||||
this.name = 'LocalAiFeatureInFlightInvocationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function options(value: unknown): LocalAiFeatureCommandOptions {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'profile',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
for (const key of [
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
] as const) {
|
||||
boundedPath(value[key], key);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze(value as unknown as LocalAiFeatureCommandOptions);
|
||||
}
|
||||
|
||||
function baseRequest(
|
||||
value: unknown,
|
||||
extraKeys: readonly string[],
|
||||
): asserts value is Record<string, unknown> {
|
||||
exactObject(
|
||||
value,
|
||||
['requestId', 'failureAuditEventId', ...extraKeys],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
typeof value.requestId !== 'string' ||
|
||||
!IDENTITY_PATTERN.test(value.requestId) ||
|
||||
typeof value.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.failureAuditEventId)
|
||||
) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'request identity is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function transitionRequest(
|
||||
value: unknown,
|
||||
operation: 'ai-feature.activate' | 'ai-feature.deactivate',
|
||||
): void {
|
||||
baseRequest(value, [
|
||||
'mutationId',
|
||||
'expectedGeneration',
|
||||
'expectedState',
|
||||
'expectedMigrationDigest',
|
||||
'safety',
|
||||
]);
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!IDENTITY_PATTERN.test(value.mutationId) ||
|
||||
!Number.isSafeInteger(value.expectedGeneration) ||
|
||||
(value.expectedGeneration as number) < 0 ||
|
||||
(value.expectedGeneration as number) > 2_147_483_647 ||
|
||||
typeof value.expectedMigrationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.expectedMigrationDigest)
|
||||
) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'transition fence is invalid',
|
||||
);
|
||||
}
|
||||
exactObject(value.safety, ['mode', 'backupEvidenceDigest'], 'safety');
|
||||
const candidateSafety = value.safety;
|
||||
if (operation === 'ai-feature.activate') {
|
||||
if (
|
||||
(value.expectedGeneration === 0 && value.expectedState !== null) ||
|
||||
((value.expectedGeneration as number) > 0 &&
|
||||
value.expectedState !== 'inactive') ||
|
||||
!(
|
||||
(candidateSafety.mode === 'fresh_database' &&
|
||||
candidateSafety.backupEvidenceDigest === null) ||
|
||||
(candidateSafety.mode === 'backup_verified' &&
|
||||
typeof candidateSafety.backupEvidenceDigest === 'string' &&
|
||||
DIGEST_PATTERN.test(candidateSafety.backupEvidenceDigest))
|
||||
)
|
||||
) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'activation request is invalid',
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
(value.expectedGeneration as number) < 1 ||
|
||||
value.expectedState !== 'active' ||
|
||||
candidateSafety.mode !== 'preserve_existing' ||
|
||||
candidateSafety.backupEvidenceDigest !== null
|
||||
) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'deactivation request is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommand(value: unknown): Readonly<LocalAiFeatureCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['schemaVersion', 'operation', 'options', 'request'],
|
||||
'command',
|
||||
);
|
||||
if (value.schemaVersion !== 1) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'schemaVersion is invalid',
|
||||
);
|
||||
}
|
||||
const commandOptions = options(value.options);
|
||||
if (value.operation === 'ai-feature.inspect') {
|
||||
baseRequest(value.request, []);
|
||||
} else if (
|
||||
value.operation === 'ai-feature.activate' ||
|
||||
value.operation === 'ai-feature.deactivate'
|
||||
) {
|
||||
transitionRequest(value.request, value.operation);
|
||||
} else {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'operation is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options: commandOptions,
|
||||
request: Object.freeze(value.request),
|
||||
} as LocalAiFeatureCommand);
|
||||
}
|
||||
|
||||
function readCommandFile(
|
||||
commandFilePath: string,
|
||||
): Readonly<LocalAiFeatureCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(commandFilePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalAiFeatureCommandConfigurationError) throw error;
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function schemaState(
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
): LocalAiFeatureSchemaState {
|
||||
const count = database.authority.client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM sqlite_schema
|
||||
WHERE type = 'table'
|
||||
AND (
|
||||
name = 'QingLong3AiSchemaMigrations'
|
||||
OR name LIKE 'ModelInvocation%'
|
||||
OR name LIKE 'ModelPriceCatalog%'
|
||||
)`,
|
||||
)
|
||||
.get() as { readonly count?: unknown };
|
||||
if (count.count === 0) return 'absent';
|
||||
try {
|
||||
assertLocalModelInvocationFeatureReady(database.authority.client);
|
||||
return 'ready';
|
||||
} catch {
|
||||
return 'partial_or_drifted';
|
||||
}
|
||||
}
|
||||
|
||||
function activationSummary(
|
||||
transition: Readonly<LocalModelInvocationFeatureTransition> | null,
|
||||
): LocalAiFeatureCommandResult['activation'] {
|
||||
return transition
|
||||
? Object.freeze({
|
||||
generation: transition.generation,
|
||||
state: transition.state,
|
||||
transitionDigest: transition.transitionDigest,
|
||||
committedAtMs: transition.committedAtMs,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
function inspect(
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
): Readonly<{
|
||||
schemaState: LocalAiFeatureSchemaState;
|
||||
current: Readonly<LocalModelInvocationFeatureTransition> | null;
|
||||
}> {
|
||||
const currentSchemaState = schemaState(database);
|
||||
if (currentSchemaState !== 'ready') {
|
||||
return Object.freeze({ schemaState: currentSchemaState, current: null });
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaState: currentSchemaState,
|
||||
current: new LocalModelInvocationFeatureActivationRepository(
|
||||
database.authority.client,
|
||||
).findCurrent(),
|
||||
});
|
||||
}
|
||||
|
||||
function assertFreshFeatureData(
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
): void {
|
||||
if (schemaState(database) === 'absent') return;
|
||||
const tables = [
|
||||
'ModelInvocationCompletions',
|
||||
'ModelInvocationPriceQuotes',
|
||||
'ModelInvocationPriceSettlements',
|
||||
'ModelInvocationQuotaReservations',
|
||||
'ModelInvocationQuotaSettlements',
|
||||
'ModelInvocationResolutions',
|
||||
'ModelInvocationStarts',
|
||||
'ModelInvocationUsageLedger',
|
||||
'ModelPriceCatalogAuthorizations',
|
||||
'ModelPriceCatalogHeads',
|
||||
'ModelPriceCatalogPublications',
|
||||
];
|
||||
for (const table of tables) {
|
||||
const present = database.authority.client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM sqlite_schema
|
||||
WHERE type = 'table' AND name = ?`,
|
||||
)
|
||||
.get(table) as { readonly count?: unknown };
|
||||
if (present.count === 1) {
|
||||
const row = database.authority.client
|
||||
.prepare(`SELECT count(*) AS count FROM "${table}"`)
|
||||
.get() as { readonly count?: unknown };
|
||||
if (row.count !== 0) throw new LocalAiFeatureDataSafetyError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoInFlightInvocation(
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
): void {
|
||||
const row = database.authority.client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "ModelInvocationStarts" start
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ModelInvocationCompletions" completion
|
||||
WHERE completion.invocation_id = start.invocation_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ModelInvocationResolutions" resolution
|
||||
WHERE resolution.invocation_id = start.invocation_id
|
||||
)`,
|
||||
)
|
||||
.get() as { readonly count?: unknown };
|
||||
if (
|
||||
typeof row.count !== 'number' ||
|
||||
!Number.isSafeInteger(row.count) ||
|
||||
row.count !== 0
|
||||
) {
|
||||
throw new LocalAiFeatureInFlightInvocationError();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmOwner(
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<void> {
|
||||
await authenticated.confirm();
|
||||
database.confirmUserCredentialFence(authenticated.databaseFence);
|
||||
database.confirmDefaultProjectOwnerFence(authenticated.databaseFence);
|
||||
}
|
||||
|
||||
async function execute(
|
||||
command: Readonly<LocalAiFeatureCommand>,
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
migrate: typeof migrateLocalModelInvocationFeature,
|
||||
): Promise<Readonly<LocalAiFeatureCommandResult>> {
|
||||
await confirmOwner(database, authenticated);
|
||||
if (command.operation === 'ai-feature.inspect') {
|
||||
const status = inspect(database);
|
||||
await confirmOwner(database, authenticated);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
migrationPlanDigest: LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
schemaState: status.schemaState,
|
||||
activation: activationSummary(status.current),
|
||||
runtimeAction: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
command.request.expectedMigrationDigest !==
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST
|
||||
) {
|
||||
throw new LocalModelInvocationFeatureTransitionConflictError();
|
||||
}
|
||||
if (command.operation === 'ai-feature.activate') {
|
||||
const before = inspect(database);
|
||||
const isActivationReplay =
|
||||
before.current?.mutationId === command.request.mutationId;
|
||||
if (
|
||||
command.request.safety.mode === 'fresh_database' &&
|
||||
before.current !== null &&
|
||||
!isActivationReplay
|
||||
) {
|
||||
throw new LocalAiFeatureDataSafetyError();
|
||||
}
|
||||
if (
|
||||
command.request.safety.mode === 'fresh_database' &&
|
||||
!isActivationReplay
|
||||
) {
|
||||
assertFreshFeatureData(database);
|
||||
}
|
||||
await migrate(database.authority.client);
|
||||
assertLocalModelInvocationFeatureReady(database.authority.client);
|
||||
} else {
|
||||
assertLocalModelInvocationFeatureReady(database.authority.client);
|
||||
assertNoInFlightInvocation(database);
|
||||
}
|
||||
|
||||
await authenticated.confirm();
|
||||
const repository = new LocalModelInvocationFeatureActivationRepository(
|
||||
database.authority.client,
|
||||
{
|
||||
beforeMutation(client, repositoryCommand) {
|
||||
if (
|
||||
repositoryCommand.principal.subject.type !==
|
||||
authenticated.principal.subject.type ||
|
||||
repositoryCommand.principal.subject.id !==
|
||||
authenticated.principal.subject.id ||
|
||||
repositoryCommand.principal.authenticationId !==
|
||||
authenticated.principal.authenticationId ||
|
||||
repositoryCommand.principal.assurance !==
|
||||
authenticated.principal.assurance
|
||||
) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
database.confirmUserCredentialFence(authenticated.databaseFence);
|
||||
database.confirmDefaultProjectOwnerFence(authenticated.databaseFence);
|
||||
if (repositoryCommand.state === 'inactive') {
|
||||
assertNoInFlightInvocation(database);
|
||||
}
|
||||
void client;
|
||||
},
|
||||
},
|
||||
);
|
||||
const committed = repository.transition(
|
||||
createLocalModelInvocationFeatureTransitionCommand({
|
||||
featureId: 'model-invocation',
|
||||
expectedGeneration: command.request.expectedGeneration,
|
||||
expectedState: command.request.expectedState,
|
||||
state:
|
||||
command.operation === 'ai-feature.activate' ? 'active' : 'inactive',
|
||||
mutationId: command.request.mutationId,
|
||||
requestId: command.request.requestId,
|
||||
expectedMigrationDigest: command.request.expectedMigrationDigest,
|
||||
safety: command.request.safety,
|
||||
principal: authenticated.principal,
|
||||
}),
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
migrationPlanDigest: LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
schemaState: 'ready',
|
||||
activation: activationSummary(committed.transition),
|
||||
runtimeAction: 'restart_required',
|
||||
status: committed.status,
|
||||
});
|
||||
}
|
||||
|
||||
type FailureAudit = Parameters<
|
||||
LocalSqliteAuthenticatedManagementDatabase['securityAudit']['record']
|
||||
>[0];
|
||||
|
||||
function failureAudit(
|
||||
command: Readonly<LocalAiFeatureCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<FailureAudit> | null {
|
||||
let outcome: FailureAudit['outcome'];
|
||||
let reason: string;
|
||||
if (!authenticated) {
|
||||
outcome = 'authentication_rejected';
|
||||
reason = 'credential_rejected';
|
||||
} else if (error instanceof LocalSqliteAuthenticatedManagementFenceError) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_fence_rejected';
|
||||
} else if (error instanceof LocalSqliteAuthenticatedManagementOwnerError) {
|
||||
outcome = 'denied';
|
||||
reason = 'platform_owner_required';
|
||||
} else if (error instanceof LocalAiFeatureDataSafetyError) {
|
||||
outcome = 'denied';
|
||||
reason = 'data_safety_rejected';
|
||||
} else if (error instanceof LocalAiFeatureInFlightInvocationError) {
|
||||
outcome = 'denied';
|
||||
reason = 'in_flight_invocation';
|
||||
} else if (
|
||||
error instanceof LocalModelInvocationFeatureTransitionConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'transition_conflict';
|
||||
} else if (
|
||||
error instanceof LocalModelInvocationFeatureTransitionUnavailableError
|
||||
) {
|
||||
outcome = 'authorization_unavailable';
|
||||
reason = 'transition_unavailable';
|
||||
} else {
|
||||
outcome = authenticated
|
||||
? 'authorization_unavailable'
|
||||
: 'authentication_unavailable';
|
||||
reason = authenticated
|
||||
? 'migration_unavailable'
|
||||
: 'authentication_unavailable';
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: command.operation.replace('-', '_').replace('.', '_'),
|
||||
projectId: null,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalAiFeatureCommandRunnerDependencies,
|
||||
): Readonly<LocalAiFeatureCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
['authenticate', 'migrate', 'now', 'openDatabase'].sort().join('\0') ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.migrate !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalAiFeatureCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function createLocalAiFeatureCommandRunner(
|
||||
candidateDependencies: LocalAiFeatureCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
migrate: migrateLocalModelInvocationFeature,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalAiFeatureCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_ai_feature',
|
||||
});
|
||||
return await execute(
|
||||
command,
|
||||
database,
|
||||
authenticated,
|
||||
adapters.migrate,
|
||||
);
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
adapters.now(),
|
||||
);
|
||||
if (audit) await database.securityAudit.record(audit);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalAiFeatureCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalAiFeatureCommandResult>> {
|
||||
return createLocalAiFeatureCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalModelPriceCatalogCommandFile } from './modelPriceCatalogCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-model-price run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_MODEL_PRICE_CATALOG_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalModelPriceCatalogCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_MODEL_PRICE_CATALOG_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Model Price Catalog command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,792 @@
|
||||
// AI management owns replay-safe model price catalog commands.
|
||||
import path from 'node:path';
|
||||
|
||||
import { assertLocalModelInvocationFeatureActive } from '@qinglong/ai/local-feature-activation';
|
||||
import {
|
||||
LocalModelPriceCatalogRepository,
|
||||
type LocalModelPriceCatalogRepositoryOptions,
|
||||
} from '@qinglong/ai/local-price-catalog-storage';
|
||||
import {
|
||||
createModelPriceCatalogPolicyDecision,
|
||||
type ModelPriceCatalogManagementOperation,
|
||||
} from '@qinglong/ai/price-catalog-management';
|
||||
import {
|
||||
bootstrapModelPriceCatalogManagementProfile,
|
||||
type ActiveModelPriceCatalogManagementCapability,
|
||||
} from '@qinglong/ai/profile';
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
openLocalSqliteAuthenticatedManagementDatabase,
|
||||
type LocalSqliteAuthenticatedManagementDatabase,
|
||||
type LocalSqliteProfile,
|
||||
} from '@qinglong/local-sqlite/authenticated-management';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CATALOG_IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_MODEL_PRICE_RATE_MICROS = 1_000_000_000_000;
|
||||
const MAX_MODEL_PRICE_CATALOG_GENERATION = 2_147_483_647;
|
||||
const LOCAL_MODEL_PRICE_POLICY_REVISION = 'local_console_platform_owner_v1';
|
||||
type SecurityAuditRecord = Parameters<
|
||||
LocalSqliteAuthenticatedManagementDatabase['securityAudit']['record']
|
||||
>[0];
|
||||
type SecurityAuditOutcome = SecurityAuditRecord['outcome'];
|
||||
|
||||
export interface LocalModelPriceCatalogCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface BaseLocalModelPriceCatalogRequest {
|
||||
readonly requestId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
}
|
||||
|
||||
interface BaseLocalModelPriceCatalogMutationRequest
|
||||
extends BaseLocalModelPriceCatalogRequest {
|
||||
readonly authorizationId: string;
|
||||
readonly mutationId: string;
|
||||
}
|
||||
|
||||
export interface PublishLocalModelPriceCatalogCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'model-price.publish';
|
||||
readonly options: LocalModelPriceCatalogCommandOptions;
|
||||
readonly request: BaseLocalModelPriceCatalogMutationRequest & {
|
||||
readonly priceRevision: string;
|
||||
readonly currency: 'USD';
|
||||
readonly inputMicrosPerMillionTokens: number;
|
||||
readonly outputMicrosPerMillionTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransitionLocalModelPriceCatalogCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
| 'model-price.activate'
|
||||
| 'model-price.deactivate'
|
||||
| 'model-price.revoke';
|
||||
readonly options: LocalModelPriceCatalogCommandOptions;
|
||||
readonly request: BaseLocalModelPriceCatalogMutationRequest & {
|
||||
readonly expectedGeneration: number;
|
||||
readonly expectedHeadDigest: string | null;
|
||||
readonly priceRevision: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InspectLocalModelPriceCatalogCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'model-price.inspect';
|
||||
readonly options: LocalModelPriceCatalogCommandOptions;
|
||||
readonly request: BaseLocalModelPriceCatalogRequest & {
|
||||
readonly priceRevision: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalModelPriceCatalogCommand =
|
||||
| PublishLocalModelPriceCatalogCommand
|
||||
| TransitionLocalModelPriceCatalogCommand
|
||||
| InspectLocalModelPriceCatalogCommand;
|
||||
|
||||
export type LocalModelPriceCatalogCommandResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'model-price.publish';
|
||||
status: 'created' | 'existing';
|
||||
publication: ReturnType<typeof publicationSummary>;
|
||||
authorization: ReturnType<typeof authorizationSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation:
|
||||
| 'model-price.activate'
|
||||
| 'model-price.deactivate'
|
||||
| 'model-price.revoke';
|
||||
status: 'created' | 'existing';
|
||||
head: ReturnType<typeof headSummary>;
|
||||
authorization: ReturnType<typeof authorizationSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'model-price.inspect';
|
||||
head: ReturnType<typeof headSummary> | null;
|
||||
publication: ReturnType<typeof publicationSummary> | null;
|
||||
}>;
|
||||
|
||||
export interface LocalModelPriceCatalogCommandRunner {
|
||||
run(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalModelPriceCatalogCommandResult>>;
|
||||
}
|
||||
|
||||
export class LocalModelPriceCatalogCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_MODEL_PRICE_CATALOG_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Model Price Catalog command is invalid: ${message}`);
|
||||
this.name = 'LocalModelPriceCatalogCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
interface LocalModelPriceCatalogCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteAuthenticatedManagementDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function options(value: unknown): LocalModelPriceCatalogCommandOptions {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'profile',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
for (const key of [
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
] as const) {
|
||||
boundedPath(value[key], key);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
value as unknown as LocalModelPriceCatalogCommandOptions,
|
||||
);
|
||||
}
|
||||
|
||||
function baseRequest(
|
||||
value: unknown,
|
||||
extraKeys: readonly string[],
|
||||
mutation: boolean,
|
||||
): asserts value is Record<string, unknown> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'requestId',
|
||||
'failureAuditEventId',
|
||||
'provider',
|
||||
'model',
|
||||
...(mutation ? ['authorizationId', 'mutationId'] : []),
|
||||
...extraKeys,
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId) ||
|
||||
typeof value.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.failureAuditEventId)
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'request audit identity is invalid',
|
||||
);
|
||||
}
|
||||
for (const key of [
|
||||
'provider',
|
||||
'model',
|
||||
...(mutation ? ['authorizationId', 'mutationId'] : []),
|
||||
]) {
|
||||
if (
|
||||
typeof value[key] !== 'string' ||
|
||||
!CATALOG_IDENTITY_PATTERN.test(value[key] as string)
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
`${key} is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function priceRevision(value: unknown, nullable: boolean): void {
|
||||
if (
|
||||
(nullable && value === null) ||
|
||||
(typeof value === 'string' && CATALOG_IDENTITY_PATTERN.test(value))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'priceRevision is invalid',
|
||||
);
|
||||
}
|
||||
|
||||
function priceRate(value: unknown, label: string): void {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
(value as number) > MAX_MODEL_PRICE_RATE_MICROS
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalModelPriceCatalogCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['schemaVersion', 'operation', 'options', 'request'],
|
||||
'command',
|
||||
);
|
||||
if (value.schemaVersion !== 1) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'schemaVersion is invalid',
|
||||
);
|
||||
}
|
||||
const commandOptions = options(value.options);
|
||||
switch (value.operation) {
|
||||
case 'model-price.publish':
|
||||
baseRequest(
|
||||
value.request,
|
||||
[
|
||||
'priceRevision',
|
||||
'currency',
|
||||
'inputMicrosPerMillionTokens',
|
||||
'outputMicrosPerMillionTokens',
|
||||
],
|
||||
true,
|
||||
);
|
||||
priceRevision(value.request.priceRevision, false);
|
||||
if (value.request.currency !== 'USD') {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'currency is invalid',
|
||||
);
|
||||
}
|
||||
priceRate(
|
||||
value.request.inputMicrosPerMillionTokens,
|
||||
'inputMicrosPerMillionTokens',
|
||||
);
|
||||
priceRate(
|
||||
value.request.outputMicrosPerMillionTokens,
|
||||
'outputMicrosPerMillionTokens',
|
||||
);
|
||||
break;
|
||||
case 'model-price.activate':
|
||||
case 'model-price.deactivate':
|
||||
case 'model-price.revoke':
|
||||
baseRequest(
|
||||
value.request,
|
||||
['expectedGeneration', 'expectedHeadDigest', 'priceRevision'],
|
||||
true,
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(value.request.expectedGeneration) ||
|
||||
(value.request.expectedGeneration as number) < 0 ||
|
||||
(value.request.expectedGeneration as number) >=
|
||||
MAX_MODEL_PRICE_CATALOG_GENERATION ||
|
||||
(value.request.expectedHeadDigest !== null &&
|
||||
(typeof value.request.expectedHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.request.expectedHeadDigest)))
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'transition fence is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
((value.request.expectedGeneration as number) === 0) !==
|
||||
(value.request.expectedHeadDigest === null)
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'transition generation and head digest are inconsistent',
|
||||
);
|
||||
}
|
||||
priceRevision(
|
||||
value.request.priceRevision,
|
||||
value.operation === 'model-price.deactivate',
|
||||
);
|
||||
if (
|
||||
(value.operation === 'model-price.deactivate' &&
|
||||
value.request.priceRevision !== null) ||
|
||||
(value.operation !== 'model-price.deactivate' &&
|
||||
value.request.priceRevision === null)
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'transition priceRevision is invalid',
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'model-price.inspect':
|
||||
baseRequest(value.request, ['priceRevision'], false);
|
||||
priceRevision(value.request.priceRevision, true);
|
||||
break;
|
||||
default:
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'operation is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...value,
|
||||
options: commandOptions,
|
||||
} as unknown as LocalModelPriceCatalogCommand);
|
||||
}
|
||||
|
||||
function readCommandFile(
|
||||
commandFilePath: string,
|
||||
): Readonly<LocalModelPriceCatalogCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(commandFilePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalModelPriceCatalogCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function operation(
|
||||
value: LocalModelPriceCatalogCommand['operation'],
|
||||
): ModelPriceCatalogManagementOperation | 'inspect' {
|
||||
switch (value) {
|
||||
case 'model-price.publish':
|
||||
return 'publish';
|
||||
case 'model-price.activate':
|
||||
return 'activate';
|
||||
case 'model-price.deactivate':
|
||||
return 'deactivate';
|
||||
case 'model-price.revoke':
|
||||
return 'revoke';
|
||||
case 'model-price.inspect':
|
||||
return 'inspect';
|
||||
}
|
||||
}
|
||||
|
||||
function publicationSummary(
|
||||
publication: Awaited<
|
||||
ReturnType<LocalModelPriceCatalogRepository['findPublication']>
|
||||
> extends infer TValue
|
||||
? NonNullable<TValue>
|
||||
: never,
|
||||
) {
|
||||
return Object.freeze({
|
||||
provider: publication.entry.provider,
|
||||
model: publication.entry.model,
|
||||
priceRevision: publication.entry.priceRevision,
|
||||
currency: publication.entry.currency,
|
||||
inputMicrosPerMillionTokens: publication.entry.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens:
|
||||
publication.entry.outputMicrosPerMillionTokens,
|
||||
catalogDigest: publication.entry.catalogDigest,
|
||||
publishedAtMs: publication.entry.publishedAtMs,
|
||||
publicationDigest: publication.publicationDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function headSummary(
|
||||
head: Awaited<
|
||||
ReturnType<LocalModelPriceCatalogRepository['findCurrent']>
|
||||
> extends infer TValue
|
||||
? NonNullable<TValue>
|
||||
: never,
|
||||
) {
|
||||
return Object.freeze({
|
||||
provider: head.provider,
|
||||
model: head.model,
|
||||
generation: head.generation,
|
||||
previousHeadDigest: head.previousHeadDigest,
|
||||
activePriceRevision: head.activePriceRevision,
|
||||
activeCatalogDigest: head.activeCatalogDigest,
|
||||
revokedPriceRevision: head.revokedPriceRevision,
|
||||
revokedCatalogDigest: head.revokedCatalogDigest,
|
||||
action: head.action,
|
||||
changedAtMs: head.changedAtMs,
|
||||
headDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function authorizationSummary(
|
||||
authorization: Awaited<
|
||||
ReturnType<LocalModelPriceCatalogRepository['findAuthorization']>
|
||||
> extends infer TValue
|
||||
? NonNullable<TValue>
|
||||
: never,
|
||||
) {
|
||||
return Object.freeze({
|
||||
authorizationId: authorization.authorizationId,
|
||||
requestId: authorization.requestId,
|
||||
operation: authorization.operation,
|
||||
policyRevision: authorization.policy.revision,
|
||||
decisionMode: authorization.decisionMode,
|
||||
committedAtMs: authorization.committedAtMs,
|
||||
authorizationDigest: authorization.authorizationDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function samePrincipal(
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
authorization: Parameters<
|
||||
NonNullable<
|
||||
LocalModelPriceCatalogRepositoryOptions['beforeAuthorizedMutation']
|
||||
>
|
||||
>[1],
|
||||
): boolean {
|
||||
const left = authenticated.principal;
|
||||
const right = authorization.principal;
|
||||
return (
|
||||
right.subject.type === left.subject.type &&
|
||||
right.subject.id === left.subject.id &&
|
||||
right.authenticationId === left.authenticationId &&
|
||||
right.authenticatedAtMs === left.authenticatedAtMs &&
|
||||
right.expiresAtMs === left.expiresAtMs &&
|
||||
right.assurance === left.assurance
|
||||
);
|
||||
}
|
||||
|
||||
async function execute(
|
||||
command: Readonly<LocalModelPriceCatalogCommand>,
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<Readonly<LocalModelPriceCatalogCommandResult>> {
|
||||
const repository = new LocalModelPriceCatalogRepository(database.authority, {
|
||||
beforeAuthorizedMutation(_client, authorization) {
|
||||
if (!samePrincipal(authenticated, authorization)) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
database.confirmUserCredentialFence(authenticated.databaseFence);
|
||||
database.confirmDefaultProjectOwnerFence(authenticated.databaseFence);
|
||||
assertLocalModelInvocationFeatureActive(database.authority.client);
|
||||
},
|
||||
});
|
||||
if (command.operation === 'model-price.inspect') {
|
||||
await authenticated.confirm();
|
||||
database.confirmDefaultProjectOwnerFence(authenticated.databaseFence);
|
||||
const [head, publication] = await Promise.all([
|
||||
repository.findCurrent(command.request.provider, command.request.model),
|
||||
command.request.priceRevision === null
|
||||
? Promise.resolve(null)
|
||||
: repository.findPublication({
|
||||
provider: command.request.provider,
|
||||
model: command.request.model,
|
||||
priceRevision: command.request.priceRevision,
|
||||
}),
|
||||
]);
|
||||
await authenticated.confirm();
|
||||
database.confirmDefaultProjectOwnerFence(authenticated.databaseFence);
|
||||
assertLocalModelInvocationFeatureActive(database.authority.client);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
head: head ? headSummary(head) : null,
|
||||
publication: publication ? publicationSummary(publication) : null,
|
||||
});
|
||||
}
|
||||
|
||||
let capability:
|
||||
| Readonly<ActiveModelPriceCatalogManagementCapability>
|
||||
| undefined;
|
||||
try {
|
||||
const profile = await bootstrapModelPriceCatalogManagementProfile({
|
||||
enabled: true,
|
||||
profile: command.options.profile,
|
||||
async loadAuthority() {
|
||||
return {
|
||||
repository,
|
||||
authorizer: {
|
||||
async authorize() {
|
||||
await authenticated.confirm();
|
||||
return createModelPriceCatalogPolicyDecision({
|
||||
effect: 'allow',
|
||||
revision: LOCAL_MODEL_PRICE_POLICY_REVISION,
|
||||
reasons: ['local_console_confirmed'],
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
audit() {},
|
||||
});
|
||||
if (profile.status !== 'active') {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'management profile did not activate',
|
||||
);
|
||||
}
|
||||
capability = profile.capability;
|
||||
if (command.operation === 'model-price.publish') {
|
||||
const result = await capability.publish({
|
||||
authorizationId: command.request.authorizationId,
|
||||
requestId: command.request.requestId,
|
||||
mutationId: command.request.mutationId,
|
||||
provider: command.request.provider,
|
||||
model: command.request.model,
|
||||
principal: authenticated.principal,
|
||||
priceRevision: command.request.priceRevision,
|
||||
currency: command.request.currency,
|
||||
inputMicrosPerMillionTokens:
|
||||
command.request.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens:
|
||||
command.request.outputMicrosPerMillionTokens,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
publication: publicationSummary(result.publication),
|
||||
authorization: authorizationSummary(result.authorization),
|
||||
});
|
||||
}
|
||||
const action = operation(command.operation);
|
||||
if (action === 'inspect' || action === 'publish') {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'transition action is invalid',
|
||||
);
|
||||
}
|
||||
const result = await capability.transition({
|
||||
authorizationId: command.request.authorizationId,
|
||||
requestId: command.request.requestId,
|
||||
mutationId: command.request.mutationId,
|
||||
provider: command.request.provider,
|
||||
model: command.request.model,
|
||||
principal: authenticated.principal,
|
||||
expectedGeneration: command.request.expectedGeneration,
|
||||
expectedHeadDigest: command.request.expectedHeadDigest,
|
||||
action,
|
||||
priceRevision: command.request.priceRevision,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
head: headSummary(result.head),
|
||||
authorization: authorizationSummary(result.authorization),
|
||||
});
|
||||
} finally {
|
||||
if (capability) await capability.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function causes(error: unknown): readonly unknown[] {
|
||||
const values: unknown[] = [];
|
||||
let current = error;
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
if (!current || typeof current !== 'object') break;
|
||||
values.push(current);
|
||||
current = 'cause' in current ? current.cause : undefined;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function code(value: unknown): string | null {
|
||||
return value &&
|
||||
typeof value === 'object' &&
|
||||
'code' in value &&
|
||||
typeof value.code === 'string'
|
||||
? value.code
|
||||
: null;
|
||||
}
|
||||
|
||||
function failureAudit(
|
||||
command: Readonly<LocalModelPriceCatalogCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
const codes = new Set(causes(error).map(code).filter(Boolean));
|
||||
let outcome: SecurityAuditOutcome;
|
||||
let reason: string;
|
||||
if (
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError ||
|
||||
codes.has('AUTHENTICATED_LOCAL_COMMAND_AUTHENTICATION_FAILED')
|
||||
) {
|
||||
outcome = authenticated ? 'denied' : 'authentication_rejected';
|
||||
reason = authenticated
|
||||
? 'credential_fence_rejected'
|
||||
: 'credential_rejected';
|
||||
} else if (
|
||||
codes.has('LOCAL_SQLITE_AUTHENTICATED_MANAGEMENT_FENCE_REJECTED')
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_fence_rejected';
|
||||
} else if (
|
||||
codes.has('LOCAL_SQLITE_AUTHENTICATED_MANAGEMENT_OWNER_REJECTED')
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'platform_owner_required';
|
||||
} else if (
|
||||
codes.has('MODEL_PRICE_CATALOG_MANAGEMENT_AUTHENTICATION_REQUIRED')
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'strong_authentication_required';
|
||||
} else if (codes.has('MODEL_PRICE_CATALOG_MANAGEMENT_FORBIDDEN')) {
|
||||
outcome = 'denied';
|
||||
reason = 'policy_denied';
|
||||
} else if (codes.has('MODEL_PRICE_CATALOG_MANAGEMENT_QUOTA_EXCEEDED')) {
|
||||
outcome = 'denied';
|
||||
reason = 'quota_exhausted';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: `model_price_catalog.${operation(command.operation)}`,
|
||||
projectId: null,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalModelPriceCatalogCommandRunnerDependencies,
|
||||
): Readonly<LocalModelPriceCatalogCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
['authenticate', 'now', 'openDatabase'].sort().join('\0') ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalModelPriceCatalogCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function createLocalModelPriceCatalogCommandRunner(
|
||||
candidateDependencies: LocalModelPriceCatalogCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalModelPriceCatalogCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
assertLocalModelInvocationFeatureActive(database.authority.client);
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_model_price',
|
||||
});
|
||||
return await execute(command, database, authenticated);
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
adapters.now(),
|
||||
);
|
||||
if (audit) await database.securityAudit.record(audit);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalModelPriceCatalogCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalModelPriceCatalogCommandResult>> {
|
||||
return createLocalModelPriceCatalogCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalModelProviderCredentialCommandFile } from './modelProviderCredentialCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-model-credential run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_MODEL_PROVIDER_CREDENTIAL_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalModelProviderCredentialCommandFile(
|
||||
commandFilePath,
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as { readonly code?: unknown; readonly name?: unknown };
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_MODEL_PROVIDER_CREDENTIAL_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,759 @@
|
||||
// AI management owns Provider credential binding commands.
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
MODEL_PROVIDER_CREDENTIAL_INSPECTION_OPERATION_ID,
|
||||
ModelProviderCredentialAdministrationAuthorizationFenceConflictError,
|
||||
ModelProviderCredentialAdministrationMutationConflictError,
|
||||
modelProviderCredentialAdministrationOperationId,
|
||||
} from '@qinglong/ai/model-provider-credential-administration';
|
||||
import {
|
||||
MODEL_PROVIDER_CREDENTIAL_TRANSITION_COMMAND_SCHEMA,
|
||||
InvalidModelProviderCredentialTransitionError,
|
||||
ModelProviderCredentialCatalogUnavailableError,
|
||||
ModelProviderCredentialTransitionConflictError,
|
||||
createModelProviderCredentialTransitionCommand,
|
||||
type ModelProviderCredentialTransition,
|
||||
} from '@qinglong/ai/model-provider-credential-catalog';
|
||||
import { MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA } from '@qinglong/ai/provider-credential';
|
||||
import {
|
||||
LocalModelProviderCredentialRepository,
|
||||
type LocalModelProviderCredentialAuthorizationInput,
|
||||
} from '@qinglong/ai/local-model-provider-credential-storage';
|
||||
import { assertLocalModelInvocationFeatureActive } from '@qinglong/ai/local-feature-activation';
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
commitLocalSqliteSecurityAuditInTransaction,
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence,
|
||||
confirmLocalSqliteProjectPolicyFence,
|
||||
openLocalSqliteAuthenticatedManagementDatabase,
|
||||
type LocalSqliteAuthenticatedManagementDatabase,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
type LocalSqliteProfile,
|
||||
} from '@qinglong/local-sqlite/authenticated-management';
|
||||
import { LocalSqliteProjectPolicyRepository } from '@qinglong/local-sqlite/project-policy';
|
||||
import {
|
||||
ProjectPolicyEngine,
|
||||
ProjectPolicyUnavailableError,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const STRONG_USER_ASSURANCES = new Set([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
|
||||
export interface LocalModelProviderCredentialCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface BaseRequest {
|
||||
readonly requestId: string;
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export interface BindLocalModelProviderCredentialCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'model-credential.bind';
|
||||
readonly options: LocalModelProviderCredentialCommandOptions;
|
||||
readonly request: BaseRequest & {
|
||||
readonly mutationId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly revision: string;
|
||||
readonly secretRef: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RevokeLocalModelProviderCredentialCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'model-credential.revoke';
|
||||
readonly options: LocalModelProviderCredentialCommandOptions;
|
||||
readonly request: BaseRequest & {
|
||||
readonly mutationId: string;
|
||||
readonly expectedGeneration: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InspectLocalModelProviderCredentialCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'model-credential.inspect';
|
||||
readonly options: LocalModelProviderCredentialCommandOptions;
|
||||
readonly request: BaseRequest & {
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalModelProviderCredentialCommand =
|
||||
| BindLocalModelProviderCredentialCommand
|
||||
| RevokeLocalModelProviderCredentialCommand
|
||||
| InspectLocalModelProviderCredentialCommand;
|
||||
|
||||
export type LocalModelProviderCredentialCommandResult = Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: LocalModelProviderCredentialCommand['operation'];
|
||||
status?: 'created' | 'existing';
|
||||
projectId: string;
|
||||
provider: string;
|
||||
state: 'bound' | 'revoked' | 'absent';
|
||||
generation: number;
|
||||
bindingRevision: string | null;
|
||||
bindingDigest: string | null;
|
||||
transitionDigest: string | null;
|
||||
changedAtMs: number | null;
|
||||
}>;
|
||||
|
||||
export class LocalModelProviderCredentialCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_MODEL_PROVIDER_CREDENTIAL_COMMAND_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local model provider credential command is invalid: ${message}`);
|
||||
this.name = 'LocalModelProviderCredentialCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalModelProviderCredentialAuthenticationError extends Error {
|
||||
readonly code = 'LOCAL_MODEL_PROVIDER_CREDENTIAL_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Local model provider credential management requires a strong User');
|
||||
this.name = 'LocalModelProviderCredentialAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalModelProviderCredentialAuthorizationError extends Error {
|
||||
readonly code = 'LOCAL_MODEL_PROVIDER_CREDENTIAL_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Local model provider credential management is forbidden');
|
||||
this.name = 'LocalModelProviderCredentialAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalModelProviderCredentialUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_MODEL_PROVIDER_CREDENTIAL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local model provider credential management is unavailable');
|
||||
this.name = 'LocalModelProviderCredentialUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
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 LocalModelProviderCredentialCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
`${label} must be below deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalModelProviderCredentialCommandOptions> {
|
||||
const optional =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs')
|
||||
? ['busyTimeoutMs']
|
||||
: [];
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'credentialFilePath',
|
||||
'databasePath',
|
||||
'deploymentRoot',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
...optional,
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
const databasePath = boundedPath(value.databasePath, 'databasePath');
|
||||
const ownerPepperKeyringDirectory = boundedPath(
|
||||
value.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const credentialFilePath = boundedPath(
|
||||
value.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
);
|
||||
for (const [candidate, label] of [
|
||||
[databasePath, 'databasePath'],
|
||||
[ownerPepperKeyringDirectory, 'ownerPepperKeyringDirectory'],
|
||||
[credentialFilePath, 'credentialFilePath'],
|
||||
] as const) {
|
||||
descendant(deploymentRoot, candidate, label);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
'profile is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
profile: value.profile,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
...(value.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: value.busyTimeoutMs as number }),
|
||||
});
|
||||
}
|
||||
|
||||
function id(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uuid(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
`${label} must be a UUID v4`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalModelProviderCredentialCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
(value.operation !== 'model-credential.bind' &&
|
||||
value.operation !== 'model-credential.revoke' &&
|
||||
value.operation !== 'model-credential.inspect')
|
||||
) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
'operation is invalid',
|
||||
);
|
||||
}
|
||||
const operation = value.operation;
|
||||
const extra =
|
||||
operation === 'model-credential.bind'
|
||||
? ['expectedGeneration', 'mutationId', 'revision', 'secretRef']
|
||||
: operation === 'model-credential.revoke'
|
||||
? ['expectedGeneration', 'mutationId']
|
||||
: ['auditEventId'];
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'failureAuditEventId',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
...extra,
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const request = value.request;
|
||||
const normalizedBase = {
|
||||
requestId: id(request.requestId, 'requestId'),
|
||||
projectId: id(request.projectId, 'projectId'),
|
||||
provider: id(request.provider, 'provider'),
|
||||
failureAuditEventId: uuid(
|
||||
request.failureAuditEventId,
|
||||
'failureAuditEventId',
|
||||
),
|
||||
};
|
||||
if (operation === 'model-credential.inspect') {
|
||||
const auditEventId = uuid(request.auditEventId, 'auditEventId');
|
||||
if (auditEventId === normalizedBase.failureAuditEventId) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
'audit identities must be distinct',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
options: normalizeOptions(value.options),
|
||||
request: Object.freeze({ ...normalizedBase, auditEventId }),
|
||||
});
|
||||
}
|
||||
const mutationId = uuid(request.mutationId, 'mutationId');
|
||||
if (mutationId === normalizedBase.failureAuditEventId) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
'audit identities must be distinct',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(request.expectedGeneration) ||
|
||||
(request.expectedGeneration as number) < 0 ||
|
||||
(request.expectedGeneration as number) > 2_147_483_646
|
||||
) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
'expectedGeneration is invalid',
|
||||
);
|
||||
}
|
||||
const options = normalizeOptions(value.options);
|
||||
if (operation === 'model-credential.bind') {
|
||||
if (typeof request.secretRef !== 'string') {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
'secretRef is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
options,
|
||||
request: Object.freeze({
|
||||
...normalizedBase,
|
||||
mutationId,
|
||||
expectedGeneration: request.expectedGeneration as number,
|
||||
revision: id(request.revision, 'revision'),
|
||||
secretRef: request.secretRef,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
options,
|
||||
request: Object.freeze({
|
||||
...normalizedBase,
|
||||
mutationId,
|
||||
expectedGeneration: request.expectedGeneration as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function readCommandFile(
|
||||
candidatePath: string,
|
||||
): Readonly<LocalModelProviderCredentialCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalModelProviderCredentialCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error instanceof PrivateLocalCommandFileError ? error : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function allowedAudit(
|
||||
command: Readonly<LocalModelProviderCredentialCommand>,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
decision: Readonly<SecurityPolicyDecision>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
const eventId =
|
||||
command.operation === 'model-credential.inspect'
|
||||
? command.request.auditEventId
|
||||
: command.request.mutationId;
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId:
|
||||
command.operation === 'model-credential.inspect'
|
||||
? MODEL_PROVIDER_CREDENTIAL_INSPECTION_OPERATION_ID
|
||||
: modelProviderCredentialAdministrationOperationId(
|
||||
command.operation === 'model-credential.bind' ? 'bind' : 'revoke',
|
||||
),
|
||||
projectId: command.request.projectId,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: decision.reasons,
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function summary(
|
||||
command: Readonly<LocalModelProviderCredentialCommand>,
|
||||
transition: Readonly<ModelProviderCredentialTransition> | null,
|
||||
status?: 'created' | 'existing',
|
||||
): LocalModelProviderCredentialCommandResult {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
...(status === undefined ? {} : { status }),
|
||||
projectId: command.request.projectId,
|
||||
provider: command.request.provider,
|
||||
state:
|
||||
transition === null
|
||||
? ('absent' as const)
|
||||
: transition.action === 'bind'
|
||||
? ('bound' as const)
|
||||
: ('revoked' as const),
|
||||
generation: transition?.generation ?? 0,
|
||||
bindingRevision: transition?.activeBindingRevision ?? null,
|
||||
bindingDigest: transition?.activeBindingDigest ?? null,
|
||||
transitionDigest: transition?.transitionDigest ?? null,
|
||||
changedAtMs: transition?.changedAtMs ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function now(value: () => number): number {
|
||||
const result = value();
|
||||
if (!Number.isSafeInteger(result) || result < 0) {
|
||||
throw new LocalModelProviderCredentialUnavailableError();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function authorize(
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
command: Readonly<LocalModelProviderCredentialCommand>,
|
||||
nowMs: number,
|
||||
): Promise<Readonly<SecurityPolicyDecision>> {
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(authenticated.principal, nowMs);
|
||||
} catch {
|
||||
throw new LocalModelProviderCredentialAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_USER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new LocalModelProviderCredentialAuthenticationError();
|
||||
}
|
||||
try {
|
||||
const decision = await new ProjectPolicyEngine(
|
||||
new LocalSqliteProjectPolicyRepository(database.authority),
|
||||
).authorize(principal, command.request.projectId, 'secret.manage');
|
||||
if (
|
||||
decision.effect !== 'allow' ||
|
||||
!decision.fence ||
|
||||
decision.fence.bindingVersion === null
|
||||
) {
|
||||
throw new LocalModelProviderCredentialAuthorizationError();
|
||||
}
|
||||
return decision;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalModelProviderCredentialAuthorizationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof ProjectPolicyUnavailableError) {
|
||||
throw new LocalModelProviderCredentialUnavailableError();
|
||||
}
|
||||
throw new LocalModelProviderCredentialUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAuthorization(
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
credentialFence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
input: LocalModelProviderCredentialAuthorizationInput,
|
||||
): void {
|
||||
try {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(
|
||||
database.authority,
|
||||
credentialFence,
|
||||
);
|
||||
confirmLocalSqliteProjectPolicyFence(
|
||||
database.authority,
|
||||
input.kind === 'mutation'
|
||||
? input.value.command.projectId
|
||||
: input.value.projectId,
|
||||
input.value.actor,
|
||||
input.value.fence,
|
||||
);
|
||||
} catch {
|
||||
throw new ModelProviderCredentialAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
try {
|
||||
commitLocalSqliteSecurityAuditInTransaction(
|
||||
database.authority,
|
||||
input.value.audit,
|
||||
input.replay,
|
||||
);
|
||||
} catch {
|
||||
throw new ModelProviderCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function failureAudit(
|
||||
command: Readonly<LocalModelProviderCredentialCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
let outcome: SecurityAuditRecord['outcome'];
|
||||
let reason: string;
|
||||
if (
|
||||
!authenticated ||
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError ||
|
||||
error instanceof LocalModelProviderCredentialAuthenticationError
|
||||
) {
|
||||
outcome = 'authentication_rejected';
|
||||
reason = 'credential_rejected';
|
||||
} else if (
|
||||
error instanceof LocalModelProviderCredentialAuthorizationError ||
|
||||
error instanceof LocalSqliteAuthenticatedManagementFenceError ||
|
||||
error instanceof
|
||||
ModelProviderCredentialAdministrationAuthorizationFenceConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_or_policy_fence_rejected';
|
||||
} else if (
|
||||
error instanceof LocalModelProviderCredentialCommandConfigurationError ||
|
||||
error instanceof InvalidModelProviderCredentialTransitionError ||
|
||||
error instanceof ModelProviderCredentialTransitionConflictError ||
|
||||
error instanceof ModelProviderCredentialAdministrationMutationConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'model_provider_credential_conflict';
|
||||
} else if (
|
||||
error instanceof ModelProviderCredentialCatalogUnavailableError ||
|
||||
error instanceof LocalModelProviderCredentialUnavailableError
|
||||
) {
|
||||
outcome = 'authorization_unavailable';
|
||||
reason = 'model_provider_credential_unavailable';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId:
|
||||
command.operation === 'model-credential.inspect'
|
||||
? MODEL_PROVIDER_CREDENTIAL_INSPECTION_OPERATION_ID
|
||||
: modelProviderCredentialAdministrationOperationId(
|
||||
command.operation === 'model-credential.bind' ? 'bind' : 'revoke',
|
||||
),
|
||||
projectId: command.request.projectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export interface LocalModelProviderCredentialCommandRunner {
|
||||
run(commandFilePath: string): Promise<LocalModelProviderCredentialCommandResult>;
|
||||
}
|
||||
|
||||
export interface LocalModelProviderCredentialCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteAuthenticatedManagementDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export function createLocalModelProviderCredentialCommandRunner(
|
||||
dependencies: LocalModelProviderCredentialCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
now: Date.now,
|
||||
},
|
||||
): Readonly<LocalModelProviderCredentialCommandRunner> {
|
||||
if (
|
||||
!dependencies ||
|
||||
typeof dependencies !== 'object' ||
|
||||
Array.isArray(dependencies) ||
|
||||
Object.keys(dependencies).sort().join('\0') !==
|
||||
'authenticate\0now\0openDatabase' ||
|
||||
typeof dependencies.openDatabase !== 'function' ||
|
||||
typeof dependencies.authenticate !== 'function' ||
|
||||
typeof dependencies.now !== 'function'
|
||||
) {
|
||||
throw new LocalModelProviderCredentialCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await dependencies.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
assertLocalModelInvocationFeatureActive(database.authority.client);
|
||||
try {
|
||||
authenticated = await dependencies.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_model_provider_credential',
|
||||
});
|
||||
await authenticated.confirm();
|
||||
const credentialFence =
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>;
|
||||
database.confirmUserCredentialFence(credentialFence);
|
||||
const observedAtMs = now(dependencies.now);
|
||||
const decision = await authorize(
|
||||
database,
|
||||
authenticated,
|
||||
command,
|
||||
observedAtMs,
|
||||
);
|
||||
const audit = allowedAudit(
|
||||
command,
|
||||
authenticated.principal,
|
||||
decision,
|
||||
observedAtMs,
|
||||
);
|
||||
const repository = new LocalModelProviderCredentialRepository(
|
||||
database.authority,
|
||||
{
|
||||
now: dependencies.now,
|
||||
authorization: Object.freeze({
|
||||
confirm(input: LocalModelProviderCredentialAuthorizationInput) {
|
||||
confirmAuthorization(database, credentialFence, input);
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (command.operation === 'model-credential.inspect') {
|
||||
const transition = await repository.inspectAuthorized({
|
||||
projectId: command.request.projectId,
|
||||
provider: command.request.provider,
|
||||
actor: authenticated.principal.subject,
|
||||
fence: decision.fence!,
|
||||
audit,
|
||||
});
|
||||
return summary(command, transition);
|
||||
}
|
||||
const action =
|
||||
command.operation === 'model-credential.bind'
|
||||
? ('bind' as const)
|
||||
: ('revoke' as const);
|
||||
const transitionCommand = createModelProviderCredentialTransitionCommand({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TRANSITION_COMMAND_SCHEMA,
|
||||
mutationId: command.request.mutationId,
|
||||
projectId: command.request.projectId,
|
||||
provider: command.request.provider,
|
||||
expectedGeneration: command.request.expectedGeneration,
|
||||
action,
|
||||
binding:
|
||||
command.operation === 'model-credential.bind'
|
||||
? Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA,
|
||||
projectId: command.request.projectId,
|
||||
provider: command.request.provider,
|
||||
revision: command.request.revision,
|
||||
secretRef: command.request.secretRef,
|
||||
scheme: 'bearer' as const,
|
||||
})
|
||||
: null,
|
||||
changedBy: authenticated.principal.subject,
|
||||
});
|
||||
const result = await repository.commitAuthorized({
|
||||
command: transitionCommand,
|
||||
actor: authenticated.principal.subject,
|
||||
fence: decision.fence!,
|
||||
audit,
|
||||
});
|
||||
return summary(command, result.transition, result.status);
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
now(dependencies.now),
|
||||
);
|
||||
if (audit) await database.securityAudit.record(audit);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalModelProviderCredentialCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<LocalModelProviderCredentialCommandResult> {
|
||||
return createLocalModelProviderCredentialCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
LocalOwnerSecretDeliveryError,
|
||||
openLocalOwnerConsole,
|
||||
type ClaimLocalOwnerFromDeliveriesRequest,
|
||||
type LocalOwnerConsole,
|
||||
type LocalOwnerSecretDeliverySummary,
|
||||
type OpenLocalOwnerConsoleOptions,
|
||||
} from '@qinglong/local-owner-console';
|
||||
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface ProvisionLocalOwnerIdentityCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.identity.provision';
|
||||
readonly options: OpenLocalOwnerConsoleOptions;
|
||||
readonly request: {
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly credentialTtlMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueLocalOwnerChallengeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.challenge.issue';
|
||||
readonly options: OpenLocalOwnerConsoleOptions;
|
||||
readonly request: {
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly ttlMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClaimLocalOwnerCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.claim.from-deliveries';
|
||||
readonly options: OpenLocalOwnerConsoleOptions;
|
||||
readonly request: ClaimLocalOwnerFromDeliveriesRequest;
|
||||
}
|
||||
|
||||
export interface InspectLocalOwnerDeliveryCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.delivery.inspect';
|
||||
readonly options: OpenLocalOwnerConsoleOptions;
|
||||
readonly request: {
|
||||
readonly kind: 'credential' | 'challenge';
|
||||
readonly mutationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalOwnerDeliveryPurpose =
|
||||
| 'credential-provisioning'
|
||||
| 'credential-recovery'
|
||||
| 'challenge';
|
||||
|
||||
export interface AcknowledgeLocalOwnerDeliveryCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.delivery.acknowledge';
|
||||
readonly options: OpenLocalOwnerConsoleOptions;
|
||||
readonly request: {
|
||||
readonly purpose: LocalOwnerDeliveryPurpose;
|
||||
readonly mutationId: string;
|
||||
readonly expectedDeliveryDigest: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueLocalOwnerCredentialRecoveryCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.credential-recovery.issue';
|
||||
readonly options: OpenLocalOwnerConsoleOptions;
|
||||
readonly request: {
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly previousCredentialId: string;
|
||||
readonly expectedPreviousVersion: number;
|
||||
readonly credentialTtlMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompleteLocalOwnerCredentialRecoveryCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.credential-recovery.complete';
|
||||
readonly options: OpenLocalOwnerConsoleOptions;
|
||||
readonly request: {
|
||||
readonly issueMutationId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalOwnerCommand =
|
||||
| ProvisionLocalOwnerIdentityCommand
|
||||
| IssueLocalOwnerChallengeCommand
|
||||
| ClaimLocalOwnerCommand
|
||||
| InspectLocalOwnerDeliveryCommand
|
||||
| AcknowledgeLocalOwnerDeliveryCommand
|
||||
| IssueLocalOwnerCredentialRecoveryCommand
|
||||
| CompleteLocalOwnerCredentialRecoveryCommand;
|
||||
|
||||
export interface LocalOwnerCommandRunnerDependencies {
|
||||
readonly openConsole: typeof openLocalOwnerConsole;
|
||||
}
|
||||
|
||||
export interface LocalOwnerCommandRunner {
|
||||
run(commandFilePath: string): Promise<Readonly<LocalOwnerCommandResult>>;
|
||||
}
|
||||
|
||||
export type LocalOwnerCommandResult = Readonly<
|
||||
Record<string, unknown> & {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalOwnerCommand['operation'];
|
||||
}
|
||||
>;
|
||||
|
||||
export class LocalOwnerCliConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_OWNER_CLI_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Owner CLI configuration is invalid: ${message}`);
|
||||
this.name = 'LocalOwnerCliConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
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 normalizeCommand(value: unknown): Readonly<LocalOwnerCommand> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['schemaVersion', 'operation', 'options', 'request'])
|
||||
) {
|
||||
throw new LocalOwnerCliConfigurationError('command shape is invalid');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const operations: readonly LocalOwnerCommand['operation'][] = [
|
||||
'owner.identity.provision',
|
||||
'owner.challenge.issue',
|
||||
'owner.claim.from-deliveries',
|
||||
'owner.delivery.inspect',
|
||||
'owner.delivery.acknowledge',
|
||||
'owner.credential-recovery.issue',
|
||||
'owner.credential-recovery.complete',
|
||||
];
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
!operations.includes(
|
||||
candidate.operation as LocalOwnerCommand['operation'],
|
||||
) ||
|
||||
!candidate.options ||
|
||||
typeof candidate.options !== 'object' ||
|
||||
Array.isArray(candidate.options) ||
|
||||
!candidate.request ||
|
||||
typeof candidate.request !== 'object' ||
|
||||
Array.isArray(candidate.request)
|
||||
) {
|
||||
throw new LocalOwnerCliConfigurationError('command value is invalid');
|
||||
}
|
||||
return Object.freeze(value as LocalOwnerCommand);
|
||||
}
|
||||
|
||||
function readCommandFile(candidatePath: string): Readonly<LocalOwnerCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerCliConfigurationError) throw error;
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LocalOwnerCliConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new LocalOwnerCliConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDeliveryRequest(
|
||||
value: InspectLocalOwnerDeliveryCommand['request'],
|
||||
): void {
|
||||
if (
|
||||
!exactKeys(value, ['kind', 'mutationId']) ||
|
||||
(value.kind !== 'credential' && value.kind !== 'challenge') ||
|
||||
!UUID_V4_PATTERN.test(value.mutationId)
|
||||
) {
|
||||
throw new LocalOwnerCliConfigurationError(
|
||||
'delivery inspect request is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAcknowledgementRequest(
|
||||
value: AcknowledgeLocalOwnerDeliveryCommand['request'],
|
||||
): void {
|
||||
if (
|
||||
!exactKeys(value, ['purpose', 'mutationId', 'expectedDeliveryDigest']) ||
|
||||
!['credential-provisioning', 'credential-recovery', 'challenge'].includes(
|
||||
value.purpose,
|
||||
) ||
|
||||
!UUID_V4_PATTERN.test(value.mutationId) ||
|
||||
!DIGEST_PATTERN.test(value.expectedDeliveryDigest)
|
||||
) {
|
||||
throw new LocalOwnerCliConfigurationError(
|
||||
'delivery acknowledgement request is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
let current = error;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
if (!current || typeof current !== 'object') return false;
|
||||
if ('code' in current && current.code === 'ENOENT') return true;
|
||||
current = 'cause' in current ? current.cause : undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function deliveryAfterMutation(
|
||||
status: 'inserted' | 'existing',
|
||||
inspect: () => Readonly<LocalOwnerSecretDeliverySummary>,
|
||||
): Readonly<LocalOwnerSecretDeliverySummary> | null {
|
||||
try {
|
||||
return inspect();
|
||||
} catch (error) {
|
||||
if (
|
||||
status === 'existing' &&
|
||||
error instanceof LocalOwnerSecretDeliveryError &&
|
||||
missing(error)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalOwnerCommandRunnerDependencies,
|
||||
): Readonly<LocalOwnerCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['openConsole']) ||
|
||||
typeof value.openConsole !== 'function'
|
||||
) {
|
||||
throw new LocalOwnerCliConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
async function execute(
|
||||
console: LocalOwnerConsole,
|
||||
command: Readonly<LocalOwnerCommand>,
|
||||
): Promise<Readonly<LocalOwnerCommandResult>> {
|
||||
switch (command.operation) {
|
||||
case 'owner.identity.provision': {
|
||||
const result = await console.service.provision(command.request);
|
||||
const delivery = deliveryAfterMutation(result.status, () =>
|
||||
console.inspectCredentialDelivery(command.request.mutationId),
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
subjectId: result.subjectId,
|
||||
credentialId: result.credentialId,
|
||||
expiresAtMs: result.expiresAtMs,
|
||||
delivery,
|
||||
});
|
||||
}
|
||||
case 'owner.challenge.issue': {
|
||||
const result = await console.service.issue(command.request);
|
||||
const delivery = deliveryAfterMutation(result.status, () =>
|
||||
console.inspectChallengeDelivery(command.request.mutationId),
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
challengeId: result.challengeId,
|
||||
expiresAtMs: result.expiresAtMs,
|
||||
delivery,
|
||||
});
|
||||
}
|
||||
case 'owner.claim.from-deliveries': {
|
||||
const result = await console.claimOwnerFromDeliveries(command.request);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
projectId: result.challenge.projectId,
|
||||
challengeId: result.challenge.challengeId,
|
||||
subject: result.binding.subject,
|
||||
role: result.binding.role,
|
||||
bindingVersion: result.binding.version,
|
||||
claimedAtMs: result.challenge.consumedAtMs,
|
||||
});
|
||||
}
|
||||
case 'owner.delivery.inspect': {
|
||||
validateDeliveryRequest(command.request);
|
||||
const delivery =
|
||||
command.request.kind === 'credential'
|
||||
? console.inspectCredentialDelivery(command.request.mutationId)
|
||||
: console.inspectChallengeDelivery(command.request.mutationId);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
delivery,
|
||||
});
|
||||
}
|
||||
case 'owner.delivery.acknowledge': {
|
||||
validateAcknowledgementRequest(command.request);
|
||||
const acknowledgement =
|
||||
command.request.purpose === 'credential-provisioning'
|
||||
? await console.acknowledgeCredentialDelivery(
|
||||
command.request.mutationId,
|
||||
command.request.expectedDeliveryDigest,
|
||||
)
|
||||
: command.request.purpose === 'credential-recovery'
|
||||
? await console.acknowledgeCredentialRecoveryDelivery(
|
||||
command.request.mutationId,
|
||||
command.request.expectedDeliveryDigest,
|
||||
)
|
||||
: await console.acknowledgeChallengeDelivery(
|
||||
command.request.mutationId,
|
||||
command.request.expectedDeliveryDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
purpose: command.request.purpose,
|
||||
kind: acknowledgement.kind,
|
||||
state: acknowledgement.state,
|
||||
mutationId: acknowledgement.mutationId,
|
||||
requestId: acknowledgement.requestId,
|
||||
ttlMs: acknowledgement.ttlMs,
|
||||
});
|
||||
}
|
||||
case 'owner.credential-recovery.issue': {
|
||||
const result = await console.credentialRecovery.issue(command.request);
|
||||
const delivery = deliveryAfterMutation(result.status, () =>
|
||||
console.inspectCredentialDelivery(command.request.mutationId),
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
subjectId: result.subjectId,
|
||||
previousCredentialId: result.previousCredentialId,
|
||||
replacementCredentialId: result.replacementCredentialId,
|
||||
expiresAtMs: result.expiresAtMs,
|
||||
state: result.state,
|
||||
delivery,
|
||||
});
|
||||
}
|
||||
case 'owner.credential-recovery.complete': {
|
||||
const result = await console.credentialRecovery.complete(command.request);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
previousCredentialId: result.previousCredentialId,
|
||||
replacementCredentialId: result.replacementCredentialId,
|
||||
state: result.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalOwnerCommandRunner(
|
||||
candidateDependencies: LocalOwnerCommandRunnerDependencies = {
|
||||
openConsole: openLocalOwnerConsole,
|
||||
},
|
||||
): LocalOwnerCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const console = await adapters.openConsole(command.options);
|
||||
try {
|
||||
return await execute(console, command);
|
||||
} finally {
|
||||
await console.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalOwnerCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalOwnerCommandResult>> {
|
||||
return createLocalOwnerCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalApprovalCommandFile } from './approvalCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-approval run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_APPROVAL_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalApprovalCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_APPROVAL_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Approval command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,556 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
ApprovalInspectionAuthorizationError,
|
||||
ApprovalInspectionUnavailableError,
|
||||
createApprovalInspectionService,
|
||||
} from '@qinglong/runtime-core/approval-inspection';
|
||||
import {
|
||||
ApprovalDecisionAuthorizationError,
|
||||
ApprovalDecisionBindingConflictError,
|
||||
ApprovalDecisionTargetUnavailableError,
|
||||
ApprovalDecisionUnavailableError,
|
||||
createApprovalDecisionService,
|
||||
type ApprovalDecisionService,
|
||||
} from '@qinglong/runtime-core/approval-decision';
|
||||
import {
|
||||
ApprovalMutationConflictError,
|
||||
ApprovalPolicyFenceConflictError,
|
||||
ApprovalRequestExpiredError,
|
||||
ApprovalRequestStateConflictError,
|
||||
ApprovalRequestVersionConflictError,
|
||||
ApprovalUnavailableError,
|
||||
normalizeApprovedActionBinding,
|
||||
type ApprovalDecision,
|
||||
type ApprovedActionBinding,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
import {
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
} from '@qinglong/local-sqlite/authenticated-management';
|
||||
import {
|
||||
openLocalSqliteApprovalDecisionDatabase,
|
||||
type LocalSqliteApprovalDecisionDatabase,
|
||||
} from '@qinglong/local-sqlite/approval-decision-database';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
export interface LocalApprovalCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface BaseLocalApprovalRequest {
|
||||
readonly projectId: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export interface InspectLocalApprovalCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'approval.inspect';
|
||||
readonly options: LocalApprovalCommandOptions;
|
||||
readonly request: BaseLocalApprovalRequest;
|
||||
}
|
||||
|
||||
export interface DecideLocalApprovalCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'approval.decide';
|
||||
readonly options: LocalApprovalCommandOptions;
|
||||
readonly request: BaseLocalApprovalRequest & {
|
||||
readonly expectedVersion: 1;
|
||||
readonly expectedAction: Readonly<ApprovedActionBinding>;
|
||||
readonly decisionId: string;
|
||||
readonly decision: ApprovalDecision;
|
||||
readonly reasonCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalApprovalCommand =
|
||||
| InspectLocalApprovalCommand
|
||||
| DecideLocalApprovalCommand;
|
||||
|
||||
export type LocalApprovalCommandResult = Readonly<
|
||||
Record<string, unknown> & {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalApprovalCommand['operation'];
|
||||
}
|
||||
>;
|
||||
|
||||
export interface LocalApprovalCommandRunner {
|
||||
run(commandFilePath: string): Promise<Readonly<LocalApprovalCommandResult>>;
|
||||
}
|
||||
|
||||
export interface LocalApprovalCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteApprovalDecisionDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly createDecisionService: typeof createApprovalDecisionService;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalApprovalCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_APPROVAL_COMMAND_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Approval command is invalid: ${message}`);
|
||||
this.name = 'LocalApprovalCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalApprovalCommandAuthorizationError extends Error {
|
||||
readonly code = 'LOCAL_APPROVAL_COMMAND_AUTHORIZATION_REJECTED';
|
||||
|
||||
constructor() {
|
||||
super('Local Approval command authorization was rejected');
|
||||
this.name = 'LocalApprovalCommandAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !== [...expected].sort().join('\0')
|
||||
) {
|
||||
throw new LocalApprovalCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalApprovalCommandConfigurationError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new LocalApprovalCommandConfigurationError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uuid(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
|
||||
throw new LocalApprovalCommandConfigurationError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOptions(value: unknown): Readonly<LocalApprovalCommandOptions> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalApprovalCommandConfigurationError('options are invalid');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
exactObject(
|
||||
candidate,
|
||||
[
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'profile',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
...(candidate.busyTimeoutMs === undefined ? [] : ['busyTimeoutMs']),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
candidate.profile !== 'edge' &&
|
||||
candidate.profile !== 'standalone'
|
||||
) {
|
||||
throw new LocalApprovalCommandConfigurationError('profile is invalid');
|
||||
}
|
||||
if (
|
||||
candidate.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(candidate.busyTimeoutMs) ||
|
||||
Number(candidate.busyTimeoutMs) < 0 ||
|
||||
Number(candidate.busyTimeoutMs) > 60_000)
|
||||
) {
|
||||
throw new LocalApprovalCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot: boundedPath(candidate.deploymentRoot, 'deploymentRoot'),
|
||||
databasePath: boundedPath(candidate.databasePath, 'databasePath'),
|
||||
profile: candidate.profile,
|
||||
ownerPepperKeyringDirectory: boundedPath(
|
||||
candidate.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
),
|
||||
credentialFilePath: boundedPath(
|
||||
candidate.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
),
|
||||
...(candidate.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: Number(candidate.busyTimeoutMs) }),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAction(value: unknown): Readonly<ApprovedActionBinding> {
|
||||
try {
|
||||
return normalizeApprovedActionBinding(value as ApprovedActionBinding);
|
||||
} catch {
|
||||
throw new LocalApprovalCommandConfigurationError(
|
||||
'expectedAction is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseRequest(value: Record<string, unknown>) {
|
||||
return Object.freeze({
|
||||
projectId: identifier(value.projectId, 'projectId'),
|
||||
approvalRequestId: identifier(
|
||||
value.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
),
|
||||
requestId: identifier(value.requestId, 'requestId'),
|
||||
auditEventId: uuid(value.auditEventId, 'auditEventId'),
|
||||
failureAuditEventId: uuid(
|
||||
value.failureAuditEventId,
|
||||
'failureAuditEventId',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCommand(value: unknown): Readonly<LocalApprovalCommand> {
|
||||
exactObject(value, ['schemaVersion', 'operation', 'options', 'request'], 'command');
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
(value.operation !== 'approval.inspect' &&
|
||||
value.operation !== 'approval.decide')
|
||||
) {
|
||||
throw new LocalApprovalCommandConfigurationError('command is invalid');
|
||||
}
|
||||
const options = normalizeOptions(value.options);
|
||||
const request = value.request;
|
||||
if (value.operation === 'approval.inspect') {
|
||||
exactObject(
|
||||
request,
|
||||
[
|
||||
'projectId',
|
||||
'approvalRequestId',
|
||||
'requestId',
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
],
|
||||
'inspect request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options,
|
||||
request: normalizeBaseRequest(request),
|
||||
});
|
||||
}
|
||||
exactObject(
|
||||
request,
|
||||
[
|
||||
'projectId',
|
||||
'approvalRequestId',
|
||||
'requestId',
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'expectedVersion',
|
||||
'expectedAction',
|
||||
'decisionId',
|
||||
'decision',
|
||||
'reasonCode',
|
||||
],
|
||||
'decision request',
|
||||
);
|
||||
if (
|
||||
request.expectedVersion !== 1 ||
|
||||
(request.decision !== 'approved' && request.decision !== 'rejected') ||
|
||||
typeof request.reasonCode !== 'string' ||
|
||||
!REASON_PATTERN.test(request.reasonCode)
|
||||
) {
|
||||
throw new LocalApprovalCommandConfigurationError(
|
||||
'decision tuple is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options,
|
||||
request: Object.freeze({
|
||||
...normalizeBaseRequest(request),
|
||||
expectedVersion: 1,
|
||||
expectedAction: normalizeAction(request.expectedAction),
|
||||
decisionId: identifier(request.decisionId, 'decisionId'),
|
||||
decision: request.decision,
|
||||
reasonCode: request.reasonCode,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function readCommandFile(candidatePath: string): Readonly<LocalApprovalCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalApprovalCommandConfigurationError) throw error;
|
||||
throw new LocalApprovalCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function failureAudit(
|
||||
command: Readonly<LocalApprovalCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
let outcome: SecurityAuditRecord['outcome'];
|
||||
let reason: string;
|
||||
if (
|
||||
!authenticated ||
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError
|
||||
) {
|
||||
outcome = 'authentication_rejected';
|
||||
reason = 'credential_rejected';
|
||||
} else if (
|
||||
error instanceof LocalApprovalCommandAuthorizationError ||
|
||||
error instanceof ApprovalInspectionAuthorizationError ||
|
||||
error instanceof ApprovalDecisionAuthorizationError ||
|
||||
error instanceof LocalSqliteAuthenticatedManagementFenceError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_or_policy_fence_rejected';
|
||||
} else if (error instanceof ApprovalDecisionTargetUnavailableError) {
|
||||
outcome = 'denied';
|
||||
reason = 'approval_target_unavailable';
|
||||
} else if (error instanceof ApprovalDecisionBindingConflictError) {
|
||||
outcome = 'denied';
|
||||
reason = 'approval_binding_conflict';
|
||||
} else if (
|
||||
error instanceof ApprovalRequestVersionConflictError ||
|
||||
error instanceof ApprovalRequestStateConflictError ||
|
||||
error instanceof ApprovalRequestExpiredError ||
|
||||
error instanceof ApprovalMutationConflictError ||
|
||||
error instanceof ApprovalPolicyFenceConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'approval_state_or_fence_conflict';
|
||||
} else if (
|
||||
error instanceof ApprovalDecisionUnavailableError ||
|
||||
error instanceof ApprovalInspectionUnavailableError ||
|
||||
error instanceof ApprovalUnavailableError
|
||||
) {
|
||||
outcome = 'authorization_unavailable';
|
||||
reason = 'approval_authority_unavailable';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: command.operation,
|
||||
projectId: command.request.projectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalApprovalCommandRunnerDependencies,
|
||||
): Readonly<LocalApprovalCommandRunnerDependencies> {
|
||||
exactObject(
|
||||
value,
|
||||
['openDatabase', 'authenticate', 'createDecisionService', 'now'],
|
||||
'runner dependencies',
|
||||
);
|
||||
if (
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.createDecisionService !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalApprovalCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
async function activateFence(
|
||||
database: LocalSqliteApprovalDecisionDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<void> {
|
||||
await authenticated.confirm();
|
||||
database.activateUserCredentialFence(
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
);
|
||||
}
|
||||
|
||||
export function createLocalApprovalCommandRunner(
|
||||
candidateDependencies: LocalApprovalCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteApprovalDecisionDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
createDecisionService: createApprovalDecisionService,
|
||||
now: Date.now,
|
||||
},
|
||||
): Readonly<LocalApprovalCommandRunner> {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_approval',
|
||||
});
|
||||
await activateFence(database, authenticated);
|
||||
const policy = new ProjectPolicyEngine(database.projectPolicy);
|
||||
if (command.operation === 'approval.inspect') {
|
||||
const inspection = createApprovalInspectionService({
|
||||
source: database.approvalDetails,
|
||||
policy,
|
||||
audit: database.securityAudit,
|
||||
now: adapters.now,
|
||||
confirmAuthorization: async () => {
|
||||
await authenticated!.confirm();
|
||||
database.confirmUserCredentialFence();
|
||||
},
|
||||
});
|
||||
const detail = await inspection.inspect({
|
||||
projectId: command.request.projectId,
|
||||
approvalRequestId: command.request.approvalRequestId,
|
||||
auditEventId: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
if (!detail) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
found: false,
|
||||
});
|
||||
}
|
||||
const request = detail.request;
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
found: true,
|
||||
projectId: request.projectId,
|
||||
approvalRequestId: request.id,
|
||||
version: request.version,
|
||||
state: request.state,
|
||||
risk: request.risk,
|
||||
decisionMode: request.decisionMode,
|
||||
expectedAction: request.action,
|
||||
requestedBy: request.requestedBy,
|
||||
requestedAtMs: request.requestedAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
preview: detail.preview,
|
||||
});
|
||||
}
|
||||
const service: Readonly<ApprovalDecisionService> =
|
||||
adapters.createDecisionService({
|
||||
approvals: database.approvals,
|
||||
policy,
|
||||
now: adapters.now,
|
||||
confirmAuthorization: async () => {
|
||||
await authenticated!.confirm();
|
||||
database.confirmUserCredentialFence();
|
||||
},
|
||||
});
|
||||
const result = await service.decide({
|
||||
projectId: command.request.projectId,
|
||||
approvalRequestId: command.request.approvalRequestId,
|
||||
expectedVersion: command.request.expectedVersion,
|
||||
expectedAction: command.request.expectedAction,
|
||||
decisionId: command.request.decisionId,
|
||||
decision: command.request.decision,
|
||||
reasonCode: command.request.reasonCode,
|
||||
auditEventId: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
projectId: result.request.projectId,
|
||||
approvalRequestId: result.request.id,
|
||||
version: result.request.version,
|
||||
state: result.request.state,
|
||||
decision: result.request.decision,
|
||||
reasonCode: result.request.decisionReasonCode,
|
||||
decidedBy: result.request.decidedBy,
|
||||
decidedAtMs: result.request.decidedAtMs,
|
||||
action: result.request.action,
|
||||
});
|
||||
} catch (error) {
|
||||
const failure = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
adapters.now(),
|
||||
);
|
||||
if (failure) await database.securityAudit.record(failure);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalApprovalCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalApprovalCommandResult>> {
|
||||
return createLocalApprovalCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalTaskDefinitionCommandFile } from './taskDefinitionCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-task run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_TASK_DEFINITION_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalTaskDefinitionCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_TASK_DEFINITION_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local TaskDefinition command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,668 @@
|
||||
// Automation management owns TaskDefinition authoring and inspection commands.
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
LocalTaskDefinitionAdministrationAuthenticationError,
|
||||
LocalTaskDefinitionAdministrationAuthorizationError,
|
||||
LocalTaskDefinitionAdministrationConfigurationError,
|
||||
LocalTaskDefinitionAdministrationUnavailableError,
|
||||
createLocalTaskDefinitionAdministrationService,
|
||||
} from '@qinglong/local-admin/task-definition-administration';
|
||||
import {
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
} from '@qinglong/local-sqlite/authenticated-management';
|
||||
import {
|
||||
openLocalSqliteTaskDefinitionAdministrationDatabase,
|
||||
type LocalSqliteTaskDefinitionAdministrationDatabase,
|
||||
} from '@qinglong/local-sqlite/task-definition-administration';
|
||||
import {
|
||||
InvalidTaskDefinitionError,
|
||||
TaskDefinitionConflictError,
|
||||
TaskDefinitionUnavailableError,
|
||||
assertTaskDefinitionIdentifier,
|
||||
assertTaskDefinitionPageSize,
|
||||
normalizeAppendTaskDefinitionRevisionCommand,
|
||||
normalizeTaskDefinitionCursor,
|
||||
type AppendTaskDefinitionRevisionCommand,
|
||||
type TaskDefinitionRecord,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
TaskDefinitionAdministrationAuthorizationFenceConflictError,
|
||||
TaskDefinitionAdministrationMutationConflictError,
|
||||
} from '@qinglong/runtime-core/task-definition-administration';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export interface LocalTaskDefinitionCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface BaseTaskDefinitionCommandRequest {
|
||||
readonly projectId: string;
|
||||
readonly requestId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export interface PutLocalTaskDefinitionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'task.put';
|
||||
readonly options: LocalTaskDefinitionCommandOptions;
|
||||
readonly request: BaseTaskDefinitionCommandRequest &
|
||||
Omit<AppendTaskDefinitionRevisionCommand, 'projectId'>;
|
||||
}
|
||||
|
||||
export interface InspectLocalTaskDefinitionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'task.inspect';
|
||||
readonly options: LocalTaskDefinitionCommandOptions;
|
||||
readonly request: BaseTaskDefinitionCommandRequest & {
|
||||
readonly taskId: string;
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListLocalTaskDefinitionsCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'task.list';
|
||||
readonly options: LocalTaskDefinitionCommandOptions;
|
||||
readonly request: BaseTaskDefinitionCommandRequest & {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{ readonly taskId: string }>;
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalTaskDefinitionCommand =
|
||||
| PutLocalTaskDefinitionCommand
|
||||
| InspectLocalTaskDefinitionCommand
|
||||
| ListLocalTaskDefinitionsCommand;
|
||||
|
||||
export type LocalTaskDefinitionSummary = Readonly<{
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
revision: number;
|
||||
name: string;
|
||||
kind: TaskDefinitionRecord['kind'];
|
||||
schema: string;
|
||||
enabled: boolean;
|
||||
contentDigest: string;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}>;
|
||||
|
||||
export type LocalTaskDefinitionCommandResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.put';
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
task: LocalTaskDefinitionSummary;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.inspect';
|
||||
found: false;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.inspect';
|
||||
found: true;
|
||||
task: LocalTaskDefinitionSummary;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'task.list';
|
||||
projectId: string;
|
||||
tasks: readonly LocalTaskDefinitionSummary[];
|
||||
nextCursor: Readonly<{ readonly taskId: string }> | null;
|
||||
}>;
|
||||
|
||||
export interface LocalTaskDefinitionCommandRunner {
|
||||
run(commandFilePath: string): Promise<LocalTaskDefinitionCommandResult>;
|
||||
}
|
||||
|
||||
export interface LocalTaskDefinitionCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteTaskDefinitionAdministrationDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly createService: typeof createLocalTaskDefinitionAdministrationService;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalTaskDefinitionCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_TASK_DEFINITION_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local TaskDefinition command configuration is invalid: ${message}`);
|
||||
this.name = 'LocalTaskDefinitionCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
required.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalTaskDefinitionCommandOptions> {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'credentialFilePath',
|
||||
'databasePath',
|
||||
'deploymentRoot',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
[],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
for (const key of [
|
||||
'databasePath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
] as const) {
|
||||
const candidate = boundedPath(value[key], key);
|
||||
descendant(deploymentRoot, candidate, key);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze(value as unknown as LocalTaskDefinitionCommandOptions);
|
||||
}
|
||||
|
||||
function requestIdentity(
|
||||
value: Record<string, unknown>,
|
||||
eventKeys: readonly string[],
|
||||
): void {
|
||||
if (
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId) ||
|
||||
eventKeys.some(
|
||||
(key) =>
|
||||
typeof value[key] !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value[key] as string),
|
||||
) ||
|
||||
new Set(eventKeys.map((key) => value[key])).size !== eventKeys.length
|
||||
) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'request identity is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePutRequest(
|
||||
value: unknown,
|
||||
): Readonly<PutLocalTaskDefinitionCommand['request']> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'enabled',
|
||||
'expectedRevision',
|
||||
'failureAuditEventId',
|
||||
'kind',
|
||||
'labels',
|
||||
'mutationId',
|
||||
'name',
|
||||
'occurredAtMs',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'spec',
|
||||
'taskId',
|
||||
],
|
||||
['description'],
|
||||
'request',
|
||||
);
|
||||
requestIdentity(value, ['failureAuditEventId', 'mutationId']);
|
||||
try {
|
||||
const { failureAuditEventId: _failure, requestId: _request, ...command } =
|
||||
value;
|
||||
normalizeAppendTaskDefinitionRevisionCommand(
|
||||
command as unknown as AppendTaskDefinitionRevisionCommand,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'TaskDefinition request is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return Object.freeze(value as unknown as PutLocalTaskDefinitionCommand['request']);
|
||||
}
|
||||
|
||||
function normalizeInspectRequest(
|
||||
value: unknown,
|
||||
): Readonly<InspectLocalTaskDefinitionCommand['request']> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'taskId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
requestIdentity(value, ['auditEventId', 'failureAuditEventId']);
|
||||
try {
|
||||
assertTaskDefinitionIdentifier(value.projectId, 'projectId');
|
||||
assertTaskDefinitionIdentifier(value.taskId, 'taskId');
|
||||
} catch (error) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'TaskDefinition identity is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
value as unknown as InspectLocalTaskDefinitionCommand['request'],
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeListRequest(
|
||||
value: unknown,
|
||||
): Readonly<ListLocalTaskDefinitionsCommand['request']> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'limit',
|
||||
'projectId',
|
||||
'requestId',
|
||||
],
|
||||
['after'],
|
||||
'request',
|
||||
);
|
||||
requestIdentity(value, ['auditEventId', 'failureAuditEventId']);
|
||||
try {
|
||||
assertTaskDefinitionIdentifier(value.projectId, 'projectId');
|
||||
assertTaskDefinitionPageSize(value.limit as number);
|
||||
const after =
|
||||
value.after === undefined
|
||||
? undefined
|
||||
: normalizeTaskDefinitionCursor(
|
||||
value.after as Readonly<{ readonly taskId: string }>,
|
||||
);
|
||||
return Object.freeze({
|
||||
...(value as unknown as ListLocalTaskDefinitionsCommand['request']),
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'TaskDefinition list request is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommand(value: unknown): Readonly<LocalTaskDefinitionCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
[],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
!['task.inspect', 'task.list', 'task.put'].includes(
|
||||
value.operation as string,
|
||||
)
|
||||
) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'command version or operation is invalid',
|
||||
);
|
||||
}
|
||||
const options = normalizeOptions(value.options);
|
||||
if (value.operation === 'task.put') {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'task.put',
|
||||
options,
|
||||
request: normalizePutRequest(value.request),
|
||||
});
|
||||
}
|
||||
if (value.operation === 'task.inspect') {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'task.inspect',
|
||||
options,
|
||||
request: normalizeInspectRequest(value.request),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'task.list',
|
||||
options,
|
||||
request: normalizeListRequest(value.request),
|
||||
});
|
||||
}
|
||||
|
||||
function readCommandFile(candidatePath: string): Readonly<LocalTaskDefinitionCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalTaskDefinitionCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function summary(value: Readonly<TaskDefinitionRecord>): LocalTaskDefinitionSummary {
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
taskId: value.taskId,
|
||||
revision: value.revision,
|
||||
name: value.name,
|
||||
kind: value.kind,
|
||||
schema: value.spec.schema,
|
||||
enabled: value.enabled,
|
||||
contentDigest: value.contentDigest,
|
||||
createdAtMs: value.createdAtMs,
|
||||
updatedAtMs: value.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function failureAudit(
|
||||
command: Readonly<LocalTaskDefinitionCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
if (
|
||||
error instanceof LocalTaskDefinitionAdministrationAuthenticationError ||
|
||||
error instanceof LocalTaskDefinitionAdministrationAuthorizationError ||
|
||||
error instanceof LocalTaskDefinitionAdministrationUnavailableError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let outcome: SecurityAuditRecord['outcome'];
|
||||
let reason: string;
|
||||
if (
|
||||
!authenticated ||
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError
|
||||
) {
|
||||
outcome = 'authentication_rejected';
|
||||
reason = 'credential_rejected';
|
||||
} else if (
|
||||
error instanceof LocalSqliteAuthenticatedManagementFenceError ||
|
||||
error instanceof
|
||||
TaskDefinitionAdministrationAuthorizationFenceConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_or_policy_fence_rejected';
|
||||
} else if (
|
||||
error instanceof TaskDefinitionConflictError ||
|
||||
error instanceof TaskDefinitionAdministrationMutationConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'task_definition_conflict';
|
||||
} else if (
|
||||
error instanceof InvalidTaskDefinitionError ||
|
||||
error instanceof LocalTaskDefinitionAdministrationConfigurationError ||
|
||||
error instanceof LocalTaskDefinitionCommandConfigurationError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'task_definition_rejected';
|
||||
} else if (error instanceof TaskDefinitionUnavailableError) {
|
||||
return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId:
|
||||
command.operation === 'task.put'
|
||||
? command.request.expectedRevision === null
|
||||
? 'task.create'
|
||||
: 'task.update'
|
||||
: 'task.read',
|
||||
projectId: command.request.projectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalTaskDefinitionCommandRunnerDependencies,
|
||||
): Readonly<LocalTaskDefinitionCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
['authenticate', 'createService', 'now', 'openDatabase']
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.createService !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalTaskDefinitionCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
async function activateFence(
|
||||
database: LocalSqliteTaskDefinitionAdministrationDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<void> {
|
||||
await authenticated.confirm();
|
||||
database.activateUserCredentialFence(
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
);
|
||||
}
|
||||
|
||||
export function createLocalTaskDefinitionCommandRunner(
|
||||
candidateDependencies: LocalTaskDefinitionCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteTaskDefinitionAdministrationDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
createService: createLocalTaskDefinitionAdministrationService,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalTaskDefinitionCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_task_definition',
|
||||
});
|
||||
await activateFence(database, authenticated);
|
||||
const service = adapters.createService(
|
||||
database.projectPolicy,
|
||||
database.taskDefinitionAdministration,
|
||||
database.taskDefinitions,
|
||||
database.securityAudit,
|
||||
{ now: adapters.now },
|
||||
);
|
||||
if (command.operation === 'task.put') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const result = await service.put({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
task: summary(result.definition),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'task.inspect') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const definition = await service.inspect({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return definition
|
||||
? Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
found: true as const,
|
||||
task: summary(definition),
|
||||
})
|
||||
: Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
found: false as const,
|
||||
projectId: command.request.projectId,
|
||||
taskId: command.request.taskId,
|
||||
});
|
||||
}
|
||||
const { failureAuditEventId: _failure, ...request } = command.request;
|
||||
const page = await service.list({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: command.request.projectId,
|
||||
tasks: Object.freeze(page.definitions.map(summary)),
|
||||
nextCursor: page.next ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
adapters.now(),
|
||||
);
|
||||
if (audit) await database.securityAudit.record(audit);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalTaskDefinitionCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<LocalTaskDefinitionCommandResult> {
|
||||
return createLocalTaskDefinitionCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalTriggerCommandFile } from './triggerCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-trigger run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_TRIGGER_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalTriggerCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_TRIGGER_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Trigger command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,666 @@
|
||||
// Automation management owns Trigger authoring and inspection commands.
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
LocalTriggerAdministrationAuthenticationError,
|
||||
LocalTriggerAdministrationAuthorizationError,
|
||||
LocalTriggerAdministrationConfigurationError,
|
||||
LocalTriggerAdministrationUnavailableError,
|
||||
createLocalTriggerAdministrationService,
|
||||
} from '@qinglong/local-admin/trigger-administration';
|
||||
import {
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
} from '@qinglong/local-sqlite/authenticated-management';
|
||||
import {
|
||||
openLocalSqliteTriggerAdministrationDatabase,
|
||||
type LocalSqliteTriggerAdministrationDatabase,
|
||||
} from '@qinglong/local-sqlite/trigger-administration';
|
||||
import {
|
||||
InvalidTriggerError,
|
||||
InvalidTriggerSpecSemanticError,
|
||||
TriggerConflictError,
|
||||
TriggerUnavailableError,
|
||||
UnsupportedTriggerSpecError,
|
||||
assertTriggerIdentifier,
|
||||
assertTriggerPageSize,
|
||||
normalizeAppendTriggerRevisionCommand,
|
||||
normalizeTriggerCursor,
|
||||
type AppendTriggerRevisionCommand,
|
||||
type TriggerRecord,
|
||||
} from '@qinglong/runtime-core/trigger';
|
||||
import {
|
||||
TriggerAdministrationAuthorizationFenceConflictError,
|
||||
TriggerAdministrationMutationConflictError,
|
||||
} from '@qinglong/runtime-core/trigger-administration';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export interface LocalTriggerCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface BaseTriggerCommandRequest {
|
||||
readonly projectId: string;
|
||||
readonly requestId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export interface PutLocalTriggerCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'trigger.put';
|
||||
readonly options: LocalTriggerCommandOptions;
|
||||
readonly request: BaseTriggerCommandRequest &
|
||||
Omit<AppendTriggerRevisionCommand, 'projectId'>;
|
||||
}
|
||||
|
||||
export interface InspectLocalTriggerCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'trigger.inspect';
|
||||
readonly options: LocalTriggerCommandOptions;
|
||||
readonly request: BaseTriggerCommandRequest & {
|
||||
readonly triggerId: string;
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListLocalTriggersCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'trigger.list';
|
||||
readonly options: LocalTriggerCommandOptions;
|
||||
readonly request: BaseTriggerCommandRequest & {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{ readonly triggerId: string }>;
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalTriggerCommand =
|
||||
| PutLocalTriggerCommand
|
||||
| InspectLocalTriggerCommand
|
||||
| ListLocalTriggersCommand;
|
||||
|
||||
export type LocalTriggerSummary = Readonly<{
|
||||
projectId: string;
|
||||
triggerId: string;
|
||||
revision: number;
|
||||
taskId: string;
|
||||
taskRevision: number;
|
||||
taskContentDigest: string;
|
||||
schema: string;
|
||||
enabled: boolean;
|
||||
contentDigest: string;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}>;
|
||||
|
||||
export type LocalTriggerCommandResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.put';
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
trigger: LocalTriggerSummary;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.inspect';
|
||||
found: false;
|
||||
projectId: string;
|
||||
triggerId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.inspect';
|
||||
found: true;
|
||||
trigger: LocalTriggerSummary;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'trigger.list';
|
||||
projectId: string;
|
||||
triggers: readonly LocalTriggerSummary[];
|
||||
nextCursor: Readonly<{ readonly triggerId: string }> | null;
|
||||
}>;
|
||||
|
||||
export interface LocalTriggerCommandRunner {
|
||||
run(commandFilePath: string): Promise<LocalTriggerCommandResult>;
|
||||
}
|
||||
|
||||
export interface LocalTriggerCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteTriggerAdministrationDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly createService: typeof createLocalTriggerAdministrationService;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalTriggerCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_TRIGGER_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Trigger command configuration is invalid: ${message}`);
|
||||
this.name = 'LocalTriggerCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
required.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalTriggerCommandOptions> {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'credentialFilePath',
|
||||
'databasePath',
|
||||
'deploymentRoot',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
[],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
for (const key of [
|
||||
'databasePath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
] as const) {
|
||||
const candidate = boundedPath(value[key], key);
|
||||
descendant(deploymentRoot, candidate, key);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalTriggerCommandConfigurationError('busyTimeoutMs is invalid');
|
||||
}
|
||||
return Object.freeze(value as unknown as LocalTriggerCommandOptions);
|
||||
}
|
||||
|
||||
function requestIdentity(
|
||||
value: Record<string, unknown>,
|
||||
eventKeys: readonly string[],
|
||||
): void {
|
||||
if (
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId) ||
|
||||
eventKeys.some(
|
||||
(key) =>
|
||||
typeof value[key] !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value[key] as string),
|
||||
) ||
|
||||
new Set(eventKeys.map((key) => value[key])).size !== eventKeys.length
|
||||
) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
'request identity is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePutRequest(
|
||||
value: unknown,
|
||||
): Readonly<PutLocalTriggerCommand['request']> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'enabled',
|
||||
'expectedRevision',
|
||||
'failureAuditEventId',
|
||||
'mutationId',
|
||||
'occurredAtMs',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'spec',
|
||||
'taskContentDigest',
|
||||
'taskId',
|
||||
'taskRevision',
|
||||
'triggerId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
requestIdentity(value, ['failureAuditEventId', 'mutationId']);
|
||||
try {
|
||||
const {
|
||||
failureAuditEventId: _failure,
|
||||
requestId: _request,
|
||||
...command
|
||||
} = value;
|
||||
normalizeAppendTriggerRevisionCommand(
|
||||
command as unknown as AppendTriggerRevisionCommand,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
'Trigger request is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return Object.freeze(value as unknown as PutLocalTriggerCommand['request']);
|
||||
}
|
||||
|
||||
function normalizeInspectRequest(
|
||||
value: unknown,
|
||||
): Readonly<InspectLocalTriggerCommand['request']> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'triggerId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
requestIdentity(value, ['auditEventId', 'failureAuditEventId']);
|
||||
try {
|
||||
assertTriggerIdentifier(value.projectId, 'projectId');
|
||||
assertTriggerIdentifier(value.triggerId, 'triggerId');
|
||||
} catch (error) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
'Trigger identity is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
value as unknown as InspectLocalTriggerCommand['request'],
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeListRequest(
|
||||
value: unknown,
|
||||
): Readonly<ListLocalTriggersCommand['request']> {
|
||||
exactObject(
|
||||
value,
|
||||
['auditEventId', 'failureAuditEventId', 'limit', 'projectId', 'requestId'],
|
||||
['after'],
|
||||
'request',
|
||||
);
|
||||
requestIdentity(value, ['auditEventId', 'failureAuditEventId']);
|
||||
try {
|
||||
assertTriggerIdentifier(value.projectId, 'projectId');
|
||||
assertTriggerPageSize(value.limit as number);
|
||||
const after =
|
||||
value.after === undefined
|
||||
? undefined
|
||||
: normalizeTriggerCursor(
|
||||
value.after as Readonly<{ readonly triggerId: string }>,
|
||||
);
|
||||
return Object.freeze({
|
||||
...(value as unknown as ListLocalTriggersCommand['request']),
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
'Trigger list request is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommand(value: unknown): Readonly<LocalTriggerCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
[],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
!['trigger.inspect', 'trigger.list', 'trigger.put'].includes(
|
||||
value.operation as string,
|
||||
)
|
||||
) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
'command version or operation is invalid',
|
||||
);
|
||||
}
|
||||
const options = normalizeOptions(value.options);
|
||||
if (value.operation === 'trigger.put') {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'trigger.put',
|
||||
options,
|
||||
request: normalizePutRequest(value.request),
|
||||
});
|
||||
}
|
||||
if (value.operation === 'trigger.inspect') {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'trigger.inspect',
|
||||
options,
|
||||
request: normalizeInspectRequest(value.request),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'trigger.list',
|
||||
options,
|
||||
request: normalizeListRequest(value.request),
|
||||
});
|
||||
}
|
||||
|
||||
function readCommandFile(candidatePath: string): Readonly<LocalTriggerCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalTriggerCommandConfigurationError) throw error;
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function summary(value: Readonly<TriggerRecord>): LocalTriggerSummary {
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
triggerId: value.triggerId,
|
||||
revision: value.revision,
|
||||
taskId: value.taskId,
|
||||
taskRevision: value.taskRevision,
|
||||
taskContentDigest: value.taskContentDigest,
|
||||
schema: value.spec.schema,
|
||||
enabled: value.enabled,
|
||||
contentDigest: value.contentDigest,
|
||||
createdAtMs: value.createdAtMs,
|
||||
updatedAtMs: value.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function failureAudit(
|
||||
command: Readonly<LocalTriggerCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
if (
|
||||
error instanceof LocalTriggerAdministrationAuthenticationError ||
|
||||
error instanceof LocalTriggerAdministrationAuthorizationError ||
|
||||
error instanceof LocalTriggerAdministrationUnavailableError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let outcome: SecurityAuditRecord['outcome'];
|
||||
let reason: string;
|
||||
if (
|
||||
!authenticated ||
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError
|
||||
) {
|
||||
outcome = 'authentication_rejected';
|
||||
reason = 'credential_rejected';
|
||||
} else if (
|
||||
error instanceof LocalSqliteAuthenticatedManagementFenceError ||
|
||||
error instanceof TriggerAdministrationAuthorizationFenceConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_or_policy_fence_rejected';
|
||||
} else if (
|
||||
error instanceof TriggerConflictError ||
|
||||
error instanceof TriggerAdministrationMutationConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'trigger_conflict';
|
||||
} else if (
|
||||
error instanceof InvalidTriggerError ||
|
||||
error instanceof InvalidTriggerSpecSemanticError ||
|
||||
error instanceof UnsupportedTriggerSpecError ||
|
||||
error instanceof LocalTriggerAdministrationConfigurationError ||
|
||||
error instanceof LocalTriggerCommandConfigurationError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'trigger_rejected';
|
||||
} else if (error instanceof TriggerUnavailableError) {
|
||||
return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId:
|
||||
command.operation === 'trigger.put'
|
||||
? command.request.expectedRevision === null
|
||||
? 'trigger.create'
|
||||
: 'trigger.update'
|
||||
: 'trigger.read',
|
||||
projectId: command.request.projectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalTriggerCommandRunnerDependencies,
|
||||
): Readonly<LocalTriggerCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
['authenticate', 'createService', 'now', 'openDatabase']
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.createService !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalTriggerCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
async function activateFence(
|
||||
database: LocalSqliteTriggerAdministrationDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<void> {
|
||||
await authenticated.confirm();
|
||||
database.activateUserCredentialFence(
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
);
|
||||
}
|
||||
|
||||
export function createLocalTriggerCommandRunner(
|
||||
candidateDependencies: LocalTriggerCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteTriggerAdministrationDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
createService: createLocalTriggerAdministrationService,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalTriggerCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_trigger',
|
||||
});
|
||||
await activateFence(database, authenticated);
|
||||
const service = adapters.createService(
|
||||
database.projectPolicy,
|
||||
database.triggerAdministration,
|
||||
database.triggers,
|
||||
database.securityAudit,
|
||||
{ now: adapters.now },
|
||||
);
|
||||
if (command.operation === 'trigger.put') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const result = await service.put({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
trigger: summary(result.trigger),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'trigger.inspect') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const trigger = await service.inspect({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return trigger
|
||||
? Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
found: true as const,
|
||||
trigger: summary(trigger),
|
||||
})
|
||||
: Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
found: false as const,
|
||||
projectId: command.request.projectId,
|
||||
triggerId: command.request.triggerId,
|
||||
});
|
||||
}
|
||||
const { failureAuditEventId: _failure, ...request } = command.request;
|
||||
const page = await service.list({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: command.request.projectId,
|
||||
triggers: Object.freeze(page.triggers.map(summary)),
|
||||
nextCursor: page.next ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
adapters.now(),
|
||||
);
|
||||
if (audit) await database.securityAudit.record(audit);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalTriggerCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<LocalTriggerCommandResult> {
|
||||
return createLocalTriggerCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalOwnerCommandFile } from './application-command/localOwnerCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-owner run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 3 || argv[0] !== 'run' || argv[1] !== '--command-file') {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_OWNER_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalOwnerCommandFile(argv[2]!);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_OWNER_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Owner command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,981 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
import {
|
||||
createLocalSqliteRolloutBackup,
|
||||
inspectLocalSqliteRolloutBackup,
|
||||
LOCAL_SQLITE_WRITE_CONTRACT_VERSION,
|
||||
openLocalSqliteChangeObserver,
|
||||
type LocalSqliteRolloutBackupEvidence,
|
||||
type LocalSqliteRolloutBackupOptions,
|
||||
} from '@qinglong/local-sqlite/rollout-safety';
|
||||
|
||||
import { preflightLocalDeploymentCompose } from './composePreflight';
|
||||
import { evidenceDigest, inspectCollectedEvidence } from './composeEvidence';
|
||||
import {
|
||||
inspectActiveComposeImageSelection,
|
||||
inspectComposeImageSelectionGeneration,
|
||||
switchLocalDeploymentComposeRevision,
|
||||
type ComposeImageSelection,
|
||||
} from './composeRevision';
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
normalizeLocalDeploymentComposeApplyCommand,
|
||||
type LocalDeploymentComposeApplyCommand,
|
||||
type LocalDeploymentComposeApplyResult,
|
||||
type LocalDeploymentProfile,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../foundation/docker';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
syncPublishedDirectory,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import {
|
||||
deploymentPaths,
|
||||
type LocalDeploymentPaths,
|
||||
} from '../foundation/render';
|
||||
|
||||
const RECEIPT_SCHEMA = 'qinglong/local-compose-rollout-receipt@v2';
|
||||
const CONTAINER_ID_PATTERN = /^[0-9a-f]{12,64}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const MAX_RECEIPT_BYTES = 64 * 1024;
|
||||
const MAX_RETAINED_ROLLOUT_BACKUPS = 8;
|
||||
|
||||
export interface LocalDeploymentComposeApplyDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
readonly now?: () => number;
|
||||
readonly wait?: (milliseconds: number) => Promise<void>;
|
||||
readonly createBackup?: typeof createLocalSqliteRolloutBackup;
|
||||
readonly inspectBackup?: typeof inspectLocalSqliteRolloutBackup;
|
||||
readonly openChangeObserver?: typeof openLocalSqliteChangeObserver;
|
||||
}
|
||||
|
||||
interface ActiveEvidence {
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
type SqliteWriteObservation = 'unchanged' | 'changed' | 'recovery_unknown';
|
||||
|
||||
interface RolloutBackupReceipt {
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
readonly pageCount: number;
|
||||
readonly pageSize: number;
|
||||
}
|
||||
|
||||
interface RolloutSqliteReceipt {
|
||||
readonly contractVersion: number;
|
||||
readonly writeContractVersion: number;
|
||||
readonly writeObservation: SqliteWriteObservation;
|
||||
readonly backup: Readonly<RolloutBackupReceipt> | null;
|
||||
}
|
||||
|
||||
interface RolloutReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly commandDigest: string;
|
||||
readonly rolloutId: string;
|
||||
readonly attemptedGeneration: number;
|
||||
readonly recordedAtMs: number;
|
||||
readonly healthEventDigest: string | null;
|
||||
readonly sqlite: Readonly<RolloutSqliteReceipt>;
|
||||
readonly result: Readonly<LocalDeploymentComposeApplyResult>;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
function digest(value: string): string {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function commandDigest(
|
||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||
): string {
|
||||
return digest(JSON.stringify(command));
|
||||
}
|
||||
|
||||
function composeArgs(
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
args: readonly string[],
|
||||
): readonly string[] {
|
||||
return [
|
||||
'compose',
|
||||
'--project-directory',
|
||||
paths.service,
|
||||
'-f',
|
||||
path.join(paths.service, 'compose.yaml'),
|
||||
'-f',
|
||||
paths.composeSelection,
|
||||
...args,
|
||||
];
|
||||
}
|
||||
|
||||
function docker(
|
||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
args: readonly string[],
|
||||
timeoutMs = 30_000,
|
||||
): string {
|
||||
return runDocker({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
args,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
function applyActiveSelection(
|
||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
): void {
|
||||
docker(
|
||||
command,
|
||||
runDocker,
|
||||
composeArgs(paths, [
|
||||
'up',
|
||||
'--detach',
|
||||
'--force-recreate',
|
||||
'--no-build',
|
||||
'--pull',
|
||||
'never',
|
||||
'--remove-orphans',
|
||||
'qinglong3',
|
||||
]),
|
||||
60_000,
|
||||
);
|
||||
}
|
||||
|
||||
function stopFailedSelection(
|
||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
): void {
|
||||
docker(
|
||||
command,
|
||||
runDocker,
|
||||
composeArgs(paths, ['stop', '--timeout', '30', 'qinglong3']),
|
||||
45_000,
|
||||
);
|
||||
}
|
||||
|
||||
function parseContainerIdentity(value: string): string | null {
|
||||
const containerId = value.trim();
|
||||
if (!CONTAINER_ID_PATTERN.test(containerId)) return null;
|
||||
return containerId;
|
||||
}
|
||||
|
||||
function inspectRunningContainer(
|
||||
output: string,
|
||||
containerId: string,
|
||||
selection: Readonly<ComposeImageSelection>,
|
||||
): boolean {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(output);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const inspected = Array.isArray(value) ? value[0] : undefined;
|
||||
const candidate = inspected as
|
||||
| {
|
||||
readonly Id?: unknown;
|
||||
readonly State?: {
|
||||
readonly Running?: unknown;
|
||||
readonly Status?: unknown;
|
||||
};
|
||||
readonly Config?: {
|
||||
readonly Image?: unknown;
|
||||
readonly Labels?: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
readonly HostConfig?: {
|
||||
readonly ReadonlyRootfs?: unknown;
|
||||
readonly NetworkMode?: unknown;
|
||||
readonly Privileged?: unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
(candidate?.Id === containerId ||
|
||||
(typeof candidate?.Id === 'string' &&
|
||||
candidate.Id.startsWith(containerId))) &&
|
||||
candidate?.State?.Running === true &&
|
||||
candidate.State.Status === 'running' &&
|
||||
candidate.Config?.Image === selection.image &&
|
||||
candidate.Config.Labels?.['io.qinglong.deployment.generation'] ===
|
||||
String(selection.generation) &&
|
||||
candidate.Config.Labels?.['io.qinglong.deployment.mutation'] ===
|
||||
selection.mutationId &&
|
||||
candidate.HostConfig?.ReadonlyRootfs === true &&
|
||||
candidate.HostConfig.NetworkMode === 'none' &&
|
||||
candidate.HostConfig.Privileged !== true
|
||||
);
|
||||
}
|
||||
|
||||
function activeEventEvidence(
|
||||
logs: string,
|
||||
profile: LocalDeploymentProfile,
|
||||
): Readonly<ActiveEvidence> | null {
|
||||
const lines = logs
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-256);
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(lines[index]!);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const event = value as Readonly<Record<string, unknown>>;
|
||||
if (
|
||||
event.schemaVersion === 1 &&
|
||||
event.component === 'qinglong3-local-application' &&
|
||||
event.level === 'info' &&
|
||||
event.event === 'active' &&
|
||||
event.profile === profile &&
|
||||
event.aiStatus === 'deployment_excluded' &&
|
||||
typeof event.instanceId === 'string'
|
||||
) {
|
||||
return Object.freeze({ digest: digest(JSON.stringify(event)) });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function observeActive(
|
||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
selection: Readonly<ComposeImageSelection>,
|
||||
profile: LocalDeploymentProfile,
|
||||
dependencies: Required<
|
||||
Pick<LocalDeploymentComposeApplyDependencies, 'runDocker' | 'now' | 'wait'>
|
||||
>,
|
||||
): Promise<Readonly<ActiveEvidence> | null> {
|
||||
const timeoutMs = profile === 'edge' ? 30_000 : 60_000;
|
||||
const deadline = dependencies.now() + timeoutMs;
|
||||
const maxAttempts = profile === 'edge' ? 120 : 240;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const containerId = parseContainerIdentity(
|
||||
docker(
|
||||
command,
|
||||
dependencies.runDocker,
|
||||
composeArgs(paths, ['ps', '--all', '--quiet', 'qinglong3']),
|
||||
),
|
||||
);
|
||||
if (containerId) {
|
||||
const running = inspectRunningContainer(
|
||||
docker(command, dependencies.runDocker, [
|
||||
'container',
|
||||
'inspect',
|
||||
containerId,
|
||||
]),
|
||||
containerId,
|
||||
selection,
|
||||
);
|
||||
if (running) {
|
||||
const evidence = activeEventEvidence(
|
||||
docker(command, dependencies.runDocker, [
|
||||
'container',
|
||||
'logs',
|
||||
'--tail',
|
||||
'256',
|
||||
containerId,
|
||||
]),
|
||||
profile,
|
||||
);
|
||||
if (evidence) return evidence;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A starting or failed candidate is handled by the bounded observation
|
||||
// window and the generation-fenced rollback below.
|
||||
}
|
||||
if (dependencies.now() >= deadline) return null;
|
||||
await dependencies.wait(250);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function backupPathFor(
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
rolloutId: string,
|
||||
): string {
|
||||
return path.join(paths.composeRolloutBackups, `${rolloutId}.sqlite`);
|
||||
}
|
||||
|
||||
function backupOptions(
|
||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
profile: LocalDeploymentProfile,
|
||||
): Readonly<LocalSqliteRolloutBackupOptions> {
|
||||
return Object.freeze({
|
||||
databasePath: paths.database,
|
||||
backupPath: backupPathFor(paths, command.request.rolloutId),
|
||||
profile,
|
||||
});
|
||||
}
|
||||
|
||||
function preflightBackupCatalog(
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
rolloutId: string,
|
||||
): void {
|
||||
const finalName = `${rolloutId}.sqlite`;
|
||||
const stageName = `.${finalName}.ql3-backup-stage`;
|
||||
const entries = fs.readdirSync(paths.composeRolloutBackups, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
let retained = 0;
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.isFile() &&
|
||||
UUID_V4_PATTERN.test(entry.name.replace(/\.sqlite$/, '')) &&
|
||||
entry.name.endsWith('.sqlite')
|
||||
) {
|
||||
retained += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name === stageName) continue;
|
||||
configurationError('compose rollout backup catalog contains drift');
|
||||
}
|
||||
if (
|
||||
retained > MAX_RETAINED_ROLLOUT_BACKUPS ||
|
||||
(retained === MAX_RETAINED_ROLLOUT_BACKUPS &&
|
||||
!entries.some((entry) => entry.name === finalName))
|
||||
) {
|
||||
configurationError('compose rollout backup retention limit is reached');
|
||||
}
|
||||
}
|
||||
|
||||
function backupReceipt(
|
||||
evidence: Readonly<LocalSqliteRolloutBackupEvidence> | null,
|
||||
): Readonly<RolloutBackupReceipt> | null {
|
||||
if (evidence === null) return null;
|
||||
return Object.freeze({
|
||||
sha256: evidence.sha256,
|
||||
bytes: evidence.bytes,
|
||||
pageCount: evidence.pageCount,
|
||||
pageSize: evidence.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
function sqliteReceipt(
|
||||
backup: Readonly<LocalSqliteRolloutBackupEvidence> | null,
|
||||
writeObservation: SqliteWriteObservation,
|
||||
): Readonly<RolloutSqliteReceipt> {
|
||||
return Object.freeze({
|
||||
contractVersion: LOCAL_SQLITE_WRITE_CONTRACT_VERSION,
|
||||
writeContractVersion: LOCAL_SQLITE_WRITE_CONTRACT_VERSION,
|
||||
writeObservation,
|
||||
backup: backupReceipt(backup),
|
||||
});
|
||||
}
|
||||
|
||||
async function applyAndObserveCandidate(
|
||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
selection: Readonly<ComposeImageSelection>,
|
||||
profile: LocalDeploymentProfile,
|
||||
dependencies: Required<
|
||||
Pick<
|
||||
LocalDeploymentComposeApplyDependencies,
|
||||
'runDocker' | 'now' | 'wait' | 'openChangeObserver'
|
||||
>
|
||||
>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
evidence: Readonly<ActiveEvidence> | null;
|
||||
writeObservation: SqliteWriteObservation;
|
||||
}>
|
||||
> {
|
||||
const observer = dependencies.openChangeObserver({
|
||||
databasePath: paths.database,
|
||||
profile,
|
||||
});
|
||||
let evidence: Readonly<ActiveEvidence> | null = null;
|
||||
try {
|
||||
try {
|
||||
applyActiveSelection(command, paths, dependencies.runDocker);
|
||||
evidence = await observeActive(
|
||||
command,
|
||||
paths,
|
||||
selection,
|
||||
profile,
|
||||
dependencies,
|
||||
);
|
||||
} catch {
|
||||
evidence = null;
|
||||
}
|
||||
let writeObservation: SqliteWriteObservation;
|
||||
try {
|
||||
writeObservation = observer.changed() ? 'changed' : 'unchanged';
|
||||
} catch (error) {
|
||||
if (evidence) throw error;
|
||||
writeObservation = 'recovery_unknown';
|
||||
}
|
||||
return Object.freeze({ evidence, writeObservation });
|
||||
} finally {
|
||||
observer.close();
|
||||
}
|
||||
}
|
||||
|
||||
function result(
|
||||
status: LocalDeploymentComposeApplyResult['status'],
|
||||
attemptedGeneration: number,
|
||||
activeGeneration: number | null,
|
||||
profile: LocalDeploymentProfile,
|
||||
): Readonly<LocalDeploymentComposeApplyResult> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.compose.apply' as const,
|
||||
status,
|
||||
attemptedGeneration,
|
||||
activeGeneration,
|
||||
profile,
|
||||
health: Object.freeze({
|
||||
event:
|
||||
status === 'failed_stopped'
|
||||
? ('unavailable' as const)
|
||||
: ('active' as const),
|
||||
}),
|
||||
service: Object.freeze({ kind: 'compose' as const }),
|
||||
});
|
||||
}
|
||||
|
||||
function receiptContents(receipt: Readonly<RolloutReceipt>): string {
|
||||
return `${JSON.stringify(receipt, null, 2)}\n`;
|
||||
}
|
||||
|
||||
async function readReceipt(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
expectedCommandDigest: string,
|
||||
rolloutId: string,
|
||||
expectedGeneration: number,
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
inspectBackup: typeof inspectLocalSqliteRolloutBackup,
|
||||
): Promise<Readonly<LocalDeploymentComposeApplyResult> | null> {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
(stat.nlink !== 1 && stat.nlink !== 2) ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_RECEIPT_BYTES
|
||||
) {
|
||||
configurationError('compose rollout receipt identity is invalid');
|
||||
}
|
||||
const contents = fs.readFileSync(filePath, 'utf8');
|
||||
let receipt: RolloutReceipt;
|
||||
try {
|
||||
receipt = JSON.parse(contents) as RolloutReceipt;
|
||||
} catch (error) {
|
||||
configurationError('compose rollout receipt is invalid', error);
|
||||
}
|
||||
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) {
|
||||
configurationError('compose rollout receipt shape is invalid');
|
||||
}
|
||||
const receiptKeys = Object.keys(receipt).sort();
|
||||
const resultKeys = Object.keys(receipt.result ?? {}).sort();
|
||||
const healthKeys = Object.keys(receipt.result?.health ?? {}).sort();
|
||||
const serviceKeys = Object.keys(receipt.result?.service ?? {}).sort();
|
||||
const sqliteKeys = Object.keys(receipt.sqlite ?? {}).sort();
|
||||
const backupKeys = Object.keys(receipt.sqlite?.backup ?? {}).sort();
|
||||
const expectedReceiptKeys = [
|
||||
'attemptedGeneration',
|
||||
'commandDigest',
|
||||
'healthEventDigest',
|
||||
'recordedAtMs',
|
||||
'result',
|
||||
'rolloutId',
|
||||
'schema',
|
||||
'sqlite',
|
||||
].sort();
|
||||
const expectedResultKeys = [
|
||||
'activeGeneration',
|
||||
'attemptedGeneration',
|
||||
'health',
|
||||
'operation',
|
||||
'profile',
|
||||
'schemaVersion',
|
||||
'service',
|
||||
'status',
|
||||
].sort();
|
||||
const expectedSqliteKeys = [
|
||||
'backup',
|
||||
'contractVersion',
|
||||
'writeContractVersion',
|
||||
'writeObservation',
|
||||
].sort();
|
||||
const expectedBackupKeys = [
|
||||
'bytes',
|
||||
'pageCount',
|
||||
'pageSize',
|
||||
'sha256',
|
||||
].sort();
|
||||
const validStatus =
|
||||
receipt.result?.status === 'active' ||
|
||||
receipt.result?.status === 'rolled_back' ||
|
||||
receipt.result?.status === 'failed_stopped';
|
||||
const expectedActiveGeneration =
|
||||
receipt.result?.status === 'active'
|
||||
? expectedGeneration
|
||||
: receipt.result?.status === 'rolled_back'
|
||||
? expectedGeneration + 1
|
||||
: null;
|
||||
const canonicalReceipt: RolloutReceipt = {
|
||||
schema: receipt.schema,
|
||||
commandDigest: receipt.commandDigest,
|
||||
rolloutId: receipt.rolloutId,
|
||||
attemptedGeneration: receipt.attemptedGeneration,
|
||||
recordedAtMs: receipt.recordedAtMs,
|
||||
healthEventDigest: receipt.healthEventDigest,
|
||||
sqlite: {
|
||||
contractVersion: receipt.sqlite?.contractVersion,
|
||||
writeContractVersion: receipt.sqlite?.writeContractVersion,
|
||||
writeObservation: receipt.sqlite?.writeObservation,
|
||||
backup:
|
||||
receipt.sqlite?.backup === null
|
||||
? null
|
||||
: {
|
||||
sha256: receipt.sqlite?.backup?.sha256,
|
||||
bytes: receipt.sqlite?.backup?.bytes,
|
||||
pageCount: receipt.sqlite?.backup?.pageCount,
|
||||
pageSize: receipt.sqlite?.backup?.pageSize,
|
||||
},
|
||||
} as RolloutSqliteReceipt,
|
||||
result: {
|
||||
schemaVersion: receipt.result?.schemaVersion,
|
||||
operation: receipt.result?.operation,
|
||||
status: receipt.result?.status,
|
||||
attemptedGeneration: receipt.result?.attemptedGeneration,
|
||||
activeGeneration: receipt.result?.activeGeneration,
|
||||
profile: receipt.result?.profile,
|
||||
health: {
|
||||
event: receipt.result?.health?.event,
|
||||
},
|
||||
service: {
|
||||
kind: receipt.result?.service?.kind,
|
||||
},
|
||||
} as LocalDeploymentComposeApplyResult,
|
||||
};
|
||||
if (
|
||||
contents !== receiptContents(canonicalReceipt) ||
|
||||
receiptKeys.length !== expectedReceiptKeys.length ||
|
||||
receiptKeys.some((key, index) => key !== expectedReceiptKeys[index]) ||
|
||||
resultKeys.length !== expectedResultKeys.length ||
|
||||
resultKeys.some((key, index) => key !== expectedResultKeys[index]) ||
|
||||
healthKeys.length !== 1 ||
|
||||
healthKeys[0] !== 'event' ||
|
||||
serviceKeys.length !== 1 ||
|
||||
serviceKeys[0] !== 'kind' ||
|
||||
sqliteKeys.length !== expectedSqliteKeys.length ||
|
||||
sqliteKeys.some((key, index) => key !== expectedSqliteKeys[index]) ||
|
||||
(receipt.sqlite?.backup === null
|
||||
? backupKeys.length !== 0
|
||||
: backupKeys.length !== expectedBackupKeys.length ||
|
||||
backupKeys.some((key, index) => key !== expectedBackupKeys[index])) ||
|
||||
receipt.schema !== RECEIPT_SCHEMA ||
|
||||
receipt.commandDigest !== expectedCommandDigest ||
|
||||
receipt.rolloutId !== rolloutId ||
|
||||
receipt.attemptedGeneration !== expectedGeneration ||
|
||||
!Number.isSafeInteger(receipt.recordedAtMs) ||
|
||||
receipt.recordedAtMs < 0 ||
|
||||
(receipt.healthEventDigest !== null &&
|
||||
(typeof receipt.healthEventDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(receipt.healthEventDigest))) ||
|
||||
receipt.sqlite?.contractVersion !== LOCAL_SQLITE_WRITE_CONTRACT_VERSION ||
|
||||
receipt.sqlite?.writeContractVersion !==
|
||||
LOCAL_SQLITE_WRITE_CONTRACT_VERSION ||
|
||||
(receipt.sqlite?.writeObservation !== 'unchanged' &&
|
||||
receipt.sqlite?.writeObservation !== 'changed' &&
|
||||
receipt.sqlite?.writeObservation !== 'recovery_unknown') ||
|
||||
(expectedGeneration === 1
|
||||
? receipt.sqlite?.backup !== null
|
||||
: !receipt.sqlite?.backup) ||
|
||||
(receipt.sqlite?.backup !== null &&
|
||||
(typeof receipt.sqlite?.backup?.sha256 !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(receipt.sqlite.backup.sha256) ||
|
||||
!Number.isSafeInteger(receipt.sqlite.backup.bytes) ||
|
||||
receipt.sqlite.backup.bytes < 1 ||
|
||||
!Number.isSafeInteger(receipt.sqlite.backup.pageCount) ||
|
||||
receipt.sqlite.backup.pageCount < 1 ||
|
||||
!Number.isSafeInteger(receipt.sqlite.backup.pageSize) ||
|
||||
receipt.sqlite.backup.pageSize < 512 ||
|
||||
receipt.sqlite.backup.pageSize > 65_536)) ||
|
||||
!receipt.result ||
|
||||
receipt.result.schemaVersion !== 1 ||
|
||||
receipt.result.operation !== 'local.deployment.compose.apply' ||
|
||||
!validStatus ||
|
||||
receipt.result.attemptedGeneration !== expectedGeneration ||
|
||||
receipt.result.activeGeneration !== expectedActiveGeneration ||
|
||||
(receipt.result.profile !== 'edge' &&
|
||||
receipt.result.profile !== 'standalone') ||
|
||||
receipt.result.health?.event !==
|
||||
(receipt.result.status === 'failed_stopped' ? 'unavailable' : 'active') ||
|
||||
receipt.result.service?.kind !== 'compose' ||
|
||||
(receipt.result.status === 'failed_stopped'
|
||||
? receipt.healthEventDigest !== null
|
||||
: receipt.healthEventDigest === null)
|
||||
) {
|
||||
configurationError('compose rollout receipt drifted');
|
||||
}
|
||||
if (receipt.sqlite.backup !== null) {
|
||||
const backupPath = backupPathFor(paths, rolloutId);
|
||||
if (fs.existsSync(backupPath)) {
|
||||
const inspected = await inspectBackup({
|
||||
databasePath: paths.database,
|
||||
backupPath,
|
||||
profile: receipt.result.profile,
|
||||
});
|
||||
if (
|
||||
inspected.contractVersion !== receipt.sqlite.contractVersion ||
|
||||
inspected.writeContractVersion !==
|
||||
receipt.sqlite.writeContractVersion ||
|
||||
inspected.sha256 !== receipt.sqlite.backup.sha256 ||
|
||||
inspected.bytes !== receipt.sqlite.backup.bytes ||
|
||||
inspected.pageCount !== receipt.sqlite.backup.pageCount ||
|
||||
inspected.pageSize !== receipt.sqlite.backup.pageSize
|
||||
) {
|
||||
configurationError('compose rollout backup receipt drifted');
|
||||
}
|
||||
} else if (
|
||||
!inspectCollectedEvidence(
|
||||
paths,
|
||||
uid,
|
||||
LOCAL_SQLITE_WRITE_CONTRACT_VERSION,
|
||||
{
|
||||
kind: 'rollout-backup',
|
||||
artifactId: rolloutId,
|
||||
sourceReceiptDigest: evidenceDigest(contents),
|
||||
snapshot: {
|
||||
contractVersion: receipt.sqlite.contractVersion,
|
||||
sha256: receipt.sqlite.backup.sha256,
|
||||
bytes: receipt.sqlite.backup.bytes,
|
||||
pageCount: receipt.sqlite.backup.pageCount,
|
||||
pageSize: receipt.sqlite.backup.pageSize,
|
||||
},
|
||||
},
|
||||
)
|
||||
) {
|
||||
configurationError('compose rollout backup is unavailable');
|
||||
}
|
||||
}
|
||||
publishExactFile(filePath, contents, 0o600, uid, 'compose rollout receipt');
|
||||
return Object.freeze({
|
||||
...receipt.result,
|
||||
health: Object.freeze({ ...receipt.result.health }),
|
||||
service: Object.freeze({ ...receipt.result.service }),
|
||||
});
|
||||
}
|
||||
|
||||
function releaseLock(lockPath: string, intent: string, uid: number): void {
|
||||
if (!fs.existsSync(lockPath)) return;
|
||||
preflightPublishedFile(lockPath, intent, 0o600, uid, 'compose rollout lock');
|
||||
fs.unlinkSync(lockPath);
|
||||
syncPublishedDirectory(path.dirname(lockPath));
|
||||
}
|
||||
|
||||
function publishReceipt(
|
||||
filePath: string,
|
||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||
rolloutResult: Readonly<LocalDeploymentComposeApplyResult>,
|
||||
evidence: Readonly<ActiveEvidence> | null,
|
||||
sqlite: Readonly<RolloutSqliteReceipt>,
|
||||
uid: number,
|
||||
): void {
|
||||
publishExactFile(
|
||||
filePath,
|
||||
receiptContents({
|
||||
schema: RECEIPT_SCHEMA,
|
||||
commandDigest: commandDigest(command),
|
||||
rolloutId: command.request.rolloutId,
|
||||
attemptedGeneration: command.request.expectedGeneration,
|
||||
recordedAtMs: command.request.failureRollbackChangedAtMs,
|
||||
healthEventDigest: evidence?.digest ?? null,
|
||||
sqlite,
|
||||
result: rolloutResult,
|
||||
}),
|
||||
0o600,
|
||||
uid,
|
||||
'compose rollout receipt',
|
||||
);
|
||||
}
|
||||
|
||||
function applyCommand(
|
||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||
generation: number,
|
||||
): Readonly<LocalDeploymentComposeApplyCommand> {
|
||||
return Object.freeze({
|
||||
...command,
|
||||
request: Object.freeze({
|
||||
...command.request,
|
||||
expectedGeneration: generation,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyLocalDeploymentCompose(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentComposeApplyDependencies = {},
|
||||
): Promise<Readonly<LocalDeploymentComposeApplyResult>> {
|
||||
const command = normalizeLocalDeploymentComposeApplyCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const paths = deploymentPaths(command.options.deploymentRoot);
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.service,
|
||||
identity.uid,
|
||||
'serviceDescriptorRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.composeRevisions,
|
||||
identity.uid,
|
||||
'composeRevisionRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.composeRollouts,
|
||||
identity.uid,
|
||||
'composeRolloutRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.composeRolloutBackups,
|
||||
identity.uid,
|
||||
'composeRolloutBackupRoot',
|
||||
);
|
||||
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
|
||||
configurationError('compose rollout is fenced by an evidence collection');
|
||||
}
|
||||
if (fs.existsSync(paths.composeRestoreLock)) {
|
||||
configurationError('compose rollout is fenced by an in-flight restore');
|
||||
}
|
||||
preflightBackupCatalog(paths, command.request.rolloutId);
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const wait =
|
||||
dependencies.wait ??
|
||||
((milliseconds: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||
const createBackup =
|
||||
dependencies.createBackup ?? createLocalSqliteRolloutBackup;
|
||||
const inspectBackup =
|
||||
dependencies.inspectBackup ?? inspectLocalSqliteRolloutBackup;
|
||||
const openChangeObserver =
|
||||
dependencies.openChangeObserver ?? openLocalSqliteChangeObserver;
|
||||
const receiptPath = path.join(
|
||||
paths.composeRollouts,
|
||||
`${command.request.rolloutId}.json`,
|
||||
);
|
||||
const intent = `${JSON.stringify(command, null, 2)}\n`;
|
||||
const replay = await readReceipt(
|
||||
receiptPath,
|
||||
identity.uid,
|
||||
commandDigest(command),
|
||||
command.request.rolloutId,
|
||||
command.request.expectedGeneration,
|
||||
paths,
|
||||
inspectBackup,
|
||||
);
|
||||
if (replay) {
|
||||
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
||||
return replay;
|
||||
}
|
||||
publishExactFile(
|
||||
paths.composeRolloutLock,
|
||||
intent,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'compose rollout lock',
|
||||
);
|
||||
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
|
||||
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
||||
configurationError('compose rollout is fenced by an evidence collection');
|
||||
}
|
||||
|
||||
let selection = inspectActiveComposeImageSelection(
|
||||
paths.composeSelection,
|
||||
paths.composeRevisions,
|
||||
identity.uid,
|
||||
);
|
||||
const attempted = inspectComposeImageSelectionGeneration(
|
||||
paths.composeRevisions,
|
||||
command.request.expectedGeneration,
|
||||
identity.uid,
|
||||
);
|
||||
if (
|
||||
selection.generation !== command.request.expectedGeneration &&
|
||||
!(
|
||||
selection.generation === command.request.expectedGeneration + 1 &&
|
||||
selection.previousGeneration === command.request.expectedGeneration &&
|
||||
selection.rollbackTargetGeneration === attempted.previousGeneration &&
|
||||
selection.mutationId === command.request.failureRollbackMutationId
|
||||
)
|
||||
) {
|
||||
configurationError(
|
||||
'active compose generation does not match the rollout recovery state',
|
||||
);
|
||||
}
|
||||
|
||||
let rolloutBackup: Readonly<LocalSqliteRolloutBackupEvidence> | null = null;
|
||||
let writeObservation: SqliteWriteObservation = 'recovery_unknown';
|
||||
if (selection.generation === command.request.expectedGeneration) {
|
||||
const preflight = await preflightLocalDeploymentCompose(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.compose.preflight',
|
||||
options: command.options,
|
||||
request: {
|
||||
expectedGeneration: command.request.expectedGeneration,
|
||||
},
|
||||
},
|
||||
{ runDocker, validateSocket },
|
||||
);
|
||||
if (selection.previousGeneration >= 1) {
|
||||
rolloutBackup = await createBackup(
|
||||
backupOptions(command, paths, preflight.profile),
|
||||
);
|
||||
}
|
||||
const candidate = await applyAndObserveCandidate(
|
||||
command,
|
||||
paths,
|
||||
selection,
|
||||
preflight.profile,
|
||||
{ runDocker, now, wait, openChangeObserver },
|
||||
);
|
||||
const evidence = candidate.evidence;
|
||||
writeObservation = candidate.writeObservation;
|
||||
if (evidence) {
|
||||
const active = result(
|
||||
'active',
|
||||
command.request.expectedGeneration,
|
||||
command.request.expectedGeneration,
|
||||
preflight.profile,
|
||||
);
|
||||
publishReceipt(
|
||||
receiptPath,
|
||||
command,
|
||||
active,
|
||||
evidence,
|
||||
sqliteReceipt(rolloutBackup, writeObservation),
|
||||
identity.uid,
|
||||
);
|
||||
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
||||
return active;
|
||||
}
|
||||
if (selection.previousGeneration < 1) {
|
||||
stopFailedSelection(command, paths, runDocker);
|
||||
const stopped = result(
|
||||
'failed_stopped',
|
||||
command.request.expectedGeneration,
|
||||
null,
|
||||
preflight.profile,
|
||||
);
|
||||
publishReceipt(
|
||||
receiptPath,
|
||||
command,
|
||||
stopped,
|
||||
null,
|
||||
sqliteReceipt(null, writeObservation),
|
||||
identity.uid,
|
||||
);
|
||||
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
||||
return stopped;
|
||||
}
|
||||
await switchLocalDeploymentComposeRevision(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.compose.rollback',
|
||||
options: {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
allowRootService: command.options.allowRootService,
|
||||
},
|
||||
request: {
|
||||
expectedGeneration: selection.generation,
|
||||
targetGeneration: selection.previousGeneration,
|
||||
mutationId: command.request.failureRollbackMutationId,
|
||||
changedAtMs: command.request.failureRollbackChangedAtMs,
|
||||
},
|
||||
},
|
||||
intent,
|
||||
);
|
||||
selection = inspectActiveComposeImageSelection(
|
||||
paths.composeSelection,
|
||||
paths.composeRevisions,
|
||||
identity.uid,
|
||||
);
|
||||
}
|
||||
|
||||
const rollbackCommand = applyCommand(command, selection.generation);
|
||||
const rollbackPreflight = await preflightLocalDeploymentCompose(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.compose.preflight',
|
||||
options: command.options,
|
||||
request: { expectedGeneration: selection.generation },
|
||||
},
|
||||
{ runDocker, validateSocket },
|
||||
);
|
||||
if (command.request.expectedGeneration > 1 && rolloutBackup === null) {
|
||||
rolloutBackup = await inspectBackup(
|
||||
backupOptions(command, paths, rollbackPreflight.profile),
|
||||
);
|
||||
}
|
||||
applyActiveSelection(rollbackCommand, paths, runDocker);
|
||||
const rollbackEvidence = await observeActive(
|
||||
rollbackCommand,
|
||||
paths,
|
||||
selection,
|
||||
rollbackPreflight.profile,
|
||||
{ runDocker, now, wait },
|
||||
);
|
||||
if (!rollbackEvidence) {
|
||||
configurationError(
|
||||
'compose rollback did not produce active health evidence',
|
||||
);
|
||||
}
|
||||
const rolledBack = result(
|
||||
'rolled_back',
|
||||
command.request.expectedGeneration,
|
||||
selection.generation,
|
||||
rollbackPreflight.profile,
|
||||
);
|
||||
publishReceipt(
|
||||
receiptPath,
|
||||
command,
|
||||
rolledBack,
|
||||
rollbackEvidence,
|
||||
sqliteReceipt(rolloutBackup, writeObservation),
|
||||
identity.uid,
|
||||
);
|
||||
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
||||
return rolledBack;
|
||||
}
|
||||
|
||||
export function applyLocalDeploymentComposeCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalDeploymentComposeApplyResult>> {
|
||||
return applyLocalDeploymentCompose(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import { publishExactFile } from '../foundation/files';
|
||||
import type { LocalDeploymentPaths } from '../foundation/render';
|
||||
|
||||
const TOMBSTONE_SCHEMA = 'qinglong/local-compose-collected-evidence@v1';
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const MAX_TOMBSTONE_BYTES = 16 * 1024;
|
||||
const COMPOSE_EVIDENCE_SQLITE_CONTRACT_MIN_VERSION = 40;
|
||||
|
||||
export type ComposeEvidenceKind = 'rollout-backup' | 'restore-safeguard';
|
||||
|
||||
export interface ComposeSnapshotEvidence {
|
||||
readonly contractVersion: number;
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
readonly pageCount: number;
|
||||
readonly pageSize: number;
|
||||
}
|
||||
|
||||
export interface ComposeCollectedEvidence {
|
||||
readonly schema: typeof TOMBSTONE_SCHEMA;
|
||||
readonly kind: ComposeEvidenceKind;
|
||||
readonly artifactId: string;
|
||||
readonly collectionId: string;
|
||||
readonly generation: number;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly sourceReceiptDigest: string;
|
||||
readonly snapshot: Readonly<ComposeSnapshotEvidence>;
|
||||
readonly collectedAtMs: number;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
export function evidenceDigest(value: string | Buffer): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export function collectedEvidencePath(
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
kind: ComposeEvidenceKind,
|
||||
artifactId: string,
|
||||
): string {
|
||||
return path.join(
|
||||
kind === 'rollout-backup'
|
||||
? paths.composeCollectedRolloutBackups
|
||||
: paths.composeCollectedRestoreSafeguards,
|
||||
`${artifactId}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function evidenceStagePath(artifactPath: string): string {
|
||||
return path.join(
|
||||
path.dirname(artifactPath),
|
||||
`.${path.basename(artifactPath)}.ql3-collection-stage`,
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalContents(
|
||||
evidence: Readonly<ComposeCollectedEvidence>,
|
||||
): string {
|
||||
return `${JSON.stringify(evidence, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function validSnapshot(
|
||||
value: unknown,
|
||||
maximumContractVersion: number,
|
||||
): value is ComposeSnapshotEvidence {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const candidate = value as Partial<ComposeSnapshotEvidence>;
|
||||
return (
|
||||
Object.keys(value).sort().join(',') ===
|
||||
['bytes', 'contractVersion', 'pageCount', 'pageSize', 'sha256']
|
||||
.sort()
|
||||
.join(',') &&
|
||||
Number.isSafeInteger(candidate.contractVersion) &&
|
||||
(candidate.contractVersion as number) >=
|
||||
COMPOSE_EVIDENCE_SQLITE_CONTRACT_MIN_VERSION &&
|
||||
(candidate.contractVersion as number) <= maximumContractVersion &&
|
||||
typeof candidate.sha256 === 'string' &&
|
||||
SHA256_PATTERN.test(candidate.sha256) &&
|
||||
Number.isSafeInteger(candidate.bytes) &&
|
||||
(candidate.bytes as number) > 0 &&
|
||||
Number.isSafeInteger(candidate.pageCount) &&
|
||||
(candidate.pageCount as number) > 0 &&
|
||||
Number.isSafeInteger(candidate.pageSize) &&
|
||||
(candidate.pageSize as number) >= 512 &&
|
||||
(candidate.pageSize as number) <= 65_536
|
||||
);
|
||||
}
|
||||
|
||||
function exactSnapshot(
|
||||
expected: Readonly<ComposeSnapshotEvidence>,
|
||||
actual: Readonly<ComposeSnapshotEvidence>,
|
||||
): boolean {
|
||||
return (
|
||||
expected.contractVersion === actual.contractVersion &&
|
||||
expected.sha256 === actual.sha256 &&
|
||||
expected.bytes === actual.bytes &&
|
||||
expected.pageCount === actual.pageCount &&
|
||||
expected.pageSize === actual.pageSize
|
||||
);
|
||||
}
|
||||
|
||||
export function inspectCollectedEvidence(
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
uid: number,
|
||||
maximumContractVersion: number,
|
||||
expected: Readonly<{
|
||||
kind: ComposeEvidenceKind;
|
||||
artifactId: string;
|
||||
sourceReceiptDigest: string;
|
||||
snapshot: Readonly<ComposeSnapshotEvidence>;
|
||||
}>,
|
||||
): Readonly<ComposeCollectedEvidence> | null {
|
||||
if (
|
||||
!Number.isSafeInteger(maximumContractVersion) ||
|
||||
maximumContractVersion < COMPOSE_EVIDENCE_SQLITE_CONTRACT_MIN_VERSION
|
||||
) {
|
||||
configurationError('compose evidence contract boundary is invalid');
|
||||
}
|
||||
const filePath = collectedEvidencePath(
|
||||
paths,
|
||||
expected.kind,
|
||||
expected.artifactId,
|
||||
);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
configurationError('compose collected evidence is unavailable', error);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
stat.nlink !== 1 ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_TOMBSTONE_BYTES
|
||||
) {
|
||||
configurationError('compose collected evidence identity is invalid');
|
||||
}
|
||||
const contents = fs.readFileSync(filePath, 'utf8');
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(contents);
|
||||
} catch (error) {
|
||||
configurationError('compose collected evidence is invalid', error);
|
||||
}
|
||||
const evidence = value as ComposeCollectedEvidence;
|
||||
if (
|
||||
!evidence ||
|
||||
typeof evidence !== 'object' ||
|
||||
Array.isArray(evidence) ||
|
||||
Object.keys(evidence).sort().join(',') !==
|
||||
[
|
||||
'artifactId',
|
||||
'collectedAtMs',
|
||||
'collectionId',
|
||||
'generation',
|
||||
'kind',
|
||||
'profile',
|
||||
'schema',
|
||||
'snapshot',
|
||||
'sourceReceiptDigest',
|
||||
]
|
||||
.sort()
|
||||
.join(',') ||
|
||||
evidence.schema !== TOMBSTONE_SCHEMA ||
|
||||
evidence.kind !== expected.kind ||
|
||||
evidence.artifactId !== expected.artifactId ||
|
||||
!UUID_V4_PATTERN.test(evidence.artifactId) ||
|
||||
!UUID_V4_PATTERN.test(evidence.collectionId) ||
|
||||
!Number.isSafeInteger(evidence.generation) ||
|
||||
evidence.generation < 1 ||
|
||||
(evidence.profile !== 'edge' && evidence.profile !== 'standalone') ||
|
||||
evidence.sourceReceiptDigest !== expected.sourceReceiptDigest ||
|
||||
!SHA256_PATTERN.test(evidence.sourceReceiptDigest) ||
|
||||
!validSnapshot(evidence.snapshot, maximumContractVersion) ||
|
||||
!exactSnapshot(expected.snapshot, evidence.snapshot) ||
|
||||
!Number.isSafeInteger(evidence.collectedAtMs) ||
|
||||
evidence.collectedAtMs < 0 ||
|
||||
contents !== canonicalContents(evidence)
|
||||
) {
|
||||
configurationError('compose collected evidence drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
...evidence,
|
||||
snapshot: Object.freeze({ ...evidence.snapshot }),
|
||||
});
|
||||
}
|
||||
|
||||
export function publishCollectedEvidence(
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
uid: number,
|
||||
evidence: Readonly<ComposeCollectedEvidence>,
|
||||
): void {
|
||||
publishExactFile(
|
||||
collectedEvidencePath(paths, evidence.kind, evidence.artifactId),
|
||||
canonicalContents(evidence),
|
||||
0o600,
|
||||
uid,
|
||||
'compose collected evidence',
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,475 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
import {
|
||||
inspectLocalSqliteReadinessPath,
|
||||
LOCAL_SQLITE_CONTRACT_VERSION,
|
||||
} from '@qinglong/local-sqlite/readiness-inspection';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
normalizeLocalDeploymentComposePreflightCommand,
|
||||
normalizeLocalDeploymentPrepareCommand,
|
||||
type LocalDeploymentComposePreflightResult,
|
||||
type LocalDeploymentProfile,
|
||||
} from '../foundation/contract';
|
||||
import { inspectActiveComposeImageSelection } from './composeRevision';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../foundation/docker';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import {
|
||||
applicationConfiguration,
|
||||
composeProjectName,
|
||||
deploymentPaths,
|
||||
descriptor,
|
||||
} from '../foundation/render';
|
||||
|
||||
const CONTAINER_ROOT = '/var/lib/qinglong3';
|
||||
const MAX_DOCKER_OUTPUT_BYTES = 256 * 1024;
|
||||
const SOURCE_REPOSITORY = 'https://github.com/whyour/qinglong';
|
||||
const ENTRYPOINT = Object.freeze([
|
||||
'node',
|
||||
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
|
||||
]);
|
||||
|
||||
export interface LocalDeploymentComposePreflightDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly auditSqlite?: typeof inspectLocalSqliteReadinessPath;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
}
|
||||
|
||||
interface ApplicationIdentity {
|
||||
readonly instanceId: string;
|
||||
readonly profile: LocalDeploymentProfile;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
function readApplicationIdentity(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
): Readonly<ApplicationIdentity> {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
configurationError('application configuration is unavailable', error);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
stat.nlink !== 1 ||
|
||||
stat.size < 2 ||
|
||||
stat.size > 64 * 1024
|
||||
) {
|
||||
configurationError('application configuration identity is invalid');
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
configurationError('application configuration is invalid', error);
|
||||
}
|
||||
const candidate = value as {
|
||||
readonly schema?: unknown;
|
||||
readonly instanceId?: unknown;
|
||||
readonly profile?: unknown;
|
||||
readonly storage?: {
|
||||
readonly mode?: unknown;
|
||||
readonly databasePath?: unknown;
|
||||
readonly busyTimeoutMs?: unknown;
|
||||
};
|
||||
};
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
Array.isArray(candidate) ||
|
||||
candidate.schema !== 'qinglong/local-application-process@v2' ||
|
||||
typeof candidate.instanceId !== 'string' ||
|
||||
(candidate.profile !== 'edge' && candidate.profile !== 'standalone') ||
|
||||
!candidate.storage ||
|
||||
candidate.storage.mode !== 'fresh' ||
|
||||
candidate.storage.databasePath !== `${CONTAINER_ROOT}/qinglong3.sqlite`
|
||||
) {
|
||||
configurationError('application configuration is not a fresh Compose v2');
|
||||
}
|
||||
const busyTimeoutMs =
|
||||
candidate.storage.busyTimeoutMs === undefined
|
||||
? undefined
|
||||
: candidate.storage.busyTimeoutMs;
|
||||
if (
|
||||
busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(busyTimeoutMs) ||
|
||||
(busyTimeoutMs as number) < 100 ||
|
||||
(busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
configurationError('application busyTimeoutMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
instanceId: candidate.instanceId,
|
||||
profile: candidate.profile,
|
||||
...(busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: busyTimeoutMs as number }),
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(value: string, label: string): unknown {
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') < 2 ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_DOCKER_OUTPUT_BYTES
|
||||
) {
|
||||
configurationError(`${label} output size is invalid`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
configurationError(`${label} output is not JSON`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function safeLabel(
|
||||
labels: Readonly<Record<string, unknown>>,
|
||||
name: string,
|
||||
): string {
|
||||
const value = labels[name];
|
||||
if (typeof value !== 'string' || value.length < 1 || value.length > 128) {
|
||||
configurationError(`image label ${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function inspectImage(
|
||||
output: string,
|
||||
image: string,
|
||||
profile: LocalDeploymentProfile,
|
||||
sqliteContractVersion: number,
|
||||
): 'amd64' | 'arm64' {
|
||||
const parsed = parseJson(output, 'Docker image inspect') as unknown[];
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
||||
configurationError('Docker image inspect result is invalid');
|
||||
}
|
||||
const record = parsed[0] as {
|
||||
readonly Id?: unknown;
|
||||
readonly RepoDigests?: unknown;
|
||||
readonly Architecture?: unknown;
|
||||
readonly Os?: unknown;
|
||||
readonly Config?: {
|
||||
readonly User?: unknown;
|
||||
readonly Entrypoint?: unknown;
|
||||
readonly Labels?: unknown;
|
||||
};
|
||||
};
|
||||
if (
|
||||
!record ||
|
||||
typeof record !== 'object' ||
|
||||
typeof record.Id !== 'string' ||
|
||||
!/^sha256:[0-9a-f]{64}$/.test(record.Id) ||
|
||||
!Array.isArray(record.RepoDigests) ||
|
||||
!record.RepoDigests.includes(image) ||
|
||||
(record.Architecture !== 'amd64' && record.Architecture !== 'arm64') ||
|
||||
record.Os !== 'linux' ||
|
||||
record.Config?.User !== '65532:65532' ||
|
||||
JSON.stringify(record.Config?.Entrypoint) !== JSON.stringify(ENTRYPOINT) ||
|
||||
!record.Config?.Labels ||
|
||||
typeof record.Config.Labels !== 'object' ||
|
||||
Array.isArray(record.Config.Labels)
|
||||
) {
|
||||
configurationError('local image identity is incompatible');
|
||||
}
|
||||
const labels = record.Config.Labels as Readonly<Record<string, unknown>>;
|
||||
const minimum = safeLabel(labels, 'io.qinglong.local.sqlite-contract-min');
|
||||
const maximum = safeLabel(labels, 'io.qinglong.local.sqlite-contract-max');
|
||||
const profiles = safeLabel(labels, 'io.qinglong.profile').split(',');
|
||||
if (
|
||||
!/^[1-9][0-9]{0,3}$/.test(minimum) ||
|
||||
!/^[1-9][0-9]{0,3}$/.test(maximum) ||
|
||||
Number(minimum) > sqliteContractVersion ||
|
||||
Number(maximum) < sqliteContractVersion ||
|
||||
safeLabel(labels, 'io.qinglong.local.sqlite-write-contract') !==
|
||||
String(sqliteContractVersion) ||
|
||||
safeLabel(labels, 'io.qinglong.local.application-config') !== '2' ||
|
||||
safeLabel(labels, 'io.qinglong.local.compose-selection') !== '1' ||
|
||||
safeLabel(labels, 'io.qinglong.ai') !== 'excluded' ||
|
||||
!profiles.includes(profile) ||
|
||||
safeLabel(labels, 'org.opencontainers.image.source') !==
|
||||
SOURCE_REPOSITORY ||
|
||||
!/^[0-9a-f]{40}$/.test(
|
||||
safeLabel(labels, 'org.opencontainers.image.revision'),
|
||||
) ||
|
||||
!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(
|
||||
safeLabel(labels, 'org.opencontainers.image.version'),
|
||||
)
|
||||
) {
|
||||
configurationError('local image compatibility labels are invalid');
|
||||
}
|
||||
return record.Architecture;
|
||||
}
|
||||
|
||||
function exactArray(value: unknown, expected: readonly string[]): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
JSON.stringify([...value].sort()) === JSON.stringify([...expected].sort())
|
||||
);
|
||||
}
|
||||
|
||||
function inspectComposeConfig(
|
||||
output: string,
|
||||
expected: Readonly<{
|
||||
projectName: string;
|
||||
image: string;
|
||||
generation: number;
|
||||
mutationId: string;
|
||||
deploymentRoot: string;
|
||||
uid: number;
|
||||
gid: number;
|
||||
profile: LocalDeploymentProfile;
|
||||
}>,
|
||||
): void {
|
||||
const config = parseJson(output, 'Docker Compose config') as {
|
||||
readonly name?: unknown;
|
||||
readonly services?: unknown;
|
||||
};
|
||||
if (
|
||||
!config ||
|
||||
typeof config !== 'object' ||
|
||||
Array.isArray(config) ||
|
||||
config.name !== expected.projectName ||
|
||||
!config.services ||
|
||||
typeof config.services !== 'object' ||
|
||||
Array.isArray(config.services) ||
|
||||
Object.keys(config.services).length !== 1
|
||||
) {
|
||||
configurationError('Docker Compose project identity is invalid');
|
||||
}
|
||||
const service = (config.services as Readonly<Record<string, unknown>>)
|
||||
.qinglong3 as Readonly<Record<string, unknown>> | undefined;
|
||||
const expectedMemory =
|
||||
(expected.profile === 'edge' ? 128 : 256) * 1024 * 1024;
|
||||
const expectedPids = expected.profile === 'edge' ? 64 : 256;
|
||||
if (
|
||||
!service ||
|
||||
typeof service !== 'object' ||
|
||||
service.image !== expected.image ||
|
||||
service.user !== `${expected.uid}:${expected.gid}` ||
|
||||
service.read_only !== true ||
|
||||
service.network_mode !== 'none' ||
|
||||
service.restart !== 'unless-stopped' ||
|
||||
Number(service.mem_limit) !== expectedMemory ||
|
||||
Number(service.pids_limit) !== expectedPids ||
|
||||
service.privileged === true ||
|
||||
service.build !== undefined ||
|
||||
service.ports !== undefined ||
|
||||
service.devices !== undefined ||
|
||||
service.environment !== undefined ||
|
||||
!exactArray(service.cap_drop, ['ALL']) ||
|
||||
!exactArray(service.security_opt, ['no-new-privileges:true']) ||
|
||||
!exactArray(service.command, [
|
||||
'--config',
|
||||
`${CONTAINER_ROOT}/local-application.json`,
|
||||
])
|
||||
) {
|
||||
configurationError('Docker Compose service contract is invalid');
|
||||
}
|
||||
const labels = service.labels as
|
||||
| Readonly<Record<string, unknown>>
|
||||
| undefined;
|
||||
if (
|
||||
!labels ||
|
||||
Object.keys(labels).length !== 2 ||
|
||||
labels['io.qinglong.deployment.generation'] !==
|
||||
String(expected.generation) ||
|
||||
labels['io.qinglong.deployment.mutation'] !== expected.mutationId
|
||||
) {
|
||||
configurationError('Docker Compose deployment labels are invalid');
|
||||
}
|
||||
const volumes = service.volumes as readonly unknown[] | undefined;
|
||||
const volume = volumes?.[0] as Readonly<Record<string, unknown>> | undefined;
|
||||
const tmpfs = service.tmpfs as readonly unknown[] | undefined;
|
||||
if (
|
||||
!Array.isArray(volumes) ||
|
||||
volumes.length !== 1 ||
|
||||
!volume ||
|
||||
volume.type !== 'bind' ||
|
||||
volume.source !== expected.deploymentRoot ||
|
||||
volume.target !== CONTAINER_ROOT ||
|
||||
!Array.isArray(tmpfs) ||
|
||||
tmpfs.length !== 1 ||
|
||||
typeof tmpfs[0] !== 'string' ||
|
||||
!tmpfs[0].startsWith('/tmp:') ||
|
||||
!tmpfs[0].includes('noexec') ||
|
||||
!tmpfs[0].includes('nosuid') ||
|
||||
!tmpfs[0].includes('nodev')
|
||||
) {
|
||||
configurationError('Docker Compose filesystem contract is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export async function preflightLocalDeploymentCompose(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentComposePreflightDependencies = {},
|
||||
): Promise<Readonly<LocalDeploymentComposePreflightResult>> {
|
||||
const command = normalizeLocalDeploymentComposePreflightCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const paths = deploymentPaths(command.options.deploymentRoot);
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.service,
|
||||
identity.uid,
|
||||
'serviceDescriptorRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.composeRevisions,
|
||||
identity.uid,
|
||||
'composeRevisionRoot',
|
||||
);
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||
const selection = inspectActiveComposeImageSelection(
|
||||
paths.composeSelection,
|
||||
paths.composeRevisions,
|
||||
identity.uid,
|
||||
);
|
||||
if (selection.generation !== command.request.expectedGeneration) {
|
||||
configurationError(
|
||||
'active compose generation does not match expectedGeneration',
|
||||
);
|
||||
}
|
||||
const application = readApplicationIdentity(
|
||||
paths.applicationConfig,
|
||||
identity.uid,
|
||||
);
|
||||
const syntheticPrepare = normalizeLocalDeploymentPrepareCommand({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.prepare',
|
||||
options: {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
profile: application.profile,
|
||||
instanceId: application.instanceId,
|
||||
...(application.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: application.busyTimeoutMs }),
|
||||
service: {
|
||||
kind: 'compose',
|
||||
image: selection.image,
|
||||
allowRootService: command.options.allowRootService,
|
||||
},
|
||||
},
|
||||
request: {
|
||||
ownerPepperKeyId: 'preflight',
|
||||
registerMutationId: '00000000-0000-4000-8000-000000000001',
|
||||
activateMutationId: '00000000-0000-4000-8000-000000000002',
|
||||
registeredAtMs: 0,
|
||||
activatedAtMs: 0,
|
||||
},
|
||||
});
|
||||
preflightPublishedFile(
|
||||
paths.applicationConfig,
|
||||
applicationConfiguration(syntheticPrepare, paths),
|
||||
0o600,
|
||||
identity.uid,
|
||||
'application configuration',
|
||||
);
|
||||
const expectedDescriptor = descriptor(
|
||||
syntheticPrepare,
|
||||
paths.applicationConfig,
|
||||
identity.uid,
|
||||
identity.gid,
|
||||
);
|
||||
preflightPublishedFile(
|
||||
path.join(paths.service, expectedDescriptor.fileName),
|
||||
expectedDescriptor.contents,
|
||||
expectedDescriptor.mode,
|
||||
identity.uid,
|
||||
'service descriptor',
|
||||
);
|
||||
|
||||
const auditSqlite =
|
||||
dependencies.auditSqlite ?? inspectLocalSqliteReadinessPath;
|
||||
const sqlite = await auditSqlite({
|
||||
databasePath: paths.database,
|
||||
profile: application.profile,
|
||||
...(application.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: application.busyTimeoutMs }),
|
||||
});
|
||||
if (sqlite.contractVersion !== LOCAL_SQLITE_CONTRACT_VERSION) {
|
||||
configurationError('SQLite contract version drifted');
|
||||
}
|
||||
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
const imageOutput = runDocker({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
args: ['image', 'inspect', selection.image],
|
||||
});
|
||||
const architecture = inspectImage(
|
||||
imageOutput,
|
||||
selection.image,
|
||||
application.profile,
|
||||
sqlite.contractVersion,
|
||||
);
|
||||
const composeOutput = runDocker({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
args: [
|
||||
'compose',
|
||||
'--project-directory',
|
||||
paths.service,
|
||||
'-f',
|
||||
path.join(paths.service, 'compose.yaml'),
|
||||
'-f',
|
||||
paths.composeSelection,
|
||||
'config',
|
||||
'--format',
|
||||
'json',
|
||||
],
|
||||
});
|
||||
inspectComposeConfig(composeOutput, {
|
||||
projectName: composeProjectName(application.instanceId),
|
||||
image: selection.image,
|
||||
generation: selection.generation,
|
||||
mutationId: selection.mutationId,
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
uid: identity.uid,
|
||||
gid: identity.gid,
|
||||
profile: application.profile,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.compose.preflight' as const,
|
||||
status: 'ready' as const,
|
||||
generation: selection.generation,
|
||||
profile: application.profile,
|
||||
sqlite: Object.freeze({
|
||||
contractVersion: sqlite.contractVersion,
|
||||
}),
|
||||
image: Object.freeze({ architecture }),
|
||||
service: Object.freeze({ kind: 'compose' as const }),
|
||||
});
|
||||
}
|
||||
|
||||
export function preflightLocalDeploymentComposeCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalDeploymentComposePreflightResult>> {
|
||||
return preflightLocalDeploymentCompose(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,425 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
normalizeLocalDeploymentComposeRevisionCommand,
|
||||
type LocalDeploymentComposeRevisionCommand,
|
||||
type LocalDeploymentComposeRevisionResult,
|
||||
type LocalDeploymentPrepareCommand,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
replaceExactFile,
|
||||
syncPublishedDirectory,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import { deploymentPaths } from '../foundation/render';
|
||||
|
||||
const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v1';
|
||||
const IMAGE_PATTERN =
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
export interface ComposeImageSelection {
|
||||
readonly generation: number;
|
||||
readonly previousGeneration: number;
|
||||
readonly rollbackTargetGeneration: number;
|
||||
readonly mutationId: string;
|
||||
readonly changedAtMs: number;
|
||||
readonly image: string;
|
||||
}
|
||||
|
||||
function selectionContents(selection: Readonly<ComposeImageSelection>): string {
|
||||
return [
|
||||
'x-qinglong-image-selection:',
|
||||
` schema: ${SELECTION_SCHEMA}`,
|
||||
` generation: ${selection.generation}`,
|
||||
` previous_generation: ${selection.previousGeneration}`,
|
||||
` rollback_target_generation: ${selection.rollbackTargetGeneration}`,
|
||||
` mutation_id: ${selection.mutationId}`,
|
||||
` changed_at_ms: ${selection.changedAtMs}`,
|
||||
'services:',
|
||||
' qinglong3:',
|
||||
` image: ${selection.image}`,
|
||||
' labels:',
|
||||
` io.qinglong.deployment.generation: "${selection.generation}"`,
|
||||
` io.qinglong.deployment.mutation: "${selection.mutationId}"`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function parseInteger(value: string | undefined, label: string): number {
|
||||
if (!value || !/^(0|[1-9][0-9]{0,5})$/.test(value)) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is invalid`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed > 100_000) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is invalid`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseSelection(
|
||||
contents: string,
|
||||
label: string,
|
||||
): Readonly<ComposeImageSelection> {
|
||||
const match =
|
||||
/^x-qinglong-image-selection:\n schema: qinglong\/local-compose-image-selection@v1\n generation: (0|[1-9][0-9]{0,5})\n previous_generation: (0|[1-9][0-9]{0,5})\n rollback_target_generation: (0|[1-9][0-9]{0,5})\n mutation_id: ([0-9a-f-]+)\n changed_at_ms: ([0-9]+)\nservices:\n qinglong3:\n image: ([^\n]+)\n labels:\n io\.qinglong\.deployment\.generation: "([0-9]+)"\n io\.qinglong\.deployment\.mutation: "([0-9a-f-]+)"\n$/.exec(
|
||||
contents,
|
||||
);
|
||||
if (!match) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} shape is invalid`);
|
||||
}
|
||||
const generation = parseInteger(match[1], `${label} generation`);
|
||||
const previousGeneration = parseInteger(
|
||||
match[2],
|
||||
`${label} previous generation`,
|
||||
);
|
||||
const rollbackTargetGeneration = parseInteger(
|
||||
match[3],
|
||||
`${label} rollback target generation`,
|
||||
);
|
||||
const mutationId = match[4];
|
||||
const changedAtMs = Number(match[5]);
|
||||
const image = match[6];
|
||||
const labelGeneration = Number(match[7]);
|
||||
const labelMutationId = match[8];
|
||||
if (
|
||||
generation < 1 ||
|
||||
previousGeneration !== generation - 1 ||
|
||||
rollbackTargetGeneration >= generation ||
|
||||
!Number.isSafeInteger(changedAtMs) ||
|
||||
changedAtMs < 0 ||
|
||||
!mutationId ||
|
||||
!UUID_V4_PATTERN.test(mutationId) ||
|
||||
!image ||
|
||||
!IMAGE_PATTERN.test(image) ||
|
||||
image.includes('..') ||
|
||||
image.includes('//') ||
|
||||
labelGeneration !== generation ||
|
||||
labelMutationId !== mutationId
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} value is invalid`);
|
||||
}
|
||||
const selection = Object.freeze({
|
||||
generation,
|
||||
previousGeneration,
|
||||
rollbackTargetGeneration,
|
||||
mutationId,
|
||||
changedAtMs,
|
||||
image,
|
||||
});
|
||||
if (selectionContents(selection) !== contents) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is not canonical`);
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
function readSelectionFile(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): Readonly<{ contents: string; selection: Readonly<ComposeImageSelection> }> {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is unavailable`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
stat.nlink !== 1 ||
|
||||
stat.size < 2 ||
|
||||
stat.size > 64 * 1024
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} identity is invalid`);
|
||||
}
|
||||
const contents = fs.readFileSync(filePath, 'utf8');
|
||||
return Object.freeze({
|
||||
contents,
|
||||
selection: parseSelection(contents, label),
|
||||
});
|
||||
}
|
||||
|
||||
function revisionPath(root: string, generation: number): string {
|
||||
return path.join(root, `${generation}.yaml`);
|
||||
}
|
||||
|
||||
function commandIntent(
|
||||
command: Readonly<LocalDeploymentComposeRevisionCommand>,
|
||||
): string {
|
||||
return `${JSON.stringify(command, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function releaseLock(lockPath: string, intent: string, uid: number): void {
|
||||
preflightPublishedFile(lockPath, intent, 0o600, uid, 'compose revision lock');
|
||||
fs.unlinkSync(lockPath);
|
||||
syncPublishedDirectory(path.dirname(lockPath));
|
||||
}
|
||||
|
||||
export function initialComposeImageSelection(
|
||||
command: Readonly<LocalDeploymentPrepareCommand>,
|
||||
): string {
|
||||
if (command.options.service.kind !== 'compose') {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'initial compose selection requires a compose service',
|
||||
);
|
||||
}
|
||||
return selectionContents({
|
||||
generation: 1,
|
||||
previousGeneration: 0,
|
||||
rollbackTargetGeneration: 0,
|
||||
mutationId: command.request.activateMutationId,
|
||||
changedAtMs: command.request.activatedAtMs,
|
||||
image: command.options.service.image,
|
||||
});
|
||||
}
|
||||
|
||||
export function preflightActiveComposeImageSelection(
|
||||
selectionPath: string,
|
||||
revisionsRoot: string,
|
||||
initialContents: string,
|
||||
uid: number,
|
||||
): 'absent' | 'existing' {
|
||||
if (!fs.existsSync(selectionPath)) return 'absent';
|
||||
const active = readSelectionFile(
|
||||
selectionPath,
|
||||
uid,
|
||||
'active compose selection',
|
||||
);
|
||||
if (active.selection.generation === 1) {
|
||||
if (active.contents !== initialContents) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'initial compose selection drifted',
|
||||
);
|
||||
}
|
||||
return 'existing';
|
||||
}
|
||||
const revision = readSelectionFile(
|
||||
revisionPath(revisionsRoot, active.selection.generation),
|
||||
uid,
|
||||
'active compose revision',
|
||||
);
|
||||
if (
|
||||
revision.selection.generation !== active.selection.generation ||
|
||||
revision.contents !== active.contents
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'active compose selection is not backed by its immutable revision',
|
||||
);
|
||||
}
|
||||
return 'existing';
|
||||
}
|
||||
|
||||
export function inspectActiveComposeImageSelection(
|
||||
selectionPath: string,
|
||||
revisionsRoot: string,
|
||||
uid: number,
|
||||
): Readonly<ComposeImageSelection> {
|
||||
const active = readSelectionFile(
|
||||
selectionPath,
|
||||
uid,
|
||||
'active compose selection',
|
||||
);
|
||||
const revision = readSelectionFile(
|
||||
revisionPath(revisionsRoot, active.selection.generation),
|
||||
uid,
|
||||
'active compose revision',
|
||||
);
|
||||
if (
|
||||
revision.selection.generation !== active.selection.generation ||
|
||||
revision.contents !== active.contents
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'active compose selection is not backed by its immutable revision',
|
||||
);
|
||||
}
|
||||
return active.selection;
|
||||
}
|
||||
|
||||
export function inspectComposeImageSelectionGeneration(
|
||||
revisionsRoot: string,
|
||||
generation: number,
|
||||
uid: number,
|
||||
): Readonly<ComposeImageSelection> {
|
||||
const revision = readSelectionFile(
|
||||
revisionPath(revisionsRoot, generation),
|
||||
uid,
|
||||
'compose revision',
|
||||
);
|
||||
if (revision.selection.generation !== generation) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'compose revision generation drifted',
|
||||
);
|
||||
}
|
||||
return revision.selection;
|
||||
}
|
||||
|
||||
export async function switchLocalDeploymentComposeRevision(
|
||||
input: unknown,
|
||||
rolloutLockIntent?: string,
|
||||
): Promise<Readonly<LocalDeploymentComposeRevisionResult>> {
|
||||
const command = normalizeLocalDeploymentComposeRevisionCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const paths = deploymentPaths(command.options.deploymentRoot);
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.service,
|
||||
identity.uid,
|
||||
'serviceDescriptorRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.composeRevisions,
|
||||
identity.uid,
|
||||
'composeRevisionRoot',
|
||||
);
|
||||
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'compose revision is fenced by an evidence collection',
|
||||
);
|
||||
}
|
||||
if (fs.existsSync(paths.composeRolloutLock)) {
|
||||
if (rolloutLockIntent === undefined) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'compose revision is fenced by an in-flight rollout',
|
||||
);
|
||||
}
|
||||
preflightPublishedFile(
|
||||
paths.composeRolloutLock,
|
||||
rolloutLockIntent,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'compose rollout lock',
|
||||
);
|
||||
}
|
||||
|
||||
const observed = readSelectionFile(
|
||||
paths.composeSelection,
|
||||
identity.uid,
|
||||
'active compose selection',
|
||||
);
|
||||
const nextGeneration = command.request.expectedGeneration + 1;
|
||||
let image: string;
|
||||
let rollbackTargetGeneration = 0;
|
||||
if (command.operation === 'local.deployment.compose.upgrade') {
|
||||
image = command.request.image;
|
||||
} else {
|
||||
rollbackTargetGeneration = command.request.targetGeneration;
|
||||
const target = readSelectionFile(
|
||||
revisionPath(paths.composeRevisions, command.request.targetGeneration),
|
||||
identity.uid,
|
||||
'rollback target compose revision',
|
||||
);
|
||||
if (target.selection.generation !== command.request.targetGeneration) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'rollback target generation drifted',
|
||||
);
|
||||
}
|
||||
image = target.selection.image;
|
||||
}
|
||||
const nextContents = selectionContents({
|
||||
generation: nextGeneration,
|
||||
previousGeneration: command.request.expectedGeneration,
|
||||
rollbackTargetGeneration,
|
||||
mutationId: command.request.mutationId,
|
||||
changedAtMs: command.request.changedAtMs,
|
||||
image,
|
||||
});
|
||||
if (command.request.changedAtMs < observed.selection.changedAtMs) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'compose revision time precedes the active selection',
|
||||
);
|
||||
}
|
||||
const exactReplay =
|
||||
observed.selection.generation === nextGeneration &&
|
||||
observed.contents === nextContents;
|
||||
if (
|
||||
!exactReplay &&
|
||||
observed.selection.generation !== command.request.expectedGeneration
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'active compose generation does not match expectedGeneration',
|
||||
);
|
||||
}
|
||||
|
||||
const intent = commandIntent(command);
|
||||
publishExactFile(
|
||||
paths.composeRevisionLock,
|
||||
intent,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'compose revision lock',
|
||||
);
|
||||
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
|
||||
releaseLock(paths.composeRevisionLock, intent, identity.uid);
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'compose revision is fenced by an evidence collection',
|
||||
);
|
||||
}
|
||||
const current = readSelectionFile(
|
||||
paths.composeSelection,
|
||||
identity.uid,
|
||||
'active compose selection',
|
||||
);
|
||||
if (
|
||||
current.contents !== observed.contents &&
|
||||
current.contents !== nextContents
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'active compose selection changed while acquiring revision lock',
|
||||
);
|
||||
}
|
||||
const revisionStatus = publishExactFile(
|
||||
revisionPath(paths.composeRevisions, nextGeneration),
|
||||
nextContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'compose revision',
|
||||
);
|
||||
const switchStatus =
|
||||
current.contents === nextContents
|
||||
? ('existing' as const)
|
||||
: replaceExactFile(
|
||||
paths.composeSelection,
|
||||
observed.contents,
|
||||
nextContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'active compose selection',
|
||||
);
|
||||
releaseLock(paths.composeRevisionLock, intent, identity.uid);
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status:
|
||||
revisionStatus === 'existing' && switchStatus === 'existing'
|
||||
? ('existing' as const)
|
||||
: ('prepared' as const),
|
||||
generation: nextGeneration,
|
||||
service: Object.freeze({ kind: 'compose' as const }),
|
||||
});
|
||||
}
|
||||
|
||||
export function switchLocalDeploymentComposeRevisionCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalDeploymentComposeRevisionResult>> {
|
||||
return switchLocalDeploymentComposeRevision(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
type LocalDeploymentProfile,
|
||||
} from '../foundation/contract';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface LocalDeploymentLegacyStopCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.cutover.legacy-stop';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
dockerExecutable: string;
|
||||
dockerSocketPath: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
cutoverId: string;
|
||||
profile: LocalDeploymentProfile;
|
||||
instanceId: string;
|
||||
activationPath: string;
|
||||
legacySourcePath: string;
|
||||
expectedLegacyDatabasePath: string;
|
||||
expectedActivationDigest: string;
|
||||
expectedLegacyContainerId: string;
|
||||
requestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentLegacyStopResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.cutover.legacy-stop';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'legacy_stopped';
|
||||
readonly cutoverId: string;
|
||||
readonly commitmentDigest: string;
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeAbsolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} must be a supervisor-safe normalized absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function trustedExecutable(value: unknown, uid: number): string {
|
||||
const filePath = safeAbsolutePath(value, 'dockerExecutable');
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable is unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
(stat.uid !== 0 && stat.uid !== uid) ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(stat.mode & 0o111) === 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable must be a canonical trusted executable',
|
||||
);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new LocalDeploymentConfigurationError('requestedAtMs is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentLegacyStopCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentLegacyStopCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !== 'local.deployment.cutover.legacy-stop'
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const identity = currentIdentity();
|
||||
const options = object(command.options, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'deploymentRoot',
|
||||
'dockerExecutable',
|
||||
'dockerSocketPath',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'allowRootService does not match the current identity',
|
||||
);
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'activationPath',
|
||||
'cutoverId',
|
||||
'expectedActivationDigest',
|
||||
'expectedLegacyDatabasePath',
|
||||
'expectedLegacyContainerId',
|
||||
'instanceId',
|
||||
'legacySourcePath',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
typeof request.cutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(request.cutoverId) ||
|
||||
(request.profile !== 'edge' && request.profile !== 'standalone') ||
|
||||
typeof request.instanceId !== 'string' ||
|
||||
!INSTANCE_ID_PATTERN.test(request.instanceId) ||
|
||||
typeof request.expectedActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedActivationDigest) ||
|
||||
typeof request.expectedLegacyContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedLegacyContainerId)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'cutover request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.cutover.legacy-stop' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
dockerExecutable: trustedExecutable(
|
||||
options.dockerExecutable,
|
||||
identity.uid,
|
||||
),
|
||||
dockerSocketPath: safeAbsolutePath(
|
||||
options.dockerSocketPath,
|
||||
'dockerSocketPath',
|
||||
),
|
||||
allowRootService: options.allowRootService,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
cutoverId: request.cutoverId,
|
||||
profile: request.profile,
|
||||
instanceId: request.instanceId,
|
||||
activationPath: safeAbsolutePath(
|
||||
request.activationPath,
|
||||
'activationPath',
|
||||
),
|
||||
legacySourcePath: safeAbsolutePath(
|
||||
request.legacySourcePath,
|
||||
'legacySourcePath',
|
||||
),
|
||||
expectedLegacyDatabasePath: safeAbsolutePath(
|
||||
request.expectedLegacyDatabasePath,
|
||||
'expectedLegacyDatabasePath',
|
||||
),
|
||||
expectedActivationDigest: request.expectedActivationDigest,
|
||||
expectedLegacyContainerId: request.expectedLegacyContainerId,
|
||||
requestedAtMs: timestamp(request.requestedAtMs),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
replaceExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import { cutoverDigest } from './targetEvidence';
|
||||
|
||||
const HEAD_SCHEMA = 'qinglong3-local-cutover-instance-head';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const ZERO_DIGEST = '0'.repeat(64);
|
||||
const MAX_INSTANCES = 64;
|
||||
|
||||
export type LocalCutoverInstanceHeadState =
|
||||
| 'legacy_stop_requested'
|
||||
| 'legacy_stopped'
|
||||
| 'target_active'
|
||||
| 'target_stopped'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
| 'manual_required'
|
||||
| 'resolution_authorized';
|
||||
|
||||
export interface LocalCutoverIdentity {
|
||||
readonly options: Readonly<{ deploymentRoot: string }>;
|
||||
readonly request: Readonly<{
|
||||
cutoverId: string;
|
||||
profile: 'edge' | 'standalone';
|
||||
instanceId: string;
|
||||
expectedActivationDigest: string;
|
||||
requestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalCutoverInstanceHead {
|
||||
readonly schema: typeof HEAD_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly revision: number;
|
||||
readonly instanceId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly cutoverId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly state: LocalCutoverInstanceHeadState;
|
||||
readonly generation: number;
|
||||
readonly previousHeadDigest: string;
|
||||
readonly sourceRecordDigest: string;
|
||||
readonly updatedAtMs: number;
|
||||
readonly headDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function contents(head: Readonly<LocalCutoverInstanceHead>): string {
|
||||
return `${JSON.stringify(head, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function record(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
revision: number,
|
||||
state: LocalCutoverInstanceHeadState,
|
||||
generation: number,
|
||||
previousHeadDigest: string,
|
||||
sourceRecordDigest: string,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const payload = Object.freeze({
|
||||
schema: HEAD_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
revision,
|
||||
instanceId: identity.request.instanceId,
|
||||
profile: identity.request.profile,
|
||||
cutoverId: identity.request.cutoverId,
|
||||
activationDigest: identity.request.expectedActivationDigest,
|
||||
state,
|
||||
generation,
|
||||
previousHeadDigest,
|
||||
sourceRecordDigest,
|
||||
updatedAtMs: identity.request.requestedAtMs,
|
||||
});
|
||||
return Object.freeze({ ...payload, headDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
const head = object(value, 'cutover instance head');
|
||||
exact(
|
||||
head,
|
||||
[
|
||||
'activationDigest',
|
||||
'cutoverId',
|
||||
'generation',
|
||||
'headDigest',
|
||||
'instanceId',
|
||||
'previousHeadDigest',
|
||||
'profile',
|
||||
'revision',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'sourceRecordDigest',
|
||||
'state',
|
||||
'updatedAtMs',
|
||||
],
|
||||
'cutover instance head',
|
||||
);
|
||||
const { headDigest, ...payload } = head;
|
||||
if (
|
||||
head.schema !== HEAD_SCHEMA ||
|
||||
head.schemaVersion !== 1 ||
|
||||
!Number.isSafeInteger(head.revision) ||
|
||||
(head.revision as number) < 1 ||
|
||||
typeof head.instanceId !== 'string' ||
|
||||
(head.profile !== 'edge' && head.profile !== 'standalone') ||
|
||||
typeof head.cutoverId !== 'string' ||
|
||||
typeof head.activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(head.activationDigest) ||
|
||||
(head.state !== 'legacy_stop_requested' &&
|
||||
head.state !== 'legacy_stopped' &&
|
||||
head.state !== 'target_active' &&
|
||||
head.state !== 'target_stopped' &&
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested' &&
|
||||
head.state !== 'legacy_running' &&
|
||||
head.state !== 'manual_required' &&
|
||||
head.state !== 'resolution_authorized') ||
|
||||
!Number.isSafeInteger(head.generation) ||
|
||||
(head.generation as number) < 0 ||
|
||||
typeof head.previousHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(head.previousHeadDigest) ||
|
||||
typeof head.sourceRecordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(head.sourceRecordDigest) ||
|
||||
!Number.isSafeInteger(head.updatedAtMs) ||
|
||||
(head.updatedAtMs as number) < 0 ||
|
||||
typeof headDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(headDigest) ||
|
||||
cutoverDigest(payload) !== headDigest
|
||||
) {
|
||||
configurationError('cutover instance head drifted');
|
||||
}
|
||||
return head as unknown as Readonly<LocalCutoverInstanceHead>;
|
||||
}
|
||||
|
||||
export function localCutoverInstanceDirectory(
|
||||
deploymentRoot: string,
|
||||
instanceId: string,
|
||||
): string {
|
||||
return path.join(deploymentRoot, 'service', 'cutover-instances', instanceId);
|
||||
}
|
||||
|
||||
export function localCutoverInstanceHeadPath(
|
||||
deploymentRoot: string,
|
||||
instanceId: string,
|
||||
): string {
|
||||
return path.join(
|
||||
localCutoverInstanceDirectory(deploymentRoot, instanceId),
|
||||
'head.json',
|
||||
);
|
||||
}
|
||||
|
||||
function ensureInstanceDirectory(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
): string {
|
||||
const serviceRoot = path.join(identity.options.deploymentRoot, 'service');
|
||||
validatePrivateDirectory(
|
||||
identity.options.deploymentRoot,
|
||||
uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(serviceRoot, uid, 'serviceDescriptorRoot');
|
||||
const root = path.join(serviceRoot, 'cutover-instances');
|
||||
ensurePrivateDirectory(root, uid, 'cutoverInstanceRoot');
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
configurationError('cutover instance catalog contains drift');
|
||||
}
|
||||
}
|
||||
const directory = localCutoverInstanceDirectory(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
);
|
||||
if (entries.length >= MAX_INSTANCES && !fs.existsSync(directory)) {
|
||||
configurationError('cutover instance retention limit is reached');
|
||||
}
|
||||
ensurePrivateDirectory(directory, uid, 'cutoverInstanceDirectory');
|
||||
return directory;
|
||||
}
|
||||
|
||||
export function readLocalCutoverInstanceHead(
|
||||
deploymentRoot: string,
|
||||
instanceId: string,
|
||||
uid: number,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const directory = localCutoverInstanceDirectory(deploymentRoot, instanceId);
|
||||
validatePrivateDirectory(directory, uid, 'cutoverInstanceDirectory');
|
||||
return parseHead(
|
||||
readPrivateLocalCommandFile(
|
||||
localCutoverInstanceHeadPath(deploymentRoot, instanceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function replaceHead(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
current: Readonly<LocalCutoverInstanceHead>,
|
||||
next: Readonly<LocalCutoverInstanceHead>,
|
||||
): 'prepared' | 'existing' {
|
||||
return replaceExactFile(
|
||||
localCutoverInstanceHeadPath(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
),
|
||||
contents(current),
|
||||
contents(next),
|
||||
0o600,
|
||||
uid,
|
||||
'cutover instance head',
|
||||
);
|
||||
}
|
||||
|
||||
export function claimLocalCutoverInstance(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
intentDigest: string,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
ensureInstanceDirectory(identity, uid);
|
||||
const headPath = localCutoverInstanceHeadPath(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
);
|
||||
if (!fs.existsSync(headPath)) {
|
||||
const initial = record(
|
||||
identity,
|
||||
1,
|
||||
'legacy_stop_requested',
|
||||
0,
|
||||
ZERO_DIGEST,
|
||||
intentDigest,
|
||||
);
|
||||
const serialized = contents(initial);
|
||||
preflightPublishedFile(
|
||||
headPath,
|
||||
serialized,
|
||||
0o600,
|
||||
uid,
|
||||
'cutover instance head',
|
||||
);
|
||||
publishExactFile(headPath, serialized, 0o600, uid, 'cutover instance head');
|
||||
}
|
||||
const current = readLocalCutoverInstanceHead(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
current.profile !== identity.request.profile ||
|
||||
current.cutoverId !== identity.request.cutoverId ||
|
||||
current.activationDigest !== identity.request.expectedActivationDigest
|
||||
) {
|
||||
configurationError(
|
||||
'another cutover owns the instance; an explicit manual resolution is required',
|
||||
);
|
||||
}
|
||||
if (current.state !== 'resolution_authorized') return current;
|
||||
const next = record(
|
||||
identity,
|
||||
current.revision + 1,
|
||||
'legacy_stop_requested',
|
||||
0,
|
||||
current.headDigest,
|
||||
intentDigest,
|
||||
);
|
||||
replaceHead(identity, uid, current, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function advanceLocalCutoverInstanceHead(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
state:
|
||||
| 'legacy_stopped'
|
||||
| 'target_active'
|
||||
| 'target_stopped'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
| 'manual_required',
|
||||
generation: number,
|
||||
sourceRecordDigest: string,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const current = readLocalCutoverInstanceHead(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
current.profile !== identity.request.profile ||
|
||||
current.cutoverId !== identity.request.cutoverId ||
|
||||
current.activationDigest !== identity.request.expectedActivationDigest
|
||||
) {
|
||||
configurationError('cutover instance head does not match the command');
|
||||
}
|
||||
if (
|
||||
current.state === state &&
|
||||
current.generation === generation &&
|
||||
current.sourceRecordDigest === sourceRecordDigest
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
current.state === 'target_active' &&
|
||||
state === 'target_active' &&
|
||||
current.generation > generation
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
state === 'target_stopped' &&
|
||||
current.generation === generation &&
|
||||
(current.state === 'rollback_prepared' ||
|
||||
current.state === 'legacy_restart_requested' ||
|
||||
current.state === 'legacy_running')
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
if (current.state === 'manual_required') {
|
||||
configurationError('manual-required cutover instance head is terminal');
|
||||
}
|
||||
const allowed =
|
||||
(state === 'legacy_stopped' && current.state === 'legacy_stop_requested') ||
|
||||
(state === 'target_active' &&
|
||||
(current.state === 'legacy_stopped' ||
|
||||
current.state === 'target_active')) ||
|
||||
(state === 'target_stopped' && current.state === 'target_active') ||
|
||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||
(state === 'legacy_restart_requested' &&
|
||||
current.state === 'rollback_prepared') ||
|
||||
(state === 'legacy_running' &&
|
||||
current.state === 'legacy_restart_requested') ||
|
||||
(state === 'manual_required' &&
|
||||
(current.state === 'legacy_stopped' ||
|
||||
current.state === 'target_active' ||
|
||||
current.state === 'rollback_prepared' ||
|
||||
current.state === 'legacy_restart_requested'));
|
||||
if (!allowed)
|
||||
configurationError('cutover instance head transition is invalid');
|
||||
const next = record(
|
||||
identity,
|
||||
current.revision + 1,
|
||||
state,
|
||||
generation,
|
||||
current.headDigest,
|
||||
sourceRecordDigest,
|
||||
);
|
||||
replaceHead(identity, uid, current, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function assertLocalCutoverTargetHead(
|
||||
identity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
identity.options.deploymentRoot,
|
||||
identity.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
head.profile !== identity.request.profile ||
|
||||
head.cutoverId !== identity.request.cutoverId ||
|
||||
head.activationDigest !== identity.request.expectedActivationDigest ||
|
||||
(head.state !== 'legacy_stopped' &&
|
||||
head.state !== 'target_active' &&
|
||||
head.state !== 'manual_required')
|
||||
) {
|
||||
configurationError(
|
||||
'target command is not bound to the instance lineage head',
|
||||
);
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
export function authorizeResolvedLocalCutoverInstance(
|
||||
currentIdentity: Readonly<LocalCutoverIdentity>,
|
||||
nextIdentity: Readonly<LocalCutoverIdentity>,
|
||||
uid: number,
|
||||
expectedHeadDigest: string,
|
||||
resolutionDigest: string,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const current = readLocalCutoverInstanceHead(
|
||||
currentIdentity.options.deploymentRoot,
|
||||
currentIdentity.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
current.headDigest !== expectedHeadDigest ||
|
||||
current.state !== 'manual_required' ||
|
||||
current.profile !== currentIdentity.request.profile ||
|
||||
current.cutoverId !== currentIdentity.request.cutoverId ||
|
||||
current.activationDigest !==
|
||||
currentIdentity.request.expectedActivationDigest
|
||||
) {
|
||||
configurationError(
|
||||
'manual resolution lost the instance head compare-and-swap',
|
||||
);
|
||||
}
|
||||
const next = record(
|
||||
nextIdentity,
|
||||
current.revision + 1,
|
||||
'resolution_authorized',
|
||||
0,
|
||||
current.headDigest,
|
||||
resolutionDigest,
|
||||
);
|
||||
replaceHead(nextIdentity, uid, current, next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../foundation/docker';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
localCutoverInstanceDirectory,
|
||||
readLocalCutoverInstanceHead,
|
||||
type LocalCutoverInstanceHead,
|
||||
} from './instanceLineage';
|
||||
import {
|
||||
EMPTY_ROLLBACK_PREPARATION_DIGEST,
|
||||
legacyRollbackTargetRunCommand,
|
||||
normalizeLocalDeploymentLegacyRollbackCommand,
|
||||
type LocalDeploymentLegacyRollbackCommand,
|
||||
type LocalDeploymentLegacyRollbackResult,
|
||||
} from './legacyRollbackContract';
|
||||
import {
|
||||
readTargetDataReconciliationEvidence,
|
||||
type TargetDataReconciliationEvidence,
|
||||
} from './targetDataEvidence';
|
||||
import {
|
||||
cutoverDigest,
|
||||
legacyCommitmentPath,
|
||||
parseActiveLegacyEvidence,
|
||||
parseStoppedLegacyEvidence,
|
||||
parseTargetContainerEvidence,
|
||||
readLegacySilenceEvidence,
|
||||
readTargetApplicationBinding,
|
||||
type LegacySilenceEvidence,
|
||||
type TargetApplicationBinding,
|
||||
} from './targetEvidence';
|
||||
import {
|
||||
legacyRollbackPhasePath,
|
||||
legacyRollbackSequence,
|
||||
publishTargetRunJournalRecord,
|
||||
readTargetRunJournalRecord,
|
||||
targetRunJournalRecord,
|
||||
targetRunManualEvidence,
|
||||
targetRunPhasePath,
|
||||
targetRunSequence,
|
||||
targetStopPhasePath,
|
||||
targetStopSequence,
|
||||
verifyTargetRunManualEvidence,
|
||||
type TargetRunJournalContext,
|
||||
type TargetRunJournalRecord,
|
||||
} from './target-run/targetRunJournal';
|
||||
import {
|
||||
verifyTargetActiveEvidence,
|
||||
verifyTargetRequestEvidence,
|
||||
} from './target-run/targetRunRecordEvidence';
|
||||
import {
|
||||
verifyTargetStoppedEvidence,
|
||||
verifyTargetStopRequestEvidence,
|
||||
type TargetStopActiveEvidence,
|
||||
} from './targetStopRecordEvidence';
|
||||
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
|
||||
|
||||
const PREPARATION_SCHEMA = 'qinglong3-local-legacy-rollback-preparation';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_PREPARATIONS_PER_INSTANCE = 15;
|
||||
|
||||
export interface LocalDeploymentLegacyRollbackDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
readonly afterBarrier?: () => void;
|
||||
readonly afterStart?: () => void;
|
||||
}
|
||||
|
||||
interface RollbackContext {
|
||||
readonly rollbackCommand: Readonly<LocalDeploymentLegacyRollbackCommand>;
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly sourceCommand: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly journalCommand: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly journal: string;
|
||||
readonly uid: number;
|
||||
readonly commitment: Readonly<LegacySilenceEvidence>;
|
||||
readonly application: Readonly<TargetApplicationBinding>;
|
||||
}
|
||||
|
||||
interface RollbackSource {
|
||||
readonly active: Readonly<TargetStopActiveEvidence>;
|
||||
readonly stoppedRecord: Readonly<TargetRunJournalRecord>;
|
||||
readonly reconciliation: Readonly<TargetDataReconciliationEvidence>;
|
||||
}
|
||||
|
||||
interface RollbackPreparation {
|
||||
readonly schema: typeof PREPARATION_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'rollback_prepared';
|
||||
readonly cutoverId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly generation: number;
|
||||
readonly expectedInstanceHeadDigest: string;
|
||||
readonly stoppedRecordDigest: string;
|
||||
readonly reconciliationEvidenceDigest: string;
|
||||
readonly legacyContainerIdentityDigest: string;
|
||||
readonly legacySourceBindingDigest: string;
|
||||
readonly targetContainerIdentityDigest: string;
|
||||
readonly targetApplicationBindingDigest: string;
|
||||
readonly rollbackRequestedAtMs: number;
|
||||
readonly preparationDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function journalCommand(
|
||||
source: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
requestedAtMs: number,
|
||||
): Readonly<LocalDeploymentTargetRunCommand> {
|
||||
return Object.freeze({
|
||||
...source,
|
||||
request: Object.freeze({ ...source.request, requestedAtMs }),
|
||||
});
|
||||
}
|
||||
|
||||
function targetContext(
|
||||
context: Readonly<RollbackContext>,
|
||||
): Readonly<TargetRunJournalContext> {
|
||||
return Object.freeze({ command: context.sourceCommand, uid: context.uid });
|
||||
}
|
||||
|
||||
function rollbackContext(
|
||||
context: Readonly<RollbackContext>,
|
||||
): Readonly<TargetRunJournalContext> {
|
||||
return Object.freeze({ command: context.journalCommand, uid: context.uid });
|
||||
}
|
||||
|
||||
function readRollbackSource(
|
||||
context: Readonly<RollbackContext>,
|
||||
): Readonly<RollbackSource> {
|
||||
const generation = context.sourceCommand.request.generation;
|
||||
const request = readTargetRunJournalRecord(
|
||||
targetRunPhasePath(context.journal, generation, 'request'),
|
||||
targetContext(context),
|
||||
{
|
||||
sequence: targetRunSequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1
|
||||
? 'target_start_requested'
|
||||
: 'target_restart_requested',
|
||||
],
|
||||
},
|
||||
);
|
||||
const requestEvidence = verifyTargetRequestEvidence(context, request);
|
||||
const activeRecord = readTargetRunJournalRecord(
|
||||
targetRunPhasePath(context.journal, generation, 'outcome'),
|
||||
targetContext(context),
|
||||
{
|
||||
sequence: targetRunSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_active'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
},
|
||||
);
|
||||
const active = Object.freeze({
|
||||
activeRecordDigest: activeRecord.recordDigest,
|
||||
targetContainerIdentityDigest:
|
||||
requestEvidence.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest:
|
||||
requestEvidence.targetApplicationBindingDigest,
|
||||
startupReceiptDigest: verifyTargetActiveEvidence(
|
||||
context,
|
||||
activeRecord,
|
||||
requestEvidence,
|
||||
),
|
||||
});
|
||||
const stopRequest = readTargetRunJournalRecord(
|
||||
targetStopPhasePath(context.journal, generation, 'request'),
|
||||
targetContext(context),
|
||||
{
|
||||
sequence: targetStopSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['target_stop_requested'],
|
||||
previousRecordDigest: activeRecord.recordDigest,
|
||||
requestedAtMs: context.sourceCommand.request.requestedAtMs,
|
||||
},
|
||||
);
|
||||
verifyTargetStopRequestEvidence(stopRequest, active);
|
||||
const stoppedRecord = readTargetRunJournalRecord(
|
||||
targetStopPhasePath(context.journal, generation, 'outcome'),
|
||||
targetContext(context),
|
||||
{
|
||||
sequence: targetStopSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_stopped'],
|
||||
previousRecordDigest: stopRequest.recordDigest,
|
||||
requestedAtMs: context.sourceCommand.request.requestedAtMs,
|
||||
},
|
||||
);
|
||||
const reconciliation = verifyTargetStoppedEvidence(stoppedRecord, active);
|
||||
if (
|
||||
stoppedRecord.recordDigest !==
|
||||
context.rollbackCommand.request.expectedStoppedRecordDigest ||
|
||||
reconciliation.disposition !== 'rollback_candidate'
|
||||
) {
|
||||
configurationError('legacy rollback requires the exact rollback candidate');
|
||||
}
|
||||
return Object.freeze({ active, stoppedRecord, reconciliation });
|
||||
}
|
||||
|
||||
function preparationPath(
|
||||
command: Readonly<LocalDeploymentLegacyRollbackCommand>,
|
||||
): string {
|
||||
return path.join(
|
||||
localCutoverInstanceDirectory(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
),
|
||||
`rollback-${command.request.cutoverId}-${String(
|
||||
command.request.generation,
|
||||
).padStart(2, '0')}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function preparationRecord(
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
): Readonly<RollbackPreparation> {
|
||||
const payload = Object.freeze({
|
||||
schema: PREPARATION_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'rollback_prepared' as const,
|
||||
cutoverId: context.sourceCommand.request.cutoverId,
|
||||
profile: context.sourceCommand.request.profile,
|
||||
instanceId: context.sourceCommand.request.instanceId,
|
||||
activationDigest: context.sourceCommand.request.expectedActivationDigest,
|
||||
generation: context.sourceCommand.request.generation,
|
||||
expectedInstanceHeadDigest:
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest,
|
||||
stoppedRecordDigest: source.stoppedRecord.recordDigest,
|
||||
reconciliationEvidenceDigest: source.reconciliation.evidenceDigest,
|
||||
legacyContainerIdentityDigest:
|
||||
context.commitment.legacyContainerIdentityDigest,
|
||||
legacySourceBindingDigest: context.commitment.legacySourceBindingDigest,
|
||||
targetContainerIdentityDigest: source.active.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest:
|
||||
source.active.targetApplicationBindingDigest,
|
||||
rollbackRequestedAtMs:
|
||||
context.rollbackCommand.request.rollbackRequestedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
preparationDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function parsePreparation(
|
||||
value: unknown,
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
): Readonly<RollbackPreparation> {
|
||||
const record = object(value, 'legacy rollback preparation');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'activationDigest',
|
||||
'cutoverId',
|
||||
'expectedInstanceHeadDigest',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacyContainerIdentityDigest',
|
||||
'legacySourceBindingDigest',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'reconciliationEvidenceDigest',
|
||||
'rollbackRequestedAtMs',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
'stoppedRecordDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerIdentityDigest',
|
||||
],
|
||||
'legacy rollback preparation',
|
||||
);
|
||||
const { preparationDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== PREPARATION_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.state !== 'rollback_prepared' ||
|
||||
record.cutoverId !== context.sourceCommand.request.cutoverId ||
|
||||
record.profile !== context.sourceCommand.request.profile ||
|
||||
record.instanceId !== context.sourceCommand.request.instanceId ||
|
||||
record.activationDigest !==
|
||||
context.sourceCommand.request.expectedActivationDigest ||
|
||||
record.generation !== context.sourceCommand.request.generation ||
|
||||
record.expectedInstanceHeadDigest !==
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest ||
|
||||
record.stoppedRecordDigest !== source.stoppedRecord.recordDigest ||
|
||||
record.reconciliationEvidenceDigest !==
|
||||
source.reconciliation.evidenceDigest ||
|
||||
record.legacyContainerIdentityDigest !==
|
||||
context.commitment.legacyContainerIdentityDigest ||
|
||||
record.legacySourceBindingDigest !==
|
||||
context.commitment.legacySourceBindingDigest ||
|
||||
record.targetContainerIdentityDigest !==
|
||||
source.active.targetContainerIdentityDigest ||
|
||||
record.targetApplicationBindingDigest !==
|
||||
source.active.targetApplicationBindingDigest ||
|
||||
record.rollbackRequestedAtMs !==
|
||||
context.rollbackCommand.request.rollbackRequestedAtMs ||
|
||||
typeof preparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(preparationDigest) ||
|
||||
cutoverDigest(payload) !== preparationDigest
|
||||
) {
|
||||
configurationError('legacy rollback preparation drifted');
|
||||
}
|
||||
return record as unknown as Readonly<RollbackPreparation>;
|
||||
}
|
||||
|
||||
function docker(
|
||||
context: Readonly<RollbackContext>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
): string {
|
||||
return runDocker({
|
||||
executable: context.sourceCommand.options.dockerExecutable,
|
||||
socketPath: context.sourceCommand.options.dockerSocketPath,
|
||||
args,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
function stoppedObservations(
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
): void {
|
||||
const legacy = parseStoppedLegacyEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'inspect',
|
||||
context.sourceCommand.request.expectedLegacyContainerId,
|
||||
],
|
||||
30_000,
|
||||
),
|
||||
context.sourceCommand,
|
||||
);
|
||||
const target = parseTargetContainerEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'inspect',
|
||||
context.sourceCommand.request.expectedTargetContainerId,
|
||||
],
|
||||
30_000,
|
||||
),
|
||||
context.sourceCommand,
|
||||
context.application,
|
||||
'stopped',
|
||||
);
|
||||
const reconciliation = readTargetDataReconciliationEvidence(
|
||||
context.sourceCommand,
|
||||
context.uid,
|
||||
);
|
||||
if (
|
||||
legacy.identityDigest !==
|
||||
context.commitment.legacyContainerIdentityDigest ||
|
||||
legacy.sourceBindingDigest !==
|
||||
context.commitment.legacySourceBindingDigest ||
|
||||
target.identityDigest !== source.active.targetContainerIdentityDigest ||
|
||||
target.applicationBindingDigest !==
|
||||
source.active.targetApplicationBindingDigest ||
|
||||
reconciliation.disposition !== 'rollback_candidate' ||
|
||||
reconciliation.evidenceDigest !== source.reconciliation.evidenceDigest
|
||||
) {
|
||||
configurationError('legacy rollback stopped evidence drifted');
|
||||
}
|
||||
}
|
||||
|
||||
function result(
|
||||
context: Readonly<RollbackContext>,
|
||||
status: 'prepared' | 'existing',
|
||||
state: LocalDeploymentLegacyRollbackResult['state'],
|
||||
preparationDigest: string,
|
||||
recordDigest: string,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const head = advanceLocalCutoverInstanceHead(
|
||||
context.sourceCommand,
|
||||
context.uid,
|
||||
state,
|
||||
context.sourceCommand.request.generation,
|
||||
state === 'rollback_prepared' ? preparationDigest : recordDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: context.rollbackCommand.operation,
|
||||
status,
|
||||
state,
|
||||
cutoverId: context.sourceCommand.request.cutoverId,
|
||||
generation: context.sourceCommand.request.generation,
|
||||
preparationDigest,
|
||||
recordDigest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function requestEvidence(
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
preparationDigest: preparation.preparationDigest,
|
||||
stoppedRecordDigest: preparation.stoppedRecordDigest,
|
||||
reconciliationEvidenceDigest: preparation.reconciliationEvidenceDigest,
|
||||
legacyContainerIdentityDigest: preparation.legacyContainerIdentityDigest,
|
||||
legacySourceBindingDigest: preparation.legacySourceBindingDigest,
|
||||
targetContainerIdentityDigest: preparation.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest: preparation.targetApplicationBindingDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function verifyRequestEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): void {
|
||||
const evidence = object(record.evidence, 'legacy rollback request evidence');
|
||||
const expected = requestEvidence(preparation);
|
||||
exact(evidence, Object.keys(expected), 'legacy rollback request evidence');
|
||||
if (
|
||||
Object.entries(expected).some(([key, value]) => evidence[key] !== value)
|
||||
) {
|
||||
configurationError('legacy rollback request evidence drifted');
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeEvidence(
|
||||
request: Readonly<TargetRunJournalRecord>,
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
preparationDigest: preparation.preparationDigest,
|
||||
requestRecordDigest: request.recordDigest,
|
||||
legacyContainerIdentityDigest: preparation.legacyContainerIdentityDigest,
|
||||
legacySourceBindingDigest: preparation.legacySourceBindingDigest,
|
||||
targetContainerIdentityDigest: preparation.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest: preparation.targetApplicationBindingDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function verifyOutcomeEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
request: Readonly<TargetRunJournalRecord>,
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): void {
|
||||
const evidence = object(record.evidence, 'legacy rollback outcome evidence');
|
||||
const expected = outcomeEvidence(request, preparation);
|
||||
exact(evidence, Object.keys(expected), 'legacy rollback outcome evidence');
|
||||
if (
|
||||
Object.entries(expected).some(([key, value]) => evidence[key] !== value)
|
||||
) {
|
||||
configurationError('legacy rollback outcome evidence drifted');
|
||||
}
|
||||
}
|
||||
|
||||
function publishManual(
|
||||
context: Readonly<RollbackContext>,
|
||||
filePath: string,
|
||||
sequence: number,
|
||||
previousRecordDigest: string,
|
||||
reason:
|
||||
| 'legacy_restart_preflight_unproved'
|
||||
| 'legacy_restart_result_unproved',
|
||||
preparationDigest: string,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const record = targetRunJournalRecord(
|
||||
context.journalCommand,
|
||||
sequence,
|
||||
'manual_required',
|
||||
previousRecordDigest,
|
||||
targetRunManualEvidence(reason),
|
||||
);
|
||||
const status = publishTargetRunJournalRecord(
|
||||
rollbackContext(context),
|
||||
filePath,
|
||||
record,
|
||||
'legacy rollback manual resolution',
|
||||
);
|
||||
return result(
|
||||
context,
|
||||
status,
|
||||
'manual_required',
|
||||
preparationDigest,
|
||||
record.recordDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function replayCommit(
|
||||
context: Readonly<RollbackContext>,
|
||||
preparation: Readonly<RollbackPreparation>,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> | undefined {
|
||||
const generation = context.sourceCommand.request.generation;
|
||||
const requestPath = legacyRollbackPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'request',
|
||||
);
|
||||
if (!fs.existsSync(requestPath)) return undefined;
|
||||
const request = readTargetRunJournalRecord(
|
||||
requestPath,
|
||||
rollbackContext(context),
|
||||
{
|
||||
sequence: legacyRollbackSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['legacy_restart_requested', 'manual_required'],
|
||||
previousRecordDigest: preparation.preparationDigest,
|
||||
requestedAtMs: context.rollbackCommand.request.rollbackRequestedAtMs,
|
||||
},
|
||||
);
|
||||
if (request.state === 'manual_required') {
|
||||
verifyTargetRunManualEvidence(request);
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
'manual_required',
|
||||
preparation.preparationDigest,
|
||||
request.recordDigest,
|
||||
);
|
||||
}
|
||||
verifyRequestEvidence(request, preparation);
|
||||
const outcomePath = legacyRollbackPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'outcome',
|
||||
);
|
||||
if (!fs.existsSync(outcomePath)) return undefined;
|
||||
const outcome = readTargetRunJournalRecord(
|
||||
outcomePath,
|
||||
rollbackContext(context),
|
||||
{
|
||||
sequence: legacyRollbackSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['legacy_running', 'manual_required'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
requestedAtMs: context.rollbackCommand.request.rollbackRequestedAtMs,
|
||||
},
|
||||
);
|
||||
if (outcome.state === 'manual_required') {
|
||||
verifyTargetRunManualEvidence(outcome);
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
'manual_required',
|
||||
preparation.preparationDigest,
|
||||
outcome.recordDigest,
|
||||
);
|
||||
}
|
||||
verifyOutcomeEvidence(outcome, request, preparation);
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
'legacy_running',
|
||||
preparation.preparationDigest,
|
||||
outcome.recordDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function prepare(
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
dependencies: LocalDeploymentLegacyRollbackDependencies,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const filePath = preparationPath(context.rollbackCommand);
|
||||
if (head.state === 'rollback_prepared') {
|
||||
const preparation = parsePreparation(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
context,
|
||||
source,
|
||||
);
|
||||
if (
|
||||
head.previousHeadDigest !==
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest ||
|
||||
head.sourceRecordDigest !== preparation.preparationDigest
|
||||
) {
|
||||
configurationError('legacy rollback preparation lost the instance head');
|
||||
}
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
'rollback_prepared',
|
||||
preparation.preparationDigest,
|
||||
source.stoppedRecord.recordDigest,
|
||||
);
|
||||
}
|
||||
if (
|
||||
head.state !== 'target_stopped' ||
|
||||
head.headDigest !==
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest ||
|
||||
head.sourceRecordDigest !== source.stoppedRecord.recordDigest ||
|
||||
head.generation !== context.sourceCommand.request.generation
|
||||
) {
|
||||
configurationError(
|
||||
'legacy rollback prepare is not bound to target stopped',
|
||||
);
|
||||
}
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(context.sourceCommand.options.dockerSocketPath, context.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
stoppedObservations(context, source, runDocker);
|
||||
const preparation = preparationRecord(context, source);
|
||||
const serialized = `${JSON.stringify(preparation, null, 2)}\n`;
|
||||
const directory = path.dirname(filePath);
|
||||
const preparationEntries = fs
|
||||
.readdirSync(directory, { withFileTypes: true })
|
||||
.filter((entry) => entry.name.startsWith('rollback-'));
|
||||
if (preparationEntries.some((entry) => !entry.isFile())) {
|
||||
configurationError(
|
||||
'legacy rollback preparation directory contains an unsafe entry',
|
||||
);
|
||||
}
|
||||
if (
|
||||
preparationEntries.length >= MAX_PREPARATIONS_PER_INSTANCE &&
|
||||
!fs.existsSync(filePath)
|
||||
) {
|
||||
configurationError(
|
||||
'legacy rollback preparation retention limit is reached',
|
||||
);
|
||||
}
|
||||
preflightPublishedFile(
|
||||
filePath,
|
||||
serialized,
|
||||
0o600,
|
||||
context.uid,
|
||||
'legacy rollback preparation',
|
||||
);
|
||||
const status = publishExactFile(
|
||||
filePath,
|
||||
serialized,
|
||||
0o600,
|
||||
context.uid,
|
||||
'legacy rollback preparation',
|
||||
);
|
||||
return result(
|
||||
context,
|
||||
status,
|
||||
'rollback_prepared',
|
||||
preparation.preparationDigest,
|
||||
source.stoppedRecord.recordDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function commit(
|
||||
context: Readonly<RollbackContext>,
|
||||
source: Readonly<RollbackSource>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
dependencies: LocalDeploymentLegacyRollbackDependencies,
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const preparation = parsePreparation(
|
||||
readPrivateLocalCommandFile(preparationPath(context.rollbackCommand)),
|
||||
context,
|
||||
source,
|
||||
);
|
||||
if (
|
||||
preparation.preparationDigest !==
|
||||
context.rollbackCommand.request.expectedPreparationDigest
|
||||
) {
|
||||
configurationError('legacy rollback commit preparation is invalid');
|
||||
}
|
||||
const replay = replayCommit(context, preparation);
|
||||
if (replay !== undefined) return replay;
|
||||
if (
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested'
|
||||
) {
|
||||
configurationError('legacy rollback commit is not bound to preparation');
|
||||
}
|
||||
if (
|
||||
head.state === 'rollback_prepared' &&
|
||||
(head.sourceRecordDigest !== preparation.preparationDigest ||
|
||||
head.previousHeadDigest !==
|
||||
context.rollbackCommand.request.expectedInstanceHeadDigest)
|
||||
) {
|
||||
configurationError('legacy rollback commit lost the instance head');
|
||||
}
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(context.sourceCommand.options.dockerSocketPath, context.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
const generation = context.sourceCommand.request.generation;
|
||||
const requestPath = legacyRollbackPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'request',
|
||||
);
|
||||
let request: Readonly<TargetRunJournalRecord>;
|
||||
let shouldStart = false;
|
||||
if (fs.existsSync(requestPath)) {
|
||||
request = readTargetRunJournalRecord(
|
||||
requestPath,
|
||||
rollbackContext(context),
|
||||
{
|
||||
sequence: legacyRollbackSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['legacy_restart_requested'],
|
||||
previousRecordDigest: preparation.preparationDigest,
|
||||
requestedAtMs: context.rollbackCommand.request.rollbackRequestedAtMs,
|
||||
},
|
||||
);
|
||||
verifyRequestEvidence(request, preparation);
|
||||
advanceLocalCutoverInstanceHead(
|
||||
context.sourceCommand,
|
||||
context.uid,
|
||||
'legacy_restart_requested',
|
||||
generation,
|
||||
request.recordDigest,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
stoppedObservations(context, source, runDocker);
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
requestPath,
|
||||
legacyRollbackSequence(generation, 'request'),
|
||||
preparation.preparationDigest,
|
||||
'legacy_restart_preflight_unproved',
|
||||
preparation.preparationDigest,
|
||||
);
|
||||
}
|
||||
request = targetRunJournalRecord(
|
||||
context.journalCommand,
|
||||
legacyRollbackSequence(generation, 'request'),
|
||||
'legacy_restart_requested',
|
||||
preparation.preparationDigest,
|
||||
requestEvidence(preparation),
|
||||
);
|
||||
publishTargetRunJournalRecord(
|
||||
rollbackContext(context),
|
||||
requestPath,
|
||||
request,
|
||||
'legacy rollback start barrier',
|
||||
);
|
||||
advanceLocalCutoverInstanceHead(
|
||||
context.sourceCommand,
|
||||
context.uid,
|
||||
'legacy_restart_requested',
|
||||
generation,
|
||||
request.recordDigest,
|
||||
);
|
||||
shouldStart = true;
|
||||
dependencies.afterBarrier?.();
|
||||
}
|
||||
if (shouldStart) {
|
||||
try {
|
||||
stoppedObservations(context, source, runDocker);
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
legacyRollbackPhasePath(context.journal, generation, 'outcome'),
|
||||
legacyRollbackSequence(generation, 'outcome'),
|
||||
request.recordDigest,
|
||||
'legacy_restart_result_unproved',
|
||||
preparation.preparationDigest,
|
||||
);
|
||||
}
|
||||
try {
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'start',
|
||||
context.sourceCommand.request.expectedLegacyContainerId,
|
||||
],
|
||||
45_000,
|
||||
);
|
||||
} catch {
|
||||
// The exact running inspection below resolves a lost start response.
|
||||
}
|
||||
dependencies.afterStart?.();
|
||||
}
|
||||
const outcomePath = legacyRollbackPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'outcome',
|
||||
);
|
||||
try {
|
||||
const legacy = parseActiveLegacyEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'inspect',
|
||||
context.sourceCommand.request.expectedLegacyContainerId,
|
||||
],
|
||||
30_000,
|
||||
),
|
||||
context.sourceCommand,
|
||||
);
|
||||
const target = parseTargetContainerEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'inspect',
|
||||
context.sourceCommand.request.expectedTargetContainerId,
|
||||
],
|
||||
30_000,
|
||||
),
|
||||
context.sourceCommand,
|
||||
context.application,
|
||||
'stopped',
|
||||
);
|
||||
if (
|
||||
legacy.identityDigest !==
|
||||
context.commitment.legacyContainerIdentityDigest ||
|
||||
legacy.sourceBindingDigest !==
|
||||
context.commitment.legacySourceBindingDigest ||
|
||||
target.identityDigest !== source.active.targetContainerIdentityDigest ||
|
||||
target.applicationBindingDigest !==
|
||||
source.active.targetApplicationBindingDigest
|
||||
) {
|
||||
configurationError('legacy rollback outcome identity drifted');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
outcomePath,
|
||||
legacyRollbackSequence(generation, 'outcome'),
|
||||
request.recordDigest,
|
||||
'legacy_restart_result_unproved',
|
||||
preparation.preparationDigest,
|
||||
);
|
||||
}
|
||||
const outcome = targetRunJournalRecord(
|
||||
context.journalCommand,
|
||||
legacyRollbackSequence(generation, 'outcome'),
|
||||
'legacy_running',
|
||||
request.recordDigest,
|
||||
outcomeEvidence(request, preparation),
|
||||
);
|
||||
const status = publishTargetRunJournalRecord(
|
||||
rollbackContext(context),
|
||||
outcomePath,
|
||||
outcome,
|
||||
'legacy rollback running commitment',
|
||||
);
|
||||
return result(
|
||||
context,
|
||||
status,
|
||||
'legacy_running',
|
||||
preparation.preparationDigest,
|
||||
outcome.recordDigest,
|
||||
);
|
||||
}
|
||||
|
||||
export function runLocalDeploymentLegacyRollback(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentLegacyRollbackDependencies = {},
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const rollbackCommand = normalizeLocalDeploymentLegacyRollbackCommand(input);
|
||||
const sourceCommand = legacyRollbackTargetRunCommand(rollbackCommand);
|
||||
const identity = currentIdentity();
|
||||
const journal = path.dirname(legacyCommitmentPath(sourceCommand));
|
||||
validatePrivateDirectory(
|
||||
sourceCommand.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
path.join(sourceCommand.options.deploymentRoot, 'service'),
|
||||
identity.uid,
|
||||
'serviceDescriptorRoot',
|
||||
);
|
||||
validatePrivateDirectory(journal, identity.uid, 'cutoverJournal');
|
||||
validatePrivateDirectory(
|
||||
localCutoverInstanceDirectory(
|
||||
sourceCommand.options.deploymentRoot,
|
||||
sourceCommand.request.instanceId,
|
||||
),
|
||||
identity.uid,
|
||||
'cutoverInstanceDirectory',
|
||||
);
|
||||
const context = Object.freeze({
|
||||
rollbackCommand,
|
||||
command: sourceCommand,
|
||||
sourceCommand,
|
||||
journalCommand: journalCommand(
|
||||
sourceCommand,
|
||||
rollbackCommand.request.rollbackRequestedAtMs,
|
||||
),
|
||||
journal,
|
||||
uid: identity.uid,
|
||||
commitment: readLegacySilenceEvidence(sourceCommand),
|
||||
application: readTargetApplicationBinding(sourceCommand),
|
||||
});
|
||||
const source = readRollbackSource(context);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
sourceCommand.options.deploymentRoot,
|
||||
sourceCommand.request.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
if (
|
||||
head.profile !== sourceCommand.request.profile ||
|
||||
head.cutoverId !== sourceCommand.request.cutoverId ||
|
||||
head.activationDigest !== sourceCommand.request.expectedActivationDigest ||
|
||||
head.generation !== sourceCommand.request.generation
|
||||
) {
|
||||
configurationError('legacy rollback is not bound to the instance lineage');
|
||||
}
|
||||
return rollbackCommand.operation ===
|
||||
'local.deployment.cutover.legacy-rollback-prepare'
|
||||
? prepare(context, source, head, dependencies)
|
||||
: commit(context, source, head, dependencies);
|
||||
}
|
||||
|
||||
export function runLocalDeploymentLegacyRollbackCommandFile(
|
||||
filePath: string,
|
||||
expectedOperation?: LocalDeploymentLegacyRollbackCommand['operation'],
|
||||
): Readonly<LocalDeploymentLegacyRollbackResult> {
|
||||
const input = readPrivateLocalCommandFile(filePath);
|
||||
if (
|
||||
expectedOperation !== undefined &&
|
||||
(!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
(input as Record<string, unknown>).operation !== expectedOperation)
|
||||
) {
|
||||
configurationError(
|
||||
'legacy rollback command does not match the CLI operation',
|
||||
);
|
||||
}
|
||||
return runLocalDeploymentLegacyRollback(input);
|
||||
}
|
||||
|
||||
export { EMPTY_ROLLBACK_PREPARATION_DIGEST };
|
||||
@@ -0,0 +1,169 @@
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import {
|
||||
normalizeLocalDeploymentTargetStopCommand,
|
||||
targetStopRunCommand,
|
||||
type LocalDeploymentTargetStopCommand,
|
||||
} from './targetStopContract';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
export const EMPTY_ROLLBACK_PREPARATION_DIGEST = '0'.repeat(64);
|
||||
|
||||
export type LocalDeploymentLegacyRollbackOperation =
|
||||
| 'local.deployment.cutover.legacy-rollback-prepare'
|
||||
| 'local.deployment.cutover.legacy-rollback-commit';
|
||||
|
||||
export interface LocalDeploymentLegacyRollbackCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentLegacyRollbackOperation;
|
||||
readonly options: LocalDeploymentTargetStopCommand['options'];
|
||||
readonly request: LocalDeploymentTargetStopCommand['request'] &
|
||||
Readonly<{
|
||||
expectedInstanceHeadDigest: string;
|
||||
expectedStoppedRecordDigest: string;
|
||||
expectedPreparationDigest: string;
|
||||
rollbackRequestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentLegacyRollbackResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentLegacyRollbackOperation;
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'rollback_prepared' | 'legacy_running' | 'manual_required';
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly preparationDigest: string;
|
||||
readonly recordDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentLegacyRollbackCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentLegacyRollbackCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
(command.operation !== 'local.deployment.cutover.legacy-rollback-prepare' &&
|
||||
command.operation !== 'local.deployment.cutover.legacy-rollback-commit')
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'legacy rollback schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'activationPath',
|
||||
'applicationConfigPath',
|
||||
'cutoverId',
|
||||
'expectedActivationDigest',
|
||||
'expectedInstanceHeadDigest',
|
||||
'expectedLegacyCommitmentDigest',
|
||||
'expectedLegacyContainerId',
|
||||
'expectedLegacyDatabasePath',
|
||||
'expectedPreparationDigest',
|
||||
'expectedStoppedRecordDigest',
|
||||
'expectedTargetApplicationConfigPath',
|
||||
'expectedTargetCommitmentPath',
|
||||
'expectedTargetContainerId',
|
||||
'expectedTargetImage',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacySourcePath',
|
||||
'manifestPath',
|
||||
'profile',
|
||||
'recoveryPath',
|
||||
'requestedAtMs',
|
||||
'rollbackRequestedAtMs',
|
||||
'targetDatabasePath',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const {
|
||||
expectedInstanceHeadDigest,
|
||||
expectedStoppedRecordDigest,
|
||||
expectedPreparationDigest,
|
||||
rollbackRequestedAtMs,
|
||||
...targetRequest
|
||||
} = request;
|
||||
const normalized = normalizeLocalDeploymentTargetStopCommand({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.cutover.target-stop',
|
||||
options: command.options,
|
||||
request: targetRequest,
|
||||
});
|
||||
if (
|
||||
typeof expectedInstanceHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(expectedInstanceHeadDigest) ||
|
||||
typeof expectedStoppedRecordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(expectedStoppedRecordDigest) ||
|
||||
typeof expectedPreparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(expectedPreparationDigest) ||
|
||||
(command.operation ===
|
||||
'local.deployment.cutover.legacy-rollback-commit') ===
|
||||
(expectedPreparationDigest === EMPTY_ROLLBACK_PREPARATION_DIGEST) ||
|
||||
!Number.isSafeInteger(rollbackRequestedAtMs) ||
|
||||
(rollbackRequestedAtMs as number) < 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'legacy rollback request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
options: normalized.options,
|
||||
request: Object.freeze({
|
||||
...normalized.request,
|
||||
expectedInstanceHeadDigest,
|
||||
expectedStoppedRecordDigest,
|
||||
expectedPreparationDigest,
|
||||
rollbackRequestedAtMs: rollbackRequestedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function legacyRollbackTargetRunCommand(
|
||||
command: Readonly<LocalDeploymentLegacyRollbackCommand>,
|
||||
) {
|
||||
return targetStopRunCommand({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.cutover.target-stop',
|
||||
options: command.options,
|
||||
request: command.request,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../foundation/docker';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import {
|
||||
normalizeLocalDeploymentLegacyStopCommand,
|
||||
type LocalDeploymentLegacyStopCommand,
|
||||
type LocalDeploymentLegacyStopResult,
|
||||
} from './contract';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
claimLocalCutoverInstance,
|
||||
} from './instanceLineage';
|
||||
|
||||
const INTENT_SCHEMA = 'qinglong3-local-cutover-journal-record';
|
||||
const COMMITMENT_KIND = 'qinglong3-local-legacy-silence-commitment';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_CUTOVERS = 64;
|
||||
|
||||
export interface LocalDeploymentLegacyStopDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function textDigest(value: string): string {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyActivation(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
): void {
|
||||
let sourceStat: fs.Stats;
|
||||
try {
|
||||
sourceStat = fs.lstatSync(command.request.legacySourcePath);
|
||||
} catch (error) {
|
||||
configurationError('legacy source is unavailable', error);
|
||||
}
|
||||
if (
|
||||
!sourceStat.isFile() ||
|
||||
sourceStat.isSymbolicLink() ||
|
||||
fs.realpathSync(command.request.legacySourcePath) !==
|
||||
command.request.legacySourcePath
|
||||
) {
|
||||
configurationError('legacy source must be a canonical regular file');
|
||||
}
|
||||
const activation = object(
|
||||
readPrivateLocalCommandFile(command.request.activationPath),
|
||||
'activation',
|
||||
);
|
||||
const expectedKeys = [
|
||||
'activationDigest',
|
||||
'adoptionManifestDigest',
|
||||
'createdAtMs',
|
||||
'kind',
|
||||
'planDigest',
|
||||
'profile',
|
||||
'recoverySha256',
|
||||
'schemaVersion',
|
||||
'sourcePathDigest',
|
||||
'state',
|
||||
'targetDevice',
|
||||
'targetInode',
|
||||
'targetPathDigest',
|
||||
'targetSha256',
|
||||
];
|
||||
exact(activation, expectedKeys, 'activation');
|
||||
if (
|
||||
activation.schemaVersion !== 1 ||
|
||||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
||||
activation.state !== 'prepared' ||
|
||||
activation.profile !== command.request.profile ||
|
||||
activation.sourcePathDigest !==
|
||||
textDigest(command.request.legacySourcePath) ||
|
||||
activation.activationDigest !== command.request.expectedActivationDigest
|
||||
) {
|
||||
configurationError('activation does not match the cutover request');
|
||||
}
|
||||
const { activationDigest, ...payload } = activation;
|
||||
if (
|
||||
typeof activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activationDigest) ||
|
||||
digest(payload) !== activationDigest
|
||||
) {
|
||||
configurationError('activation digest does not match');
|
||||
}
|
||||
}
|
||||
|
||||
function cutoverDirectory(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
uid: number,
|
||||
): string {
|
||||
const serviceRoot = path.join(command.options.deploymentRoot, 'service');
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(serviceRoot, uid, 'serviceDescriptorRoot');
|
||||
const catalog = path.join(serviceRoot, 'cutovers');
|
||||
ensurePrivateDirectory(catalog, uid, 'cutoverRoot');
|
||||
const entries = fs.readdirSync(catalog, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
configurationError('cutover catalog contains drift');
|
||||
}
|
||||
}
|
||||
const target = path.join(catalog, command.request.cutoverId);
|
||||
if (entries.length >= MAX_CUTOVERS && !fs.existsSync(target)) {
|
||||
configurationError('cutover retention limit is reached');
|
||||
}
|
||||
ensurePrivateDirectory(target, uid, 'cutoverJournal');
|
||||
return target;
|
||||
}
|
||||
|
||||
function endpointDigest(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
): string {
|
||||
return digest({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
});
|
||||
}
|
||||
|
||||
function intentRecord(command: Readonly<LocalDeploymentLegacyStopCommand>) {
|
||||
const payload = Object.freeze({
|
||||
schema: INTENT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
sequence: 1 as const,
|
||||
state: 'legacy_stop_requested' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
activationDigest: command.request.expectedActivationDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
controller: Object.freeze({
|
||||
kind: 'docker' as const,
|
||||
endpointDigest: endpointDigest(command),
|
||||
legacyContainerId: command.request.expectedLegacyContainerId,
|
||||
requestedSourceBindingDigest: digest({
|
||||
legacySourcePath: command.request.legacySourcePath,
|
||||
legacyDatabasePath: command.request.expectedLegacyDatabasePath,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return Object.freeze({ ...payload, recordDigest: digest(payload) });
|
||||
}
|
||||
|
||||
function parseStoppedContainer(
|
||||
output: string,
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
): Readonly<{ identityDigest: string; sourceBindingDigest: string }> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (error) {
|
||||
configurationError('legacy container inspection is invalid', error);
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
||||
configurationError('legacy container inspection count is invalid');
|
||||
}
|
||||
const container = object(parsed[0], 'legacy container');
|
||||
const state = object(container.State, 'legacy container state');
|
||||
const hostConfig = object(
|
||||
container.HostConfig,
|
||||
'legacy container host config',
|
||||
);
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'legacy container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'legacy container config');
|
||||
if (
|
||||
container.Id !== command.request.expectedLegacyContainerId ||
|
||||
state.Running !== false ||
|
||||
state.Restarting !== false ||
|
||||
state.Paused !== false ||
|
||||
state.Pid !== 0 ||
|
||||
(state.Status !== 'exited' && state.Status !== 'dead') ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no') ||
|
||||
typeof container.Created !== 'string' ||
|
||||
container.Created.length < 1 ||
|
||||
container.Created.length > 128 ||
|
||||
typeof container.Name !== 'string' ||
|
||||
container.Name.length < 2 ||
|
||||
container.Name.length > 256 ||
|
||||
typeof config.Image !== 'string' ||
|
||||
config.Image.length < 1 ||
|
||||
config.Image.length > 512
|
||||
) {
|
||||
configurationError(
|
||||
'legacy container is not durably stopped with restart disabled',
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(container.Mounts)) {
|
||||
configurationError('legacy container mount evidence is unavailable');
|
||||
}
|
||||
const matchingMounts = container.Mounts.flatMap((value) => {
|
||||
const mount = object(value, 'legacy container mount');
|
||||
if (
|
||||
mount.Type !== 'bind' ||
|
||||
typeof mount.Source !== 'string' ||
|
||||
typeof mount.Destination !== 'string' ||
|
||||
typeof mount.RW !== 'boolean' ||
|
||||
!path.isAbsolute(mount.Source) ||
|
||||
!path.isAbsolute(mount.Destination) ||
|
||||
path.normalize(mount.Source) !== mount.Source ||
|
||||
path.normalize(mount.Destination) !== mount.Destination
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const relative = path.relative(
|
||||
mount.Source,
|
||||
command.request.legacySourcePath,
|
||||
);
|
||||
if (
|
||||
relative.startsWith('..') ||
|
||||
path.isAbsolute(relative) ||
|
||||
path.join(mount.Destination, relative) !==
|
||||
command.request.expectedLegacyDatabasePath
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
Object.freeze({
|
||||
source: mount.Source,
|
||||
destination: mount.Destination,
|
||||
readWrite: mount.RW,
|
||||
}),
|
||||
];
|
||||
});
|
||||
if (matchingMounts.length !== 1) {
|
||||
configurationError(
|
||||
'legacy container does not have one exact activation source binding',
|
||||
);
|
||||
}
|
||||
const matchingMount = matchingMounts[0]!;
|
||||
return Object.freeze({
|
||||
identityDigest: digest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
}),
|
||||
sourceBindingDigest: digest({
|
||||
sourcePathDigest: textDigest(command.request.legacySourcePath),
|
||||
databasePathDigest: textDigest(
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
),
|
||||
mountSourceDigest: textDigest(matchingMount.source),
|
||||
mountDestinationDigest: textDigest(matchingMount.destination),
|
||||
readWrite: matchingMount.readWrite,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function commitmentRecord(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
previousRecordDigest: string,
|
||||
legacyContainerIdentityDigest: string,
|
||||
legacySourceBindingDigest: string,
|
||||
) {
|
||||
const payload = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: COMMITMENT_KIND,
|
||||
state: 'legacy_stopped' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
activationDigest: command.request.expectedActivationDigest,
|
||||
previousRecordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
observedAtMs: command.request.requestedAtMs,
|
||||
controller: Object.freeze({
|
||||
kind: 'docker' as const,
|
||||
endpointDigest: endpointDigest(command),
|
||||
legacyContainerId: command.request.expectedLegacyContainerId,
|
||||
legacyContainerIdentityDigest,
|
||||
legacySourceBindingDigest,
|
||||
}),
|
||||
});
|
||||
return Object.freeze({ ...payload, commitmentDigest: digest(payload) });
|
||||
}
|
||||
|
||||
function verifyExistingCommitment(
|
||||
value: unknown,
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
previousRecordDigest: string,
|
||||
): Readonly<{ commitmentDigest: string }> {
|
||||
const commitment = object(value, 'legacy silence commitment');
|
||||
exact(
|
||||
commitment,
|
||||
[
|
||||
'activationDigest',
|
||||
'commitmentDigest',
|
||||
'controller',
|
||||
'cutoverId',
|
||||
'instanceId',
|
||||
'kind',
|
||||
'observedAtMs',
|
||||
'previousRecordDigest',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'legacy silence commitment',
|
||||
);
|
||||
const controller = object(commitment.controller, 'commitment controller');
|
||||
exact(
|
||||
controller,
|
||||
[
|
||||
'endpointDigest',
|
||||
'kind',
|
||||
'legacyContainerId',
|
||||
'legacyContainerIdentityDigest',
|
||||
'legacySourceBindingDigest',
|
||||
],
|
||||
'commitment controller',
|
||||
);
|
||||
const { commitmentDigest, ...payload } = commitment;
|
||||
if (
|
||||
commitment.schemaVersion !== 1 ||
|
||||
commitment.kind !== COMMITMENT_KIND ||
|
||||
commitment.state !== 'legacy_stopped' ||
|
||||
commitment.cutoverId !== command.request.cutoverId ||
|
||||
commitment.profile !== command.request.profile ||
|
||||
commitment.instanceId !== command.request.instanceId ||
|
||||
commitment.activationDigest !== command.request.expectedActivationDigest ||
|
||||
commitment.previousRecordDigest !== previousRecordDigest ||
|
||||
commitment.requestedAtMs !== command.request.requestedAtMs ||
|
||||
commitment.observedAtMs !== command.request.requestedAtMs ||
|
||||
controller.kind !== 'docker' ||
|
||||
controller.endpointDigest !== endpointDigest(command) ||
|
||||
controller.legacyContainerId !==
|
||||
command.request.expectedLegacyContainerId ||
|
||||
typeof controller.legacyContainerIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(controller.legacyContainerIdentityDigest) ||
|
||||
typeof controller.legacySourceBindingDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(controller.legacySourceBindingDigest) ||
|
||||
typeof commitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(commitmentDigest) ||
|
||||
digest(payload) !== commitmentDigest
|
||||
) {
|
||||
configurationError('legacy silence commitment drifted');
|
||||
}
|
||||
return Object.freeze({ commitmentDigest });
|
||||
}
|
||||
|
||||
function docker(
|
||||
command: Readonly<LocalDeploymentLegacyStopCommand>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
): string {
|
||||
return runDocker({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
args,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function stopLegacyDockerForLocalDeployment(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentLegacyStopDependencies = {},
|
||||
): Readonly<LocalDeploymentLegacyStopResult> {
|
||||
const command = normalizeLocalDeploymentLegacyStopCommand(input);
|
||||
const identity = currentIdentity();
|
||||
verifyActivation(command);
|
||||
const intent = intentRecord(command);
|
||||
claimLocalCutoverInstance(command, identity.uid, intent.recordDigest);
|
||||
const journal = cutoverDirectory(command, identity.uid);
|
||||
const intentPath = path.join(journal, '0001-legacy-stop-requested.json');
|
||||
const commitmentPath = path.join(journal, '0002-legacy-stopped.json');
|
||||
const intentContents = `${JSON.stringify(intent, null, 2)}\n`;
|
||||
preflightPublishedFile(
|
||||
intentPath,
|
||||
intentContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy stop intent',
|
||||
);
|
||||
publishExactFile(
|
||||
intentPath,
|
||||
intentContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy stop intent',
|
||||
);
|
||||
if (fs.existsSync(commitmentPath)) {
|
||||
const existing = verifyExistingCommitment(
|
||||
readPrivateLocalCommandFile(commitmentPath),
|
||||
command,
|
||||
intent.recordDigest,
|
||||
);
|
||||
advanceLocalCutoverInstanceHead(
|
||||
command,
|
||||
identity.uid,
|
||||
'legacy_stopped',
|
||||
0,
|
||||
existing.commitmentDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: 'existing' as const,
|
||||
state: 'legacy_stopped' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
commitmentDigest: existing.commitmentDigest,
|
||||
});
|
||||
}
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
docker(
|
||||
command,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'update',
|
||||
'--restart',
|
||||
'no',
|
||||
command.request.expectedLegacyContainerId,
|
||||
],
|
||||
30_000,
|
||||
);
|
||||
docker(
|
||||
command,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'stop',
|
||||
'--time',
|
||||
'30',
|
||||
command.request.expectedLegacyContainerId,
|
||||
],
|
||||
45_000,
|
||||
);
|
||||
const stopped = parseStoppedContainer(
|
||||
docker(
|
||||
command,
|
||||
runDocker,
|
||||
['container', 'inspect', command.request.expectedLegacyContainerId],
|
||||
30_000,
|
||||
),
|
||||
command,
|
||||
);
|
||||
const commitment = commitmentRecord(
|
||||
command,
|
||||
intent.recordDigest,
|
||||
stopped.identityDigest,
|
||||
stopped.sourceBindingDigest,
|
||||
);
|
||||
const commitmentContents = `${JSON.stringify(commitment, null, 2)}\n`;
|
||||
preflightPublishedFile(
|
||||
commitmentPath,
|
||||
commitmentContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy silence commitment',
|
||||
);
|
||||
publishExactFile(
|
||||
commitmentPath,
|
||||
commitmentContents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy silence commitment',
|
||||
);
|
||||
advanceLocalCutoverInstanceHead(
|
||||
command,
|
||||
identity.uid,
|
||||
'legacy_stopped',
|
||||
0,
|
||||
commitment.commitmentDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: 'prepared' as const,
|
||||
state: 'legacy_stopped' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
commitmentDigest: commitment.commitmentDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function stopLegacyDockerForLocalDeploymentCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalDeploymentLegacyStopResult> {
|
||||
return stopLegacyDockerForLocalDeployment(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
);
|
||||
}
|
||||
+610
@@ -0,0 +1,610 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../../foundation/docker';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../../foundation/files';
|
||||
import {
|
||||
authorizeResolvedLocalCutoverInstance,
|
||||
localCutoverInstanceDirectory,
|
||||
readLocalCutoverInstanceHead,
|
||||
type LocalCutoverInstanceHead,
|
||||
type LocalCutoverIdentity,
|
||||
} from '../instanceLineage';
|
||||
import {
|
||||
EMPTY_RESOLUTION_DIGEST,
|
||||
normalizeLocalDeploymentCutoverManualCommand,
|
||||
type LocalDeploymentCutoverManualCommand,
|
||||
} from './manualResolutionContract';
|
||||
import { cutoverDigest } from '../targetEvidence';
|
||||
|
||||
const PREPARATION_SCHEMA = 'qinglong3-local-cutover-manual-resolution';
|
||||
const JOURNAL_SCHEMA = 'qinglong3-local-cutover-journal-record';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_JOURNAL_FILES = 64;
|
||||
|
||||
export type LocalDeploymentCutoverObservationState =
|
||||
| 'stopped'
|
||||
| 'running'
|
||||
| 'unknown';
|
||||
|
||||
export interface LocalDeploymentCutoverManualResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentCutoverManualCommand['operation'];
|
||||
readonly status: 'prepared' | 'existing' | 'observed';
|
||||
readonly state:
|
||||
| 'manual_diagnosed'
|
||||
| 'resolution_prepared'
|
||||
| 'resolution_authorized';
|
||||
readonly currentCutoverId: string;
|
||||
readonly nextCutoverId: string;
|
||||
readonly legacyState?: LocalDeploymentCutoverObservationState;
|
||||
readonly targetState?: LocalDeploymentCutoverObservationState;
|
||||
readonly legacyObservationDigest?: string;
|
||||
readonly targetObservationDigest?: string;
|
||||
readonly preparationDigest?: string;
|
||||
readonly instanceHeadDigest?: string;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentCutoverManualDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
}
|
||||
|
||||
interface ContainerObservation {
|
||||
readonly state: LocalDeploymentCutoverObservationState;
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
interface ResolutionPreparation {
|
||||
readonly schema: typeof PREPARATION_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'resolution_prepared';
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly currentCutoverId: string;
|
||||
readonly nextCutoverId: string;
|
||||
readonly currentActivationDigest: string;
|
||||
readonly nextActivationDigest: string;
|
||||
readonly expectedInstanceHeadDigest: string;
|
||||
readonly expectedManualRecordDigest: string;
|
||||
readonly expectedLegacyContainerId: string;
|
||||
readonly expectedTargetContainerId: string;
|
||||
readonly legacyObservationDigest: string;
|
||||
readonly targetObservationDigest: string;
|
||||
readonly requestedAtMs: number;
|
||||
readonly preparationDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function currentIdentityFor(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
): Readonly<LocalCutoverIdentity> {
|
||||
return Object.freeze({
|
||||
options: Object.freeze({ deploymentRoot: command.options.deploymentRoot }),
|
||||
request: Object.freeze({
|
||||
cutoverId: command.request.currentCutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
expectedActivationDigest: command.request.currentActivationDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function nextIdentityFor(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
): Readonly<LocalCutoverIdentity> {
|
||||
return Object.freeze({
|
||||
options: Object.freeze({ deploymentRoot: command.options.deploymentRoot }),
|
||||
request: Object.freeze({
|
||||
cutoverId: command.request.nextCutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
expectedActivationDigest: command.request.nextActivationDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function manualHead(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
uid: number,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
head.state !== 'manual_required' ||
|
||||
head.profile !== command.request.profile ||
|
||||
head.cutoverId !== command.request.currentCutoverId ||
|
||||
head.activationDigest !== command.request.currentActivationDigest ||
|
||||
head.headDigest !== command.request.expectedInstanceHeadDigest ||
|
||||
head.sourceRecordDigest !== command.request.expectedManualRecordDigest
|
||||
) {
|
||||
configurationError('manual resolution does not match the instance head');
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
function verifyManualJournalRecord(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
): void {
|
||||
const journal = path.join(
|
||||
command.options.deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
command.request.currentCutoverId,
|
||||
);
|
||||
validatePrivateDirectory(journal, currentIdentity().uid, 'cutoverJournal');
|
||||
const entries = fs.readdirSync(journal, { withFileTypes: true });
|
||||
if (entries.length > MAX_JOURNAL_FILES) {
|
||||
configurationError('cutover journal retention limit is exceeded');
|
||||
}
|
||||
let matched = false;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) {
|
||||
configurationError('cutover journal contains drift');
|
||||
}
|
||||
const value = readPrivateLocalCommandFile(path.join(journal, entry.name));
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(value as Record<string, unknown>).recordDigest !==
|
||||
command.request.expectedManualRecordDigest
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const record = object(value, 'manual-required journal record');
|
||||
const { recordDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== JOURNAL_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.state !== 'manual_required' ||
|
||||
record.cutoverId !== command.request.currentCutoverId ||
|
||||
record.profile !== command.request.profile ||
|
||||
record.instanceId !== command.request.instanceId ||
|
||||
record.activationDigest !== command.request.currentActivationDigest ||
|
||||
record.generation !== head.generation ||
|
||||
typeof recordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(recordDigest) ||
|
||||
cutoverDigest(payload) !== recordDigest
|
||||
) {
|
||||
configurationError('manual-required journal record drifted');
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
if (!matched)
|
||||
configurationError('manual-required journal record is unavailable');
|
||||
}
|
||||
|
||||
function parseContainerObservation(
|
||||
output: string,
|
||||
expectedContainerId: string,
|
||||
): Readonly<ContainerObservation> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (error) {
|
||||
configurationError('container inspection is invalid', error);
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
||||
configurationError('container inspection count is invalid');
|
||||
}
|
||||
const container = object(parsed[0], 'container');
|
||||
const state = object(container.State, 'container state');
|
||||
const hostConfig = object(container.HostConfig, 'container host config');
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'container config');
|
||||
if (
|
||||
container.Id !== expectedContainerId ||
|
||||
typeof container.Created !== 'string' ||
|
||||
container.Created.length < 1 ||
|
||||
container.Created.length > 128 ||
|
||||
typeof container.Name !== 'string' ||
|
||||
container.Name.length < 2 ||
|
||||
container.Name.length > 256 ||
|
||||
typeof config.Image !== 'string' ||
|
||||
config.Image.length < 1 ||
|
||||
config.Image.length > 512 ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no')
|
||||
) {
|
||||
configurationError('container inspection identity is invalid');
|
||||
}
|
||||
const stopped =
|
||||
state.Running === false &&
|
||||
state.Restarting === false &&
|
||||
state.Paused === false &&
|
||||
state.Pid === 0 &&
|
||||
(state.Status === 'exited' || state.Status === 'dead');
|
||||
const running =
|
||||
state.Running === true &&
|
||||
state.Restarting === false &&
|
||||
state.Paused === false &&
|
||||
Number.isSafeInteger(state.Pid) &&
|
||||
(state.Pid as number) > 0 &&
|
||||
state.Status === 'running';
|
||||
if (!stopped && !running) {
|
||||
configurationError('container state is ambiguous');
|
||||
}
|
||||
const observationState = stopped ? 'stopped' : 'running';
|
||||
return Object.freeze({
|
||||
state: observationState,
|
||||
digest: cutoverDigest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
state: observationState,
|
||||
restartPolicy: restartPolicy.Name,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function inspectContainer(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
containerId: string,
|
||||
): Readonly<ContainerObservation> {
|
||||
try {
|
||||
return parseContainerObservation(
|
||||
runDocker({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
args: ['container', 'inspect', containerId],
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
containerId,
|
||||
);
|
||||
} catch {
|
||||
return Object.freeze({
|
||||
state: 'unknown' as const,
|
||||
digest: cutoverDigest({ containerId, state: 'unknown' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function observations(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
dependencies: LocalDeploymentCutoverManualDependencies,
|
||||
uid: number,
|
||||
): Readonly<{ legacy: ContainerObservation; target: ContainerObservation }> {
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
return Object.freeze({
|
||||
legacy: inspectContainer(
|
||||
command,
|
||||
runDocker,
|
||||
command.request.expectedLegacyContainerId,
|
||||
),
|
||||
target: inspectContainer(
|
||||
command,
|
||||
runDocker,
|
||||
command.request.expectedTargetContainerId,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function preparationPath(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
): string {
|
||||
return path.join(
|
||||
command.options.deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
command.request.currentCutoverId,
|
||||
`manual-resolution-${cutoverDigest(command.request.nextCutoverId).slice(
|
||||
0,
|
||||
32,
|
||||
)}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function preparationRecord(
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
observation: Readonly<{
|
||||
legacy: ContainerObservation;
|
||||
target: ContainerObservation;
|
||||
}>,
|
||||
): Readonly<ResolutionPreparation> {
|
||||
const payload = Object.freeze({
|
||||
schema: PREPARATION_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'resolution_prepared' as const,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
currentCutoverId: command.request.currentCutoverId,
|
||||
nextCutoverId: command.request.nextCutoverId,
|
||||
currentActivationDigest: command.request.currentActivationDigest,
|
||||
nextActivationDigest: command.request.nextActivationDigest,
|
||||
expectedInstanceHeadDigest: command.request.expectedInstanceHeadDigest,
|
||||
expectedManualRecordDigest: command.request.expectedManualRecordDigest,
|
||||
expectedLegacyContainerId: command.request.expectedLegacyContainerId,
|
||||
expectedTargetContainerId: command.request.expectedTargetContainerId,
|
||||
legacyObservationDigest: observation.legacy.digest,
|
||||
targetObservationDigest: observation.target.digest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
preparationDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function parsePreparation(
|
||||
value: unknown,
|
||||
command: Readonly<LocalDeploymentCutoverManualCommand>,
|
||||
): Readonly<ResolutionPreparation> {
|
||||
const record = object(value, 'manual resolution preparation');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'currentActivationDigest',
|
||||
'currentCutoverId',
|
||||
'expectedInstanceHeadDigest',
|
||||
'expectedLegacyContainerId',
|
||||
'expectedManualRecordDigest',
|
||||
'expectedTargetContainerId',
|
||||
'instanceId',
|
||||
'legacyObservationDigest',
|
||||
'nextActivationDigest',
|
||||
'nextCutoverId',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
'targetObservationDigest',
|
||||
],
|
||||
'manual resolution preparation',
|
||||
);
|
||||
const { preparationDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== PREPARATION_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.state !== 'resolution_prepared' ||
|
||||
record.profile !== command.request.profile ||
|
||||
record.instanceId !== command.request.instanceId ||
|
||||
record.currentCutoverId !== command.request.currentCutoverId ||
|
||||
record.nextCutoverId !== command.request.nextCutoverId ||
|
||||
record.currentActivationDigest !==
|
||||
command.request.currentActivationDigest ||
|
||||
record.nextActivationDigest !== command.request.nextActivationDigest ||
|
||||
record.expectedInstanceHeadDigest !==
|
||||
command.request.expectedInstanceHeadDigest ||
|
||||
record.expectedManualRecordDigest !==
|
||||
command.request.expectedManualRecordDigest ||
|
||||
record.expectedLegacyContainerId !==
|
||||
command.request.expectedLegacyContainerId ||
|
||||
record.expectedTargetContainerId !==
|
||||
command.request.expectedTargetContainerId ||
|
||||
typeof record.legacyObservationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.legacyObservationDigest) ||
|
||||
typeof record.targetObservationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.targetObservationDigest) ||
|
||||
typeof preparationDigest !== 'string' ||
|
||||
preparationDigest !== command.request.expectedPreparationDigest ||
|
||||
cutoverDigest(payload) !== preparationDigest
|
||||
) {
|
||||
configurationError('manual resolution preparation drifted');
|
||||
}
|
||||
return record as unknown as Readonly<ResolutionPreparation>;
|
||||
}
|
||||
|
||||
function baseResult(command: Readonly<LocalDeploymentCutoverManualCommand>) {
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
currentCutoverId: command.request.currentCutoverId,
|
||||
nextCutoverId: command.request.nextCutoverId,
|
||||
};
|
||||
}
|
||||
|
||||
export function runLocalDeploymentCutoverManualCommand(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentCutoverManualDependencies = {},
|
||||
): Readonly<LocalDeploymentCutoverManualResult> {
|
||||
const command = normalizeLocalDeploymentCutoverManualCommand(input);
|
||||
const identity = currentIdentity();
|
||||
validatePrivateDirectory(
|
||||
localCutoverInstanceDirectory(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
),
|
||||
identity.uid,
|
||||
'cutoverInstanceDirectory',
|
||||
);
|
||||
const currentHead = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
if (
|
||||
command.operation === 'local.deployment.cutover.manual-resolution-commit' &&
|
||||
currentHead.state === 'resolution_authorized' &&
|
||||
currentHead.cutoverId === command.request.nextCutoverId &&
|
||||
currentHead.activationDigest === command.request.nextActivationDigest &&
|
||||
currentHead.previousHeadDigest ===
|
||||
command.request.expectedInstanceHeadDigest &&
|
||||
currentHead.sourceRecordDigest === command.request.expectedPreparationDigest
|
||||
) {
|
||||
return Object.freeze({
|
||||
...baseResult(command),
|
||||
status: 'existing' as const,
|
||||
state: 'resolution_authorized' as const,
|
||||
preparationDigest: command.request.expectedPreparationDigest,
|
||||
instanceHeadDigest: currentHead.headDigest,
|
||||
});
|
||||
}
|
||||
const head = manualHead(command, identity.uid);
|
||||
verifyManualJournalRecord(command, head);
|
||||
const observed = observations(command, dependencies, identity.uid);
|
||||
if (command.operation === 'local.deployment.cutover.manual-diagnose') {
|
||||
return Object.freeze({
|
||||
...baseResult(command),
|
||||
status: 'observed' as const,
|
||||
state: 'manual_diagnosed' as const,
|
||||
legacyState: observed.legacy.state,
|
||||
targetState: observed.target.state,
|
||||
legacyObservationDigest: observed.legacy.digest,
|
||||
targetObservationDigest: observed.target.digest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
if (
|
||||
observed.legacy.state !== 'stopped' ||
|
||||
observed.target.state !== 'stopped'
|
||||
) {
|
||||
configurationError(
|
||||
'manual resolution requires both legacy and target to be proved stopped',
|
||||
);
|
||||
}
|
||||
if (
|
||||
command.operation === 'local.deployment.cutover.manual-resolution-prepare'
|
||||
) {
|
||||
const preparation = preparationRecord(command, observed);
|
||||
const serialized = `${JSON.stringify(preparation, null, 2)}\n`;
|
||||
const filePath = preparationPath(command);
|
||||
const preparationStagePath = path.join(
|
||||
path.dirname(filePath),
|
||||
`.${path.basename(filePath)}.ql3-deploy-stage`,
|
||||
);
|
||||
if (
|
||||
fs.readdirSync(path.dirname(filePath)).length >= MAX_JOURNAL_FILES &&
|
||||
!fs.existsSync(filePath) &&
|
||||
!fs.existsSync(preparationStagePath)
|
||||
) {
|
||||
configurationError('cutover journal retention limit is reached');
|
||||
}
|
||||
preflightPublishedFile(
|
||||
filePath,
|
||||
serialized,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'manual resolution preparation',
|
||||
);
|
||||
const status = publishExactFile(
|
||||
filePath,
|
||||
serialized,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'manual resolution preparation',
|
||||
);
|
||||
return Object.freeze({
|
||||
...baseResult(command),
|
||||
status,
|
||||
state: 'resolution_prepared' as const,
|
||||
legacyState: observed.legacy.state,
|
||||
targetState: observed.target.state,
|
||||
legacyObservationDigest: observed.legacy.digest,
|
||||
targetObservationDigest: observed.target.digest,
|
||||
preparationDigest: preparation.preparationDigest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
const preparation = parsePreparation(
|
||||
readPrivateLocalCommandFile(preparationPath(command)),
|
||||
command,
|
||||
);
|
||||
if (
|
||||
observed.legacy.digest !== preparation.legacyObservationDigest ||
|
||||
observed.target.digest !== preparation.targetObservationDigest
|
||||
) {
|
||||
configurationError(
|
||||
'manual resolution container evidence drifted after prepare',
|
||||
);
|
||||
}
|
||||
const nextHead = authorizeResolvedLocalCutoverInstance(
|
||||
currentIdentityFor(command),
|
||||
nextIdentityFor(command),
|
||||
identity.uid,
|
||||
command.request.expectedInstanceHeadDigest,
|
||||
preparation.preparationDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
...baseResult(command),
|
||||
status: 'prepared' as const,
|
||||
state: 'resolution_authorized' as const,
|
||||
legacyState: observed.legacy.state,
|
||||
targetState: observed.target.state,
|
||||
legacyObservationDigest: observed.legacy.digest,
|
||||
targetObservationDigest: observed.target.digest,
|
||||
preparationDigest: preparation.preparationDigest,
|
||||
instanceHeadDigest: nextHead.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalDeploymentCutoverManualCommandFile(
|
||||
filePath: string,
|
||||
expectedOperation?: LocalDeploymentCutoverManualCommand['operation'],
|
||||
): Readonly<LocalDeploymentCutoverManualResult> {
|
||||
const input = readPrivateLocalCommandFile(filePath);
|
||||
if (
|
||||
expectedOperation !== undefined &&
|
||||
(!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
(input as Record<string, unknown>).operation !== expectedOperation)
|
||||
) {
|
||||
configurationError(
|
||||
'manual cutover command does not match the CLI operation',
|
||||
);
|
||||
}
|
||||
return runLocalDeploymentCutoverManualCommand(input);
|
||||
}
|
||||
|
||||
export { EMPTY_RESOLUTION_DIGEST };
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../../foundation/contract';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||
export const EMPTY_RESOLUTION_DIGEST = '0'.repeat(64);
|
||||
|
||||
export type LocalDeploymentCutoverManualOperation =
|
||||
| 'local.deployment.cutover.manual-diagnose'
|
||||
| 'local.deployment.cutover.manual-resolution-prepare'
|
||||
| 'local.deployment.cutover.manual-resolution-commit';
|
||||
|
||||
export interface LocalDeploymentCutoverManualCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentCutoverManualOperation;
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
dockerExecutable: string;
|
||||
dockerSocketPath: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
profile: 'edge' | 'standalone';
|
||||
instanceId: string;
|
||||
currentCutoverId: string;
|
||||
nextCutoverId: string;
|
||||
currentActivationDigest: string;
|
||||
nextActivationDigest: string;
|
||||
expectedInstanceHeadDigest: string;
|
||||
expectedManualRecordDigest: string;
|
||||
expectedLegacyContainerId: string;
|
||||
expectedTargetContainerId: string;
|
||||
expectedPreparationDigest: string;
|
||||
requestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeAbsolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} must be a supervisor-safe normalized absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function trustedExecutable(value: unknown, uid: number): string {
|
||||
const filePath = safeAbsolutePath(value, 'dockerExecutable');
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable is unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
(stat.uid !== 0 && stat.uid !== uid) ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(stat.mode & 0o111) === 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable must be a canonical trusted executable',
|
||||
);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentCutoverManualCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentCutoverManualCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
(command.operation !== 'local.deployment.cutover.manual-diagnose' &&
|
||||
command.operation !==
|
||||
'local.deployment.cutover.manual-resolution-prepare' &&
|
||||
command.operation !== 'local.deployment.cutover.manual-resolution-commit')
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const identity = currentIdentity();
|
||||
const options = object(command.options, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'deploymentRoot',
|
||||
'dockerExecutable',
|
||||
'dockerSocketPath',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'allowRootService does not match the current identity',
|
||||
);
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'currentActivationDigest',
|
||||
'currentCutoverId',
|
||||
'expectedInstanceHeadDigest',
|
||||
'expectedLegacyContainerId',
|
||||
'expectedManualRecordDigest',
|
||||
'expectedPreparationDigest',
|
||||
'expectedTargetContainerId',
|
||||
'instanceId',
|
||||
'nextActivationDigest',
|
||||
'nextCutoverId',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
(request.profile !== 'edge' && request.profile !== 'standalone') ||
|
||||
typeof request.instanceId !== 'string' ||
|
||||
!INSTANCE_ID_PATTERN.test(request.instanceId) ||
|
||||
typeof request.currentCutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(request.currentCutoverId) ||
|
||||
typeof request.nextCutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(request.nextCutoverId) ||
|
||||
request.nextCutoverId === request.currentCutoverId ||
|
||||
typeof request.currentActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.currentActivationDigest) ||
|
||||
typeof request.nextActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.nextActivationDigest) ||
|
||||
typeof request.expectedInstanceHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedInstanceHeadDigest) ||
|
||||
typeof request.expectedManualRecordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedManualRecordDigest) ||
|
||||
typeof request.expectedLegacyContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedLegacyContainerId) ||
|
||||
typeof request.expectedTargetContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedTargetContainerId) ||
|
||||
request.expectedTargetContainerId === request.expectedLegacyContainerId ||
|
||||
typeof request.expectedPreparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedPreparationDigest) ||
|
||||
(command.operation ===
|
||||
'local.deployment.cutover.manual-resolution-commit') ===
|
||||
(request.expectedPreparationDigest === EMPTY_RESOLUTION_DIGEST) ||
|
||||
!Number.isSafeInteger(request.requestedAtMs) ||
|
||||
(request.requestedAtMs as number) < 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'manual cutover request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
dockerExecutable: trustedExecutable(
|
||||
options.dockerExecutable,
|
||||
identity.uid,
|
||||
),
|
||||
dockerSocketPath: safeAbsolutePath(
|
||||
options.dockerSocketPath,
|
||||
'dockerSocketPath',
|
||||
),
|
||||
allowRootService: options.allowRootService,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
profile: request.profile,
|
||||
instanceId: request.instanceId,
|
||||
currentCutoverId: request.currentCutoverId,
|
||||
nextCutoverId: request.nextCutoverId,
|
||||
currentActivationDigest: request.currentActivationDigest,
|
||||
nextActivationDigest: request.nextActivationDigest,
|
||||
expectedInstanceHeadDigest: request.expectedInstanceHeadDigest,
|
||||
expectedManualRecordDigest: request.expectedManualRecordDigest,
|
||||
expectedLegacyContainerId: request.expectedLegacyContainerId,
|
||||
expectedTargetContainerId: request.expectedTargetContainerId,
|
||||
expectedPreparationDigest: request.expectedPreparationDigest,
|
||||
requestedAtMs: request.requestedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../../foundation/docker';
|
||||
import { validatePrivateDirectory } from '../../foundation/files';
|
||||
import {
|
||||
legacyCommitmentPath,
|
||||
parseStoppedLegacyEvidence,
|
||||
parseTargetContainerEvidence,
|
||||
readLegacySilenceEvidence,
|
||||
readTargetApplicationBinding,
|
||||
readTargetStartupReceipt,
|
||||
verifyTargetRunActivation,
|
||||
type LegacySilenceEvidence,
|
||||
type TargetApplicationBinding,
|
||||
type TargetContainerEvidence,
|
||||
} from '../targetEvidence';
|
||||
import {
|
||||
normalizeLocalDeploymentTargetRunCommand,
|
||||
type LocalDeploymentTargetRunCommand,
|
||||
type LocalDeploymentTargetRunResult,
|
||||
} from './targetRunContract';
|
||||
import {
|
||||
publishTargetRunJournalRecord as publishRecord,
|
||||
readTargetRunJournalRecord as readRecord,
|
||||
targetRunJournalRecord as journalRecord,
|
||||
targetRunManualEvidence as manualEvidence,
|
||||
targetRunPhasePath as phasePath,
|
||||
targetRunSequence as sequence,
|
||||
verifyTargetRunManualEvidence as verifyManualEvidence,
|
||||
type TargetRunJournalRecord,
|
||||
type TargetRunManualReason as ManualReason,
|
||||
} from './targetRunJournal';
|
||||
import {
|
||||
targetActiveEvidence as activeEvidence,
|
||||
targetRequestEvidence as requestEvidence,
|
||||
verifyTargetActiveEvidence as verifyActiveEvidence,
|
||||
verifyTargetRequestEvidence as verifyRequestEvidence,
|
||||
} from './targetRunRecordEvidence';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
assertLocalCutoverTargetHead,
|
||||
} from '../instanceLineage';
|
||||
|
||||
export interface LocalDeploymentTargetRunDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
readonly now?: () => number;
|
||||
readonly wait?: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
interface RunContext {
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly journal: string;
|
||||
readonly commitment: Readonly<LegacySilenceEvidence>;
|
||||
readonly application: Readonly<TargetApplicationBinding>;
|
||||
readonly uid: number;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function result(
|
||||
context: Readonly<RunContext>,
|
||||
status: 'prepared' | 'existing',
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
): Readonly<LocalDeploymentTargetRunResult> {
|
||||
advanceLocalCutoverInstanceHead(
|
||||
context.command,
|
||||
context.uid,
|
||||
record.state as 'target_active' | 'manual_required',
|
||||
record.generation,
|
||||
record.recordDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: context.command.operation,
|
||||
status,
|
||||
state: record.state as 'target_active' | 'manual_required',
|
||||
cutoverId: context.command.request.cutoverId,
|
||||
generation: context.command.request.generation,
|
||||
recordDigest: record.recordDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function publishManual(
|
||||
context: Readonly<RunContext>,
|
||||
filePath: string,
|
||||
recordSequence: number,
|
||||
previousRecordDigest: string,
|
||||
reason: ManualReason,
|
||||
): Readonly<LocalDeploymentTargetRunResult> {
|
||||
const record = journalRecord(
|
||||
context.command,
|
||||
recordSequence,
|
||||
'manual_required',
|
||||
previousRecordDigest,
|
||||
manualEvidence(reason),
|
||||
);
|
||||
const status = publishRecord(
|
||||
context,
|
||||
filePath,
|
||||
record,
|
||||
'target cutover manual resolution',
|
||||
);
|
||||
return result(context, status, record);
|
||||
}
|
||||
|
||||
function docker(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
args: readonly string[],
|
||||
timeoutMs = 30_000,
|
||||
): string {
|
||||
return runDocker({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
args,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
function inspectContainer(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
containerId: string,
|
||||
): string {
|
||||
return docker(command, runDocker, ['container', 'inspect', containerId]);
|
||||
}
|
||||
|
||||
function readPriorActive(
|
||||
context: Readonly<RunContext>,
|
||||
): Readonly<{ startupReceiptDigest: string; recordDigest: string }> {
|
||||
const generation = context.command.request.generation - 1;
|
||||
const request = readRecord(
|
||||
phasePath(context.journal, generation, 'request'),
|
||||
context,
|
||||
{
|
||||
sequence: sequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1
|
||||
? 'target_start_requested'
|
||||
: 'target_restart_requested',
|
||||
],
|
||||
},
|
||||
);
|
||||
const requestEvidence = verifyRequestEvidence(
|
||||
{
|
||||
...context,
|
||||
command: Object.freeze({
|
||||
...context.command,
|
||||
operation:
|
||||
generation === 1
|
||||
? ('local.deployment.cutover.target-start' as const)
|
||||
: ('local.deployment.cutover.target-restart' as const),
|
||||
request: Object.freeze({ ...context.command.request, generation }),
|
||||
}),
|
||||
},
|
||||
request,
|
||||
);
|
||||
const active = readRecord(
|
||||
phasePath(context.journal, generation, 'outcome'),
|
||||
context,
|
||||
{
|
||||
sequence: sequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_active'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
},
|
||||
);
|
||||
const startupReceiptDigest = verifyActiveEvidence(
|
||||
{
|
||||
...context,
|
||||
command: Object.freeze({
|
||||
...context.command,
|
||||
operation:
|
||||
generation === 1
|
||||
? ('local.deployment.cutover.target-start' as const)
|
||||
: ('local.deployment.cutover.target-restart' as const),
|
||||
request: Object.freeze({ ...context.command.request, generation }),
|
||||
}),
|
||||
},
|
||||
active,
|
||||
requestEvidence,
|
||||
);
|
||||
return Object.freeze({
|
||||
startupReceiptDigest,
|
||||
recordDigest: active.recordDigest,
|
||||
});
|
||||
}
|
||||
|
||||
async function observeActiveTarget(
|
||||
context: Readonly<RunContext>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
request: ReturnType<typeof verifyRequestEvidence>,
|
||||
now: () => number,
|
||||
wait: (milliseconds: number) => Promise<void>,
|
||||
): Promise<
|
||||
| Readonly<{
|
||||
target: Readonly<TargetContainerEvidence>;
|
||||
startupReceiptDigest: string;
|
||||
}>
|
||||
| undefined
|
||||
> {
|
||||
const timeoutMs =
|
||||
context.command.request.profile === 'edge' ? 30_000 : 60_000;
|
||||
const maximumAttempts =
|
||||
context.command.request.profile === 'edge' ? 120 : 240;
|
||||
const deadline = now() + timeoutMs;
|
||||
for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
|
||||
try {
|
||||
const target = parseTargetContainerEvidence(
|
||||
inspectContainer(
|
||||
context.command,
|
||||
runDocker,
|
||||
context.command.request.expectedTargetContainerId,
|
||||
),
|
||||
context.command,
|
||||
context.application,
|
||||
'active',
|
||||
);
|
||||
const receipt = readTargetStartupReceipt(context.command);
|
||||
if (
|
||||
target.identityDigest === request.targetContainerIdentityDigest &&
|
||||
target.applicationBindingDigest ===
|
||||
request.targetApplicationBindingDigest &&
|
||||
receipt !== null &&
|
||||
receipt.digest !== request.previousStartupReceiptDigest
|
||||
) {
|
||||
return Object.freeze({
|
||||
target,
|
||||
startupReceiptDigest: receipt.digest,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// The bounded inspection-only window resolves all unknown start results.
|
||||
}
|
||||
if (now() >= deadline) return undefined;
|
||||
await wait(250);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function replayCurrentTerminal(
|
||||
context: Readonly<RunContext>,
|
||||
previousRecordDigest: string,
|
||||
): Readonly<LocalDeploymentTargetRunResult> | undefined {
|
||||
const generation = context.command.request.generation;
|
||||
const requestPath = phasePath(context.journal, generation, 'request');
|
||||
if (!fs.existsSync(requestPath)) return undefined;
|
||||
const request = readRecord(requestPath, context, {
|
||||
sequence: sequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1 ? 'target_start_requested' : 'target_restart_requested',
|
||||
'manual_required',
|
||||
],
|
||||
previousRecordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (request.state === 'manual_required') {
|
||||
verifyManualEvidence(request);
|
||||
return result(context, 'existing', request);
|
||||
}
|
||||
const requestBinding = verifyRequestEvidence(context, request);
|
||||
const outcomePath = phasePath(context.journal, generation, 'outcome');
|
||||
if (!fs.existsSync(outcomePath)) return undefined;
|
||||
const outcome = readRecord(outcomePath, context, {
|
||||
sequence: sequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_active', 'manual_required'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (outcome.state === 'manual_required') verifyManualEvidence(outcome);
|
||||
else verifyActiveEvidence(context, outcome, requestBinding);
|
||||
return result(context, 'existing', outcome);
|
||||
}
|
||||
|
||||
function restartRecheckPrefix(
|
||||
context: Readonly<RunContext>,
|
||||
previousActiveDigest: string,
|
||||
):
|
||||
| Readonly<{
|
||||
reverified: Readonly<TargetRunJournalRecord>;
|
||||
terminal?: Readonly<LocalDeploymentTargetRunResult>;
|
||||
}>
|
||||
| undefined {
|
||||
const generation = context.command.request.generation;
|
||||
const recheckPath = phasePath(context.journal, generation, 'recheck');
|
||||
if (!fs.existsSync(recheckPath)) return undefined;
|
||||
const recheck = readRecord(recheckPath, context, {
|
||||
sequence: sequence(generation, 'recheck'),
|
||||
generation,
|
||||
states: ['legacy_recheck_requested'],
|
||||
previousRecordDigest: previousActiveDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
const evidence = object(recheck.evidence, 'legacy recheck evidence');
|
||||
exact(
|
||||
evidence,
|
||||
['legacyCommitmentDigest', 'legacyContainerId'],
|
||||
'legacy recheck evidence',
|
||||
);
|
||||
if (
|
||||
evidence.legacyCommitmentDigest !== context.commitment.commitmentDigest ||
|
||||
evidence.legacyContainerId !==
|
||||
context.command.request.expectedLegacyContainerId
|
||||
) {
|
||||
configurationError('legacy recheck evidence drifted');
|
||||
}
|
||||
const verifiedPath = phasePath(context.journal, generation, 'verified');
|
||||
if (!fs.existsSync(verifiedPath)) return undefined;
|
||||
const verified = readRecord(verifiedPath, context, {
|
||||
sequence: sequence(generation, 'verified'),
|
||||
generation,
|
||||
states: ['legacy_reverified', 'manual_required'],
|
||||
previousRecordDigest: recheck.recordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (verified.state === 'manual_required') {
|
||||
verifyManualEvidence(verified);
|
||||
return Object.freeze({
|
||||
reverified: verified,
|
||||
terminal: result(context, 'existing', verified),
|
||||
});
|
||||
}
|
||||
const verifiedEvidence = object(
|
||||
verified.evidence,
|
||||
'legacy reverified evidence',
|
||||
);
|
||||
exact(
|
||||
verifiedEvidence,
|
||||
[
|
||||
'legacyCommitmentDigest',
|
||||
'legacyContainerIdentityDigest',
|
||||
'legacySourceBindingDigest',
|
||||
],
|
||||
'legacy reverified evidence',
|
||||
);
|
||||
if (
|
||||
verifiedEvidence.legacyCommitmentDigest !==
|
||||
context.commitment.commitmentDigest ||
|
||||
verifiedEvidence.legacyContainerIdentityDigest !==
|
||||
context.commitment.legacyContainerIdentityDigest ||
|
||||
verifiedEvidence.legacySourceBindingDigest !==
|
||||
context.commitment.legacySourceBindingDigest
|
||||
) {
|
||||
configurationError('legacy reverified evidence drifted');
|
||||
}
|
||||
return Object.freeze({ reverified: verified });
|
||||
}
|
||||
|
||||
async function runWithStartBarrier(
|
||||
context: Readonly<RunContext>,
|
||||
previousRecordDigest: string,
|
||||
previousStartupReceiptDigest: string | null,
|
||||
dependencies: Required<
|
||||
Pick<LocalDeploymentTargetRunDependencies, 'runDocker' | 'now' | 'wait'>
|
||||
>,
|
||||
): Promise<Readonly<LocalDeploymentTargetRunResult>> {
|
||||
const generation = context.command.request.generation;
|
||||
const requestPath = phasePath(context.journal, generation, 'request');
|
||||
let request: Readonly<TargetRunJournalRecord>;
|
||||
let requestStatus: 'prepared' | 'existing';
|
||||
if (fs.existsSync(requestPath)) {
|
||||
request = readRecord(requestPath, context, {
|
||||
sequence: sequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1
|
||||
? 'target_start_requested'
|
||||
: 'target_restart_requested',
|
||||
],
|
||||
previousRecordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
requestStatus = 'existing';
|
||||
} else {
|
||||
let target: Readonly<TargetContainerEvidence>;
|
||||
try {
|
||||
target = parseTargetContainerEvidence(
|
||||
inspectContainer(
|
||||
context.command,
|
||||
dependencies.runDocker,
|
||||
context.command.request.expectedTargetContainerId,
|
||||
),
|
||||
context.command,
|
||||
context.application,
|
||||
'stopped',
|
||||
);
|
||||
const receipt = readTargetStartupReceipt(context.command);
|
||||
if (
|
||||
(generation === 1 && receipt !== null) ||
|
||||
(generation > 1 && receipt?.digest !== previousStartupReceiptDigest)
|
||||
) {
|
||||
configurationError('target startup receipt preflight is invalid');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
requestPath,
|
||||
sequence(generation, 'request'),
|
||||
previousRecordDigest,
|
||||
'target_preflight_unproved',
|
||||
);
|
||||
}
|
||||
request = journalRecord(
|
||||
context.command,
|
||||
sequence(generation, 'request'),
|
||||
generation === 1 ? 'target_start_requested' : 'target_restart_requested',
|
||||
previousRecordDigest,
|
||||
requestEvidence(context, target, previousStartupReceiptDigest),
|
||||
);
|
||||
requestStatus = publishRecord(
|
||||
context,
|
||||
requestPath,
|
||||
request,
|
||||
'target start barrier',
|
||||
);
|
||||
}
|
||||
const requestBinding = verifyRequestEvidence(context, request);
|
||||
const outcomePath = phasePath(context.journal, generation, 'outcome');
|
||||
if (fs.existsSync(outcomePath)) {
|
||||
const existing = replayCurrentTerminal(context, previousRecordDigest);
|
||||
if (existing === undefined) configurationError('target outcome drifted');
|
||||
return existing;
|
||||
}
|
||||
if (requestStatus === 'prepared') {
|
||||
try {
|
||||
const output = docker(
|
||||
context.command,
|
||||
dependencies.runDocker,
|
||||
[
|
||||
'container',
|
||||
'start',
|
||||
context.command.request.expectedTargetContainerId,
|
||||
],
|
||||
45_000,
|
||||
).trim();
|
||||
if (output !== context.command.request.expectedTargetContainerId) {
|
||||
configurationError('target start response identity is invalid');
|
||||
}
|
||||
} catch {
|
||||
// The durable barrier forbids retry; inspection below is authoritative.
|
||||
}
|
||||
}
|
||||
const observed = await observeActiveTarget(
|
||||
context,
|
||||
dependencies.runDocker,
|
||||
requestBinding,
|
||||
dependencies.now,
|
||||
dependencies.wait,
|
||||
);
|
||||
if (observed === undefined) {
|
||||
return publishManual(
|
||||
context,
|
||||
outcomePath,
|
||||
sequence(generation, 'outcome'),
|
||||
request.recordDigest,
|
||||
generation === 1
|
||||
? 'target_start_result_unproved'
|
||||
: 'target_restart_result_unproved',
|
||||
);
|
||||
}
|
||||
const active = journalRecord(
|
||||
context.command,
|
||||
sequence(generation, 'outcome'),
|
||||
'target_active',
|
||||
request.recordDigest,
|
||||
activeEvidence(context, observed.target, observed.startupReceiptDigest),
|
||||
);
|
||||
publishRecord(context, outcomePath, active, 'target active commitment');
|
||||
return result(context, 'prepared', active);
|
||||
}
|
||||
|
||||
export async function runLocalDeploymentDockerTarget(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentTargetRunDependencies = {},
|
||||
): Promise<Readonly<LocalDeploymentTargetRunResult>> {
|
||||
const command = normalizeLocalDeploymentTargetRunCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const serviceRoot = path.join(command.options.deploymentRoot, 'service');
|
||||
const journal = path.dirname(legacyCommitmentPath(command));
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(serviceRoot, identity.uid, 'serviceDescriptorRoot');
|
||||
validatePrivateDirectory(journal, identity.uid, 'cutoverJournal');
|
||||
verifyTargetRunActivation(command);
|
||||
const commitment = readLegacySilenceEvidence(command);
|
||||
const application = readTargetApplicationBinding(command);
|
||||
const context = Object.freeze({
|
||||
command,
|
||||
journal,
|
||||
commitment,
|
||||
application,
|
||||
uid: identity.uid,
|
||||
});
|
||||
const instanceHead = assertLocalCutoverTargetHead(command, identity.uid);
|
||||
if (
|
||||
instanceHead.state === 'manual_required' &&
|
||||
instanceHead.generation !== command.request.generation
|
||||
) {
|
||||
configurationError('manual-required instance lineage is terminal');
|
||||
}
|
||||
|
||||
let previousRecordDigest = commitment.commitmentDigest;
|
||||
let previousStartupReceiptDigest: string | null = null;
|
||||
let canReplayCurrent = command.request.generation === 1;
|
||||
if (command.request.generation > 1) {
|
||||
const prior = readPriorActive(context);
|
||||
previousRecordDigest = prior.recordDigest;
|
||||
previousStartupReceiptDigest = prior.startupReceiptDigest;
|
||||
const prefix = restartRecheckPrefix(context, previousRecordDigest);
|
||||
if (prefix?.terminal !== undefined) return prefix.terminal;
|
||||
if (prefix !== undefined) {
|
||||
previousRecordDigest = prefix.reverified.recordDigest;
|
||||
canReplayCurrent = true;
|
||||
}
|
||||
}
|
||||
const replay = canReplayCurrent
|
||||
? replayCurrentTerminal(context, previousRecordDigest)
|
||||
: undefined;
|
||||
if (replay !== undefined) return replay;
|
||||
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const wait =
|
||||
dependencies.wait ??
|
||||
((milliseconds: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||
|
||||
if (command.request.generation > 1) {
|
||||
const recheckPath = phasePath(
|
||||
journal,
|
||||
command.request.generation,
|
||||
'recheck',
|
||||
);
|
||||
let recheck: Readonly<TargetRunJournalRecord>;
|
||||
if (fs.existsSync(recheckPath)) {
|
||||
recheck = readRecord(recheckPath, context, {
|
||||
sequence: sequence(command.request.generation, 'recheck'),
|
||||
generation: command.request.generation,
|
||||
states: ['legacy_recheck_requested'],
|
||||
previousRecordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
} else {
|
||||
recheck = journalRecord(
|
||||
command,
|
||||
sequence(command.request.generation, 'recheck'),
|
||||
'legacy_recheck_requested',
|
||||
previousRecordDigest,
|
||||
Object.freeze({
|
||||
legacyCommitmentDigest: commitment.commitmentDigest,
|
||||
legacyContainerId: command.request.expectedLegacyContainerId,
|
||||
}),
|
||||
);
|
||||
publishRecord(context, recheckPath, recheck, 'legacy recheck request');
|
||||
}
|
||||
const verifiedPath = phasePath(
|
||||
journal,
|
||||
command.request.generation,
|
||||
'verified',
|
||||
);
|
||||
let verified: Readonly<TargetRunJournalRecord>;
|
||||
if (fs.existsSync(verifiedPath)) {
|
||||
verified = readRecord(verifiedPath, context, {
|
||||
sequence: sequence(command.request.generation, 'verified'),
|
||||
generation: command.request.generation,
|
||||
states: ['legacy_reverified', 'manual_required'],
|
||||
previousRecordDigest: recheck.recordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
if (verified.state === 'manual_required') {
|
||||
verifyManualEvidence(verified);
|
||||
return result(context, 'existing', verified);
|
||||
}
|
||||
} else {
|
||||
let legacy;
|
||||
try {
|
||||
legacy = parseStoppedLegacyEvidence(
|
||||
inspectContainer(
|
||||
command,
|
||||
runDocker,
|
||||
command.request.expectedLegacyContainerId,
|
||||
),
|
||||
command,
|
||||
);
|
||||
if (
|
||||
legacy.identityDigest !== commitment.legacyContainerIdentityDigest ||
|
||||
legacy.sourceBindingDigest !== commitment.legacySourceBindingDigest
|
||||
) {
|
||||
configurationError('legacy silence evidence changed');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
verifiedPath,
|
||||
sequence(command.request.generation, 'verified'),
|
||||
recheck.recordDigest,
|
||||
'legacy_silence_unproved',
|
||||
);
|
||||
}
|
||||
verified = journalRecord(
|
||||
command,
|
||||
sequence(command.request.generation, 'verified'),
|
||||
'legacy_reverified',
|
||||
recheck.recordDigest,
|
||||
Object.freeze({
|
||||
legacyCommitmentDigest: commitment.commitmentDigest,
|
||||
legacyContainerIdentityDigest: legacy.identityDigest,
|
||||
legacySourceBindingDigest: legacy.sourceBindingDigest,
|
||||
}),
|
||||
);
|
||||
publishRecord(
|
||||
context,
|
||||
verifiedPath,
|
||||
verified,
|
||||
'legacy reverified commitment',
|
||||
);
|
||||
}
|
||||
previousRecordDigest = verified.recordDigest;
|
||||
}
|
||||
|
||||
return runWithStartBarrier(
|
||||
context,
|
||||
previousRecordDigest,
|
||||
previousStartupReceiptDigest,
|
||||
{ runDocker, now, wait },
|
||||
);
|
||||
}
|
||||
|
||||
export function runLocalDeploymentDockerTargetCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalDeploymentTargetRunResult>> {
|
||||
return runLocalDeploymentDockerTarget(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
type LocalDeploymentProfile,
|
||||
} from '../../foundation/contract';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const IMAGE_DIGEST_PATTERN =
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/;
|
||||
const MAX_TARGET_GENERATION = 15;
|
||||
|
||||
export type LocalDeploymentTargetRunOperation =
|
||||
| 'local.deployment.cutover.target-start'
|
||||
| 'local.deployment.cutover.target-restart';
|
||||
|
||||
export interface LocalDeploymentTargetRunCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentTargetRunOperation;
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
dockerExecutable: string;
|
||||
dockerSocketPath: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
cutoverId: string;
|
||||
profile: LocalDeploymentProfile;
|
||||
instanceId: string;
|
||||
activationPath: string;
|
||||
legacySourcePath: string;
|
||||
targetDatabasePath: string;
|
||||
recoveryPath: string;
|
||||
manifestPath: string;
|
||||
expectedLegacyDatabasePath: string;
|
||||
expectedActivationDigest: string;
|
||||
expectedLegacyCommitmentDigest: string;
|
||||
expectedLegacyContainerId: string;
|
||||
expectedTargetContainerId: string;
|
||||
expectedTargetImage: string;
|
||||
applicationConfigPath: string;
|
||||
expectedTargetApplicationConfigPath: string;
|
||||
expectedTargetCommitmentPath: string;
|
||||
generation: number;
|
||||
requestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentTargetRunResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: LocalDeploymentTargetRunOperation;
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'target_active' | 'manual_required';
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly recordDigest: string;
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeAbsolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} must be a supervisor-safe normalized absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function trustedExecutable(value: unknown, uid: number): string {
|
||||
const filePath = safeAbsolutePath(value, 'dockerExecutable');
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable is unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
(stat.uid !== 0 && stat.uid !== uid) ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(stat.mode & 0o111) === 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerExecutable must be a canonical trusted executable',
|
||||
);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, minimum: number): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentTargetRunCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentTargetRunCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
(command.operation !== 'local.deployment.cutover.target-start' &&
|
||||
command.operation !== 'local.deployment.cutover.target-restart')
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const identity = currentIdentity();
|
||||
const options = object(command.options, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'deploymentRoot',
|
||||
'dockerExecutable',
|
||||
'dockerSocketPath',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'allowRootService does not match the current identity',
|
||||
);
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'activationPath',
|
||||
'applicationConfigPath',
|
||||
'cutoverId',
|
||||
'expectedActivationDigest',
|
||||
'expectedLegacyCommitmentDigest',
|
||||
'expectedLegacyContainerId',
|
||||
'expectedLegacyDatabasePath',
|
||||
'expectedTargetApplicationConfigPath',
|
||||
'expectedTargetCommitmentPath',
|
||||
'expectedTargetContainerId',
|
||||
'expectedTargetImage',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacySourcePath',
|
||||
'manifestPath',
|
||||
'profile',
|
||||
'recoveryPath',
|
||||
'requestedAtMs',
|
||||
'targetDatabasePath',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const generation = integer(request.generation, 'generation', 1);
|
||||
if (
|
||||
typeof request.cutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(request.cutoverId) ||
|
||||
(request.profile !== 'edge' && request.profile !== 'standalone') ||
|
||||
typeof request.instanceId !== 'string' ||
|
||||
!INSTANCE_ID_PATTERN.test(request.instanceId) ||
|
||||
typeof request.expectedActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedActivationDigest) ||
|
||||
typeof request.expectedLegacyCommitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedLegacyCommitmentDigest) ||
|
||||
typeof request.expectedLegacyContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedLegacyContainerId) ||
|
||||
typeof request.expectedTargetContainerId !== 'string' ||
|
||||
!CONTAINER_ID_PATTERN.test(request.expectedTargetContainerId) ||
|
||||
request.expectedTargetContainerId === request.expectedLegacyContainerId ||
|
||||
typeof request.expectedTargetImage !== 'string' ||
|
||||
!IMAGE_DIGEST_PATTERN.test(request.expectedTargetImage) ||
|
||||
generation > MAX_TARGET_GENERATION ||
|
||||
(command.operation === 'local.deployment.cutover.target-start' &&
|
||||
generation !== 1) ||
|
||||
(command.operation === 'local.deployment.cutover.target-restart' &&
|
||||
generation < 2)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'target run request identity is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
new Set([
|
||||
request.activationPath,
|
||||
request.legacySourcePath,
|
||||
request.targetDatabasePath,
|
||||
request.recoveryPath,
|
||||
request.manifestPath,
|
||||
request.applicationConfigPath,
|
||||
]).size !== 6
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'target run authority paths must be distinct',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
dockerExecutable: trustedExecutable(
|
||||
options.dockerExecutable,
|
||||
identity.uid,
|
||||
),
|
||||
dockerSocketPath: safeAbsolutePath(
|
||||
options.dockerSocketPath,
|
||||
'dockerSocketPath',
|
||||
),
|
||||
allowRootService: options.allowRootService,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
cutoverId: request.cutoverId,
|
||||
profile: request.profile,
|
||||
instanceId: request.instanceId,
|
||||
activationPath: safeAbsolutePath(
|
||||
request.activationPath,
|
||||
'activationPath',
|
||||
),
|
||||
legacySourcePath: safeAbsolutePath(
|
||||
request.legacySourcePath,
|
||||
'legacySourcePath',
|
||||
),
|
||||
targetDatabasePath: safeAbsolutePath(
|
||||
request.targetDatabasePath,
|
||||
'targetDatabasePath',
|
||||
),
|
||||
recoveryPath: safeAbsolutePath(request.recoveryPath, 'recoveryPath'),
|
||||
manifestPath: safeAbsolutePath(request.manifestPath, 'manifestPath'),
|
||||
expectedLegacyDatabasePath: safeAbsolutePath(
|
||||
request.expectedLegacyDatabasePath,
|
||||
'expectedLegacyDatabasePath',
|
||||
),
|
||||
expectedActivationDigest: request.expectedActivationDigest,
|
||||
expectedLegacyCommitmentDigest: request.expectedLegacyCommitmentDigest,
|
||||
expectedLegacyContainerId: request.expectedLegacyContainerId,
|
||||
expectedTargetContainerId: request.expectedTargetContainerId,
|
||||
expectedTargetImage: request.expectedTargetImage,
|
||||
applicationConfigPath: safeAbsolutePath(
|
||||
request.applicationConfigPath,
|
||||
'applicationConfigPath',
|
||||
),
|
||||
expectedTargetApplicationConfigPath: safeAbsolutePath(
|
||||
request.expectedTargetApplicationConfigPath,
|
||||
'expectedTargetApplicationConfigPath',
|
||||
),
|
||||
expectedTargetCommitmentPath: safeAbsolutePath(
|
||||
request.expectedTargetCommitmentPath,
|
||||
'expectedTargetCommitmentPath',
|
||||
),
|
||||
generation,
|
||||
requestedAtMs: integer(request.requestedAtMs, 'requestedAtMs', 0),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/contract';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
} from '../../foundation/files';
|
||||
import { cutoverDigest } from '../targetEvidence';
|
||||
import type { LocalDeploymentTargetRunCommand } from './targetRunContract';
|
||||
|
||||
const JOURNAL_SCHEMA = 'qinglong3-local-cutover-journal-record';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export type TargetRunJournalState =
|
||||
| 'legacy_recheck_requested'
|
||||
| 'legacy_reverified'
|
||||
| 'target_start_requested'
|
||||
| 'target_restart_requested'
|
||||
| 'target_stop_requested'
|
||||
| 'target_active'
|
||||
| 'target_stopped'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
| 'manual_required';
|
||||
|
||||
export type TargetRunManualReason =
|
||||
| 'legacy_silence_unproved'
|
||||
| 'target_preflight_unproved'
|
||||
| 'target_start_result_unproved'
|
||||
| 'target_restart_result_unproved'
|
||||
| 'target_stop_preflight_unproved'
|
||||
| 'target_stop_result_unproved'
|
||||
| 'legacy_restart_preflight_unproved'
|
||||
| 'legacy_restart_result_unproved';
|
||||
|
||||
export interface TargetRunJournalRecord {
|
||||
readonly schema: typeof JOURNAL_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly sequence: number;
|
||||
readonly state: TargetRunJournalState;
|
||||
readonly cutoverId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly generation: number;
|
||||
readonly previousRecordDigest: string;
|
||||
readonly requestedAtMs: number;
|
||||
readonly evidence: Readonly<Record<string, unknown>>;
|
||||
readonly recordDigest: string;
|
||||
}
|
||||
|
||||
export interface TargetRunJournalContext {
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly uid: number;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function targetRunJournalRecord(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
sequence: number,
|
||||
state: TargetRunJournalState,
|
||||
previousRecordDigest: string,
|
||||
evidence: Readonly<Record<string, unknown>>,
|
||||
): Readonly<TargetRunJournalRecord> {
|
||||
const payload = Object.freeze({
|
||||
schema: JOURNAL_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
sequence,
|
||||
state,
|
||||
cutoverId: command.request.cutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
activationDigest: command.request.expectedActivationDigest,
|
||||
generation: command.request.generation,
|
||||
previousRecordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
evidence,
|
||||
});
|
||||
return Object.freeze({ ...payload, recordDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
function parseTargetRunJournalRecord(
|
||||
value: unknown,
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
expected: Readonly<{
|
||||
sequence: number;
|
||||
generation: number;
|
||||
states: readonly TargetRunJournalState[];
|
||||
previousRecordDigest?: string;
|
||||
requestedAtMs?: number;
|
||||
}>,
|
||||
): Readonly<TargetRunJournalRecord> {
|
||||
const record = object(value, 'target run journal record');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'activationDigest',
|
||||
'cutoverId',
|
||||
'evidence',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'previousRecordDigest',
|
||||
'profile',
|
||||
'recordDigest',
|
||||
'requestedAtMs',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'sequence',
|
||||
'state',
|
||||
],
|
||||
'target run journal record',
|
||||
);
|
||||
const { recordDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== JOURNAL_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.sequence !== expected.sequence ||
|
||||
!expected.states.includes(record.state as TargetRunJournalState) ||
|
||||
record.cutoverId !== command.request.cutoverId ||
|
||||
record.profile !== command.request.profile ||
|
||||
record.instanceId !== command.request.instanceId ||
|
||||
record.activationDigest !== command.request.expectedActivationDigest ||
|
||||
record.generation !== expected.generation ||
|
||||
(expected.previousRecordDigest !== undefined &&
|
||||
record.previousRecordDigest !== expected.previousRecordDigest) ||
|
||||
(expected.requestedAtMs !== undefined &&
|
||||
record.requestedAtMs !== expected.requestedAtMs) ||
|
||||
typeof record.previousRecordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.previousRecordDigest) ||
|
||||
!Number.isSafeInteger(record.requestedAtMs) ||
|
||||
(record.requestedAtMs as number) < 0 ||
|
||||
typeof recordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(recordDigest) ||
|
||||
cutoverDigest(payload) !== recordDigest
|
||||
) {
|
||||
configurationError('target run journal record drifted');
|
||||
}
|
||||
object(record.evidence, 'target run journal evidence');
|
||||
return record as unknown as Readonly<TargetRunJournalRecord>;
|
||||
}
|
||||
|
||||
export function publishTargetRunJournalRecord(
|
||||
context: Readonly<TargetRunJournalContext>,
|
||||
filePath: string,
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
label: string,
|
||||
): 'prepared' | 'existing' {
|
||||
const contents = `${JSON.stringify(record, null, 2)}\n`;
|
||||
preflightPublishedFile(filePath, contents, 0o600, context.uid, label);
|
||||
return publishExactFile(filePath, contents, 0o600, context.uid, label);
|
||||
}
|
||||
|
||||
export function readTargetRunJournalRecord(
|
||||
filePath: string,
|
||||
context: Readonly<TargetRunJournalContext>,
|
||||
expected: Parameters<typeof parseTargetRunJournalRecord>[2],
|
||||
): Readonly<TargetRunJournalRecord> {
|
||||
return parseTargetRunJournalRecord(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
context.command,
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
export function targetRunSequence(
|
||||
generation: number,
|
||||
phase: 'recheck' | 'verified' | 'request' | 'outcome',
|
||||
): number {
|
||||
if (generation === 1) return phase === 'request' ? 3 : 4;
|
||||
const base = generation * 4;
|
||||
if (phase === 'recheck') return base - 3;
|
||||
if (phase === 'verified') return base - 2;
|
||||
if (phase === 'request') return base - 1;
|
||||
return base;
|
||||
}
|
||||
|
||||
export function targetRunPhasePath(
|
||||
journal: string,
|
||||
generation: number,
|
||||
phase: 'recheck' | 'verified' | 'request' | 'outcome',
|
||||
): string {
|
||||
const number = String(targetRunSequence(generation, phase)).padStart(4, '0');
|
||||
const label =
|
||||
generation === 1
|
||||
? phase === 'request'
|
||||
? 'target-start-decision'
|
||||
: 'target-start-outcome'
|
||||
: phase === 'recheck'
|
||||
? 'legacy-recheck-decision'
|
||||
: phase === 'verified'
|
||||
? 'legacy-recheck-outcome'
|
||||
: phase === 'request'
|
||||
? 'target-restart-decision'
|
||||
: 'target-restart-outcome';
|
||||
return path.join(journal, `${number}-${label}.json`);
|
||||
}
|
||||
|
||||
export function targetStopSequence(
|
||||
generation: number,
|
||||
phase: 'request' | 'outcome',
|
||||
): number {
|
||||
return generation * 4 + (phase === 'request' ? 1 : 2);
|
||||
}
|
||||
|
||||
export function targetStopPhasePath(
|
||||
journal: string,
|
||||
generation: number,
|
||||
phase: 'request' | 'outcome',
|
||||
): string {
|
||||
const number = String(targetStopSequence(generation, phase)).padStart(4, '0');
|
||||
return path.join(
|
||||
journal,
|
||||
`${number}-${
|
||||
phase === 'request' ? 'target-stop-decision' : 'target-stop-outcome'
|
||||
}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function legacyRollbackSequence(
|
||||
generation: number,
|
||||
phase: 'request' | 'outcome',
|
||||
): number {
|
||||
return generation * 4 + (phase === 'request' ? 3 : 4);
|
||||
}
|
||||
|
||||
export function legacyRollbackPhasePath(
|
||||
journal: string,
|
||||
generation: number,
|
||||
phase: 'request' | 'outcome',
|
||||
): string {
|
||||
const number = String(legacyRollbackSequence(generation, phase)).padStart(
|
||||
4,
|
||||
'0',
|
||||
);
|
||||
return path.join(
|
||||
journal,
|
||||
`${number}-${
|
||||
phase === 'request'
|
||||
? 'legacy-rollback-start-decision'
|
||||
: 'legacy-rollback-start-outcome'
|
||||
}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function targetRunManualEvidence(
|
||||
reason: TargetRunManualReason,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
reason,
|
||||
uncertainState:
|
||||
reason === 'legacy_silence_unproved'
|
||||
? 'legacy_silence'
|
||||
: reason === 'legacy_restart_preflight_unproved' ||
|
||||
reason === 'legacy_restart_result_unproved'
|
||||
? 'legacy_activity'
|
||||
: 'target_activity',
|
||||
errorDigest: crypto
|
||||
.createHash('sha256')
|
||||
.update(`qinglong3.local-cutover.${reason}`, 'utf8')
|
||||
.digest('hex'),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetRunManualEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
): void {
|
||||
const evidence = object(record.evidence, 'manual-required evidence');
|
||||
exact(
|
||||
evidence,
|
||||
['errorDigest', 'reason', 'uncertainState'],
|
||||
'manual-required evidence',
|
||||
);
|
||||
if (
|
||||
(evidence.reason !== 'legacy_silence_unproved' &&
|
||||
evidence.reason !== 'target_preflight_unproved' &&
|
||||
evidence.reason !== 'target_start_result_unproved' &&
|
||||
evidence.reason !== 'target_restart_result_unproved' &&
|
||||
evidence.reason !== 'target_stop_preflight_unproved' &&
|
||||
evidence.reason !== 'target_stop_result_unproved' &&
|
||||
evidence.reason !== 'legacy_restart_preflight_unproved' &&
|
||||
evidence.reason !== 'legacy_restart_result_unproved') ||
|
||||
(evidence.uncertainState !== 'legacy_silence' &&
|
||||
evidence.uncertainState !== 'legacy_activity' &&
|
||||
evidence.uncertainState !== 'target_activity') ||
|
||||
typeof evidence.errorDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.errorDigest)
|
||||
) {
|
||||
configurationError('manual-required evidence drifted');
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/contract';
|
||||
import {
|
||||
cutoverDigest,
|
||||
type LegacySilenceEvidence,
|
||||
type TargetApplicationBinding,
|
||||
type TargetContainerEvidence,
|
||||
} from '../targetEvidence';
|
||||
import type { LocalDeploymentTargetRunCommand } from './targetRunContract';
|
||||
import type { TargetRunJournalRecord } from './targetRunJournal';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface TargetRunRecordEvidenceContext {
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly commitment: Readonly<LegacySilenceEvidence>;
|
||||
readonly application: Readonly<TargetApplicationBinding>;
|
||||
}
|
||||
|
||||
export interface VerifiedTargetRequestEvidence {
|
||||
readonly targetContainerIdentityDigest: string;
|
||||
readonly targetApplicationBindingDigest: string;
|
||||
readonly previousStartupReceiptDigest: string | null;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function targetRequestEvidence(
|
||||
context: Readonly<TargetRunRecordEvidenceContext>,
|
||||
target: Readonly<TargetContainerEvidence>,
|
||||
previousStartupReceiptDigest: string | null,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
kind:
|
||||
context.command.request.generation === 1
|
||||
? 'target_start'
|
||||
: 'target_restart',
|
||||
legacyCommitmentDigest: context.commitment.commitmentDigest,
|
||||
targetContainerId: context.command.request.expectedTargetContainerId,
|
||||
targetContainerIdentityDigest: target.identityDigest,
|
||||
targetApplicationBindingDigest: target.applicationBindingDigest,
|
||||
targetImageDigest: cutoverDigest(
|
||||
context.command.request.expectedTargetImage,
|
||||
),
|
||||
applicationConfigDigest: context.application.configDigest,
|
||||
previousStartupReceiptDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetRequestEvidence(
|
||||
context: Readonly<TargetRunRecordEvidenceContext>,
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
): Readonly<VerifiedTargetRequestEvidence> {
|
||||
const evidence = object(record.evidence, 'target request evidence');
|
||||
exact(
|
||||
evidence,
|
||||
[
|
||||
'applicationConfigDigest',
|
||||
'kind',
|
||||
'legacyCommitmentDigest',
|
||||
'previousStartupReceiptDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerId',
|
||||
'targetContainerIdentityDigest',
|
||||
'targetImageDigest',
|
||||
],
|
||||
'target request evidence',
|
||||
);
|
||||
if (
|
||||
evidence.kind !==
|
||||
(context.command.request.generation === 1
|
||||
? 'target_start'
|
||||
: 'target_restart') ||
|
||||
evidence.legacyCommitmentDigest !== context.commitment.commitmentDigest ||
|
||||
evidence.targetContainerId !==
|
||||
context.command.request.expectedTargetContainerId ||
|
||||
evidence.targetImageDigest !==
|
||||
cutoverDigest(context.command.request.expectedTargetImage) ||
|
||||
evidence.applicationConfigDigest !== context.application.configDigest ||
|
||||
typeof evidence.targetContainerIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.targetContainerIdentityDigest) ||
|
||||
typeof evidence.targetApplicationBindingDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.targetApplicationBindingDigest) ||
|
||||
(evidence.previousStartupReceiptDigest !== null &&
|
||||
(typeof evidence.previousStartupReceiptDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.previousStartupReceiptDigest)))
|
||||
) {
|
||||
configurationError('target request evidence drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
targetContainerIdentityDigest:
|
||||
evidence.targetContainerIdentityDigest as string,
|
||||
targetApplicationBindingDigest:
|
||||
evidence.targetApplicationBindingDigest as string,
|
||||
previousStartupReceiptDigest: evidence.previousStartupReceiptDigest as
|
||||
| string
|
||||
| null,
|
||||
});
|
||||
}
|
||||
|
||||
export function targetActiveEvidence(
|
||||
context: Readonly<TargetRunRecordEvidenceContext>,
|
||||
target: Readonly<TargetContainerEvidence>,
|
||||
startupReceiptDigest: string,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
legacyCommitmentDigest: context.commitment.commitmentDigest,
|
||||
targetContainerIdentityDigest: target.identityDigest,
|
||||
targetApplicationBindingDigest: target.applicationBindingDigest,
|
||||
startupReceiptDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetActiveEvidence(
|
||||
context: Readonly<TargetRunRecordEvidenceContext>,
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
request: Readonly<VerifiedTargetRequestEvidence>,
|
||||
): string {
|
||||
const evidence = object(record.evidence, 'target active evidence');
|
||||
exact(
|
||||
evidence,
|
||||
[
|
||||
'legacyCommitmentDigest',
|
||||
'startupReceiptDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerIdentityDigest',
|
||||
],
|
||||
'target active evidence',
|
||||
);
|
||||
if (
|
||||
evidence.legacyCommitmentDigest !== context.commitment.commitmentDigest ||
|
||||
evidence.targetContainerIdentityDigest !==
|
||||
request.targetContainerIdentityDigest ||
|
||||
evidence.targetApplicationBindingDigest !==
|
||||
request.targetApplicationBindingDigest ||
|
||||
typeof evidence.startupReceiptDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.startupReceiptDigest) ||
|
||||
evidence.startupReceiptDigest === request.previousStartupReceiptDigest
|
||||
) {
|
||||
configurationError('target active evidence drifted');
|
||||
}
|
||||
return evidence.startupReceiptDigest;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import type { LocalDeploymentTargetReconciliationDisposition } from './targetStopContract';
|
||||
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
|
||||
import { cutoverDigest } from './targetEvidence';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const UNKNOWN_DIGEST = '0'.repeat(64);
|
||||
const HASH_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
export interface TargetDataReconciliationEvidence {
|
||||
readonly disposition: LocalDeploymentTargetReconciliationDisposition;
|
||||
readonly targetMatchesActivation: boolean | null;
|
||||
readonly sourceMatchesRecovery: boolean | null;
|
||||
readonly targetSidecarsClear: boolean | null;
|
||||
readonly sourceSidecarsClear: boolean | null;
|
||||
readonly targetFileIdentityDigest: string;
|
||||
readonly sourceFileIdentityDigest: string;
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
interface FileEvidence {
|
||||
readonly sha256: string;
|
||||
readonly identityDigest: string;
|
||||
readonly sidecarsClear: boolean;
|
||||
readonly pathDigest: string;
|
||||
readonly device: string;
|
||||
readonly inode: string;
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new Error('activation must be an object');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function textDigest(value: string): string {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function fileHash(descriptor: number): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES);
|
||||
try {
|
||||
for (;;) {
|
||||
const count = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null);
|
||||
if (count === 0) return hash.digest('hex');
|
||||
hash.update(buffer.subarray(0, count));
|
||||
}
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function sameFileStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.mode === right.mode &&
|
||||
left.nlink === right.nlink &&
|
||||
left.uid === right.uid &&
|
||||
left.size === right.size &&
|
||||
left.mtimeNs === right.mtimeNs &&
|
||||
left.ctimeNs === right.ctimeNs
|
||||
);
|
||||
}
|
||||
|
||||
function sidecarSnapshot(filePath: string): readonly boolean[] {
|
||||
return Object.freeze(
|
||||
['-wal', '-shm', '-journal'].map((suffix) =>
|
||||
fs.existsSync(`${filePath}${suffix}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function fileEvidence(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): Readonly<FileEvidence> {
|
||||
const pathStat = fs.lstatSync(filePath, { bigint: true });
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const before = fs.fstatSync(descriptor, { bigint: true });
|
||||
const sidecarsBefore = sidecarSnapshot(filePath);
|
||||
if (
|
||||
!pathStat.isFile() ||
|
||||
pathStat.isSymbolicLink() ||
|
||||
!sameFileStat(pathStat, before) ||
|
||||
before.uid !== BigInt(uid) ||
|
||||
before.nlink !== 1n ||
|
||||
(before.mode & 0o077n) !== 0n ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(Number.MAX_SAFE_INTEGER)
|
||||
) {
|
||||
throw new Error(`${label} identity is invalid`);
|
||||
}
|
||||
const sha256 = fileHash(descriptor);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
const sidecarsAfter = sidecarSnapshot(filePath);
|
||||
if (
|
||||
!sameFileStat(before, after) ||
|
||||
sidecarsBefore.some((value, index) => value !== sidecarsAfter[index])
|
||||
) {
|
||||
throw new Error(`${label} changed while evidence was collected`);
|
||||
}
|
||||
const sidecarsClear = sidecarsAfter.every((value) => !value);
|
||||
const pathDigest = textDigest(filePath);
|
||||
return Object.freeze({
|
||||
sha256,
|
||||
sidecarsClear,
|
||||
pathDigest,
|
||||
device: after.dev.toString(),
|
||||
inode: after.ino.toString(),
|
||||
identityDigest: cutoverDigest({
|
||||
pathDigest,
|
||||
device: after.dev.toString(),
|
||||
inode: after.ino.toString(),
|
||||
bytes: after.size.toString(),
|
||||
modifiedAtNs: after.mtimeNs.toString(),
|
||||
sha256,
|
||||
sidecarsClear,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function evidence(
|
||||
payload: Omit<TargetDataReconciliationEvidence, 'evidenceDigest'>,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
return Object.freeze({ ...payload, evidenceDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
export function readTargetDataReconciliationEvidence(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
uid: number,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
try {
|
||||
const activation = object(
|
||||
readPrivateLocalCommandFile(command.request.activationPath),
|
||||
);
|
||||
const { activationDigest, ...payload } = activation;
|
||||
if (
|
||||
activation.schemaVersion !== 1 ||
|
||||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
||||
activation.state !== 'prepared' ||
|
||||
activation.profile !== command.request.profile ||
|
||||
activation.sourcePathDigest !==
|
||||
textDigest(command.request.legacySourcePath) ||
|
||||
activation.targetPathDigest !==
|
||||
textDigest(command.request.targetDatabasePath) ||
|
||||
activationDigest !== command.request.expectedActivationDigest ||
|
||||
typeof activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activationDigest) ||
|
||||
typeof activation.targetSha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.targetSha256) ||
|
||||
typeof activation.recoverySha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.recoverySha256) ||
|
||||
typeof activation.targetDevice !== 'string' ||
|
||||
typeof activation.targetInode !== 'string' ||
|
||||
cutoverDigest(payload) !== activationDigest
|
||||
) {
|
||||
throw new Error('activation identity drifted');
|
||||
}
|
||||
const target = fileEvidence(
|
||||
command.request.targetDatabasePath,
|
||||
uid,
|
||||
'target database',
|
||||
);
|
||||
const source = fileEvidence(
|
||||
command.request.legacySourcePath,
|
||||
uid,
|
||||
'legacy source database',
|
||||
);
|
||||
if (
|
||||
target.pathDigest !== activation.targetPathDigest ||
|
||||
target.device !== activation.targetDevice ||
|
||||
target.inode !== activation.targetInode
|
||||
) {
|
||||
throw new Error('target database stable identity drifted');
|
||||
}
|
||||
const targetMatchesActivation = target.sha256 === activation.targetSha256;
|
||||
const sourceMatchesRecovery = source.sha256 === activation.recoverySha256;
|
||||
const disposition =
|
||||
!targetMatchesActivation || !target.sidecarsClear
|
||||
? ('reconciliation_required' as const)
|
||||
: sourceMatchesRecovery && source.sidecarsClear
|
||||
? ('rollback_candidate' as const)
|
||||
: ('manual_review' as const);
|
||||
return evidence({
|
||||
disposition,
|
||||
targetMatchesActivation,
|
||||
sourceMatchesRecovery,
|
||||
targetSidecarsClear: target.sidecarsClear,
|
||||
sourceSidecarsClear: source.sidecarsClear,
|
||||
targetFileIdentityDigest: target.identityDigest,
|
||||
sourceFileIdentityDigest: source.identityDigest,
|
||||
});
|
||||
} catch {
|
||||
return evidence({
|
||||
disposition: 'manual_review',
|
||||
targetMatchesActivation: null,
|
||||
sourceMatchesRecovery: null,
|
||||
targetSidecarsClear: null,
|
||||
sourceSidecarsClear: null,
|
||||
targetFileIdentityDigest: UNKNOWN_DIGEST,
|
||||
sourceFileIdentityDigest: UNKNOWN_DIGEST,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyTargetDataReconciliationEvidence(
|
||||
value: unknown,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
const candidate = object(value);
|
||||
const keys = Object.keys(candidate).sort();
|
||||
const expected = [
|
||||
'disposition',
|
||||
'evidenceDigest',
|
||||
'sourceFileIdentityDigest',
|
||||
'sourceMatchesRecovery',
|
||||
'sourceSidecarsClear',
|
||||
'targetFileIdentityDigest',
|
||||
'targetMatchesActivation',
|
||||
'targetSidecarsClear',
|
||||
].sort();
|
||||
const { evidenceDigest, ...payload } = candidate;
|
||||
if (
|
||||
JSON.stringify(keys) !== JSON.stringify(expected) ||
|
||||
(candidate.disposition !== 'rollback_candidate' &&
|
||||
candidate.disposition !== 'reconciliation_required' &&
|
||||
candidate.disposition !== 'manual_review') ||
|
||||
(candidate.targetMatchesActivation !== null &&
|
||||
typeof candidate.targetMatchesActivation !== 'boolean') ||
|
||||
(candidate.sourceMatchesRecovery !== null &&
|
||||
typeof candidate.sourceMatchesRecovery !== 'boolean') ||
|
||||
(candidate.targetSidecarsClear !== null &&
|
||||
typeof candidate.targetSidecarsClear !== 'boolean') ||
|
||||
(candidate.sourceSidecarsClear !== null &&
|
||||
typeof candidate.sourceSidecarsClear !== 'boolean') ||
|
||||
typeof candidate.targetFileIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(candidate.targetFileIdentityDigest) ||
|
||||
typeof candidate.sourceFileIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(candidate.sourceFileIdentityDigest) ||
|
||||
typeof evidenceDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidenceDigest) ||
|
||||
cutoverDigest(payload) !== evidenceDigest
|
||||
) {
|
||||
throw new Error('target data reconciliation evidence drifted');
|
||||
}
|
||||
return candidate as unknown as Readonly<TargetDataReconciliationEvidence>;
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
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 START_TICKS_PATTERN = /^[1-9][0-9]{0,19}$/;
|
||||
const NODE_VERSION_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
|
||||
export interface LegacySilenceEvidence {
|
||||
readonly commitmentDigest: string;
|
||||
readonly legacyContainerIdentityDigest: string;
|
||||
readonly legacySourceBindingDigest: string;
|
||||
}
|
||||
|
||||
export interface TargetApplicationBinding {
|
||||
readonly configDigest: string;
|
||||
readonly targetActivationPath: string;
|
||||
readonly targetLegacySourcePath: string;
|
||||
readonly targetDatabasePath: string;
|
||||
readonly targetRecoveryPath: string;
|
||||
readonly targetManifestPath: string;
|
||||
}
|
||||
|
||||
export interface TargetContainerEvidence {
|
||||
readonly identityDigest: string;
|
||||
readonly applicationBindingDigest: string;
|
||||
}
|
||||
|
||||
export interface TargetStartupReceiptEvidence {
|
||||
readonly digest: string;
|
||||
readonly bootId: string;
|
||||
readonly activeBootAgeMs: number;
|
||||
readonly processId: number;
|
||||
readonly processStartTicks: string;
|
||||
readonly nodeExecutable: string;
|
||||
}
|
||||
|
||||
export interface TargetStartupReceiptCommandIdentity {
|
||||
readonly request: Readonly<{
|
||||
applicationConfigPath: string;
|
||||
instanceId: string;
|
||||
profile: 'edge' | 'standalone';
|
||||
}>;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
export function cutoverDigest(value: unknown): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function textDigest(value: string): string {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function endpointDigest(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): string {
|
||||
return cutoverDigest({
|
||||
executable: command.options.dockerExecutable,
|
||||
socketPath: command.options.dockerSocketPath,
|
||||
});
|
||||
}
|
||||
|
||||
export function legacyCommitmentPath(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): string {
|
||||
return path.join(
|
||||
command.options.deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
command.request.cutoverId,
|
||||
'0002-legacy-stopped.json',
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyTargetRunActivation(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): void {
|
||||
let sourceStat: fs.Stats;
|
||||
try {
|
||||
sourceStat = fs.lstatSync(command.request.legacySourcePath);
|
||||
} catch (error) {
|
||||
configurationError('legacy source is unavailable', error);
|
||||
}
|
||||
if (
|
||||
!sourceStat.isFile() ||
|
||||
sourceStat.isSymbolicLink() ||
|
||||
fs.realpathSync(command.request.legacySourcePath) !==
|
||||
command.request.legacySourcePath
|
||||
) {
|
||||
configurationError('legacy source must be a canonical regular file');
|
||||
}
|
||||
const activation = object(
|
||||
readPrivateLocalCommandFile(command.request.activationPath),
|
||||
'activation',
|
||||
);
|
||||
exact(
|
||||
activation,
|
||||
[
|
||||
'activationDigest',
|
||||
'adoptionManifestDigest',
|
||||
'createdAtMs',
|
||||
'kind',
|
||||
'planDigest',
|
||||
'profile',
|
||||
'recoverySha256',
|
||||
'schemaVersion',
|
||||
'sourcePathDigest',
|
||||
'state',
|
||||
'targetDevice',
|
||||
'targetInode',
|
||||
'targetPathDigest',
|
||||
'targetSha256',
|
||||
],
|
||||
'activation',
|
||||
);
|
||||
const { activationDigest, ...payload } = activation;
|
||||
if (
|
||||
activation.schemaVersion !== 1 ||
|
||||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
||||
activation.state !== 'prepared' ||
|
||||
activation.profile !== command.request.profile ||
|
||||
activation.sourcePathDigest !==
|
||||
textDigest(command.request.legacySourcePath) ||
|
||||
activationDigest !== command.request.expectedActivationDigest ||
|
||||
typeof activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activationDigest) ||
|
||||
cutoverDigest(payload) !== activationDigest
|
||||
) {
|
||||
configurationError('activation does not match the target run request');
|
||||
}
|
||||
}
|
||||
|
||||
export function readLegacySilenceEvidence(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): Readonly<LegacySilenceEvidence> {
|
||||
const commitment = object(
|
||||
readPrivateLocalCommandFile(legacyCommitmentPath(command)),
|
||||
'legacy silence commitment',
|
||||
);
|
||||
exact(
|
||||
commitment,
|
||||
[
|
||||
'activationDigest',
|
||||
'commitmentDigest',
|
||||
'controller',
|
||||
'cutoverId',
|
||||
'instanceId',
|
||||
'kind',
|
||||
'observedAtMs',
|
||||
'previousRecordDigest',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'legacy silence commitment',
|
||||
);
|
||||
const controller = object(commitment.controller, 'commitment controller');
|
||||
exact(
|
||||
controller,
|
||||
[
|
||||
'endpointDigest',
|
||||
'kind',
|
||||
'legacyContainerId',
|
||||
'legacyContainerIdentityDigest',
|
||||
'legacySourceBindingDigest',
|
||||
],
|
||||
'commitment controller',
|
||||
);
|
||||
const { commitmentDigest, ...payload } = commitment;
|
||||
if (
|
||||
commitment.schemaVersion !== 1 ||
|
||||
commitment.kind !== 'qinglong3-local-legacy-silence-commitment' ||
|
||||
commitment.state !== 'legacy_stopped' ||
|
||||
commitment.cutoverId !== command.request.cutoverId ||
|
||||
commitment.profile !== command.request.profile ||
|
||||
commitment.instanceId !== command.request.instanceId ||
|
||||
commitment.activationDigest !== command.request.expectedActivationDigest ||
|
||||
commitmentDigest !== command.request.expectedLegacyCommitmentDigest ||
|
||||
typeof commitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(commitmentDigest) ||
|
||||
controller.kind !== 'docker' ||
|
||||
controller.endpointDigest !== endpointDigest(command) ||
|
||||
controller.legacyContainerId !==
|
||||
command.request.expectedLegacyContainerId ||
|
||||
typeof controller.legacyContainerIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(controller.legacyContainerIdentityDigest) ||
|
||||
typeof controller.legacySourceBindingDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(controller.legacySourceBindingDigest) ||
|
||||
cutoverDigest(payload) !== commitmentDigest
|
||||
) {
|
||||
configurationError('legacy silence commitment does not match target run');
|
||||
}
|
||||
return Object.freeze({
|
||||
commitmentDigest,
|
||||
legacyContainerIdentityDigest:
|
||||
controller.legacyContainerIdentityDigest as string,
|
||||
legacySourceBindingDigest: controller.legacySourceBindingDigest as string,
|
||||
});
|
||||
}
|
||||
|
||||
export function readTargetApplicationBinding(
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): Readonly<TargetApplicationBinding> {
|
||||
const config = object(
|
||||
readPrivateLocalCommandFile(command.request.applicationConfigPath),
|
||||
'target application configuration',
|
||||
);
|
||||
const storage = object(config.storage, 'target storage configuration');
|
||||
const cutover = object(config.cutover, 'target cutover configuration');
|
||||
if (
|
||||
config.schema !== 'qinglong/local-application-process@v3' ||
|
||||
config.profile !== command.request.profile ||
|
||||
config.instanceId !== command.request.instanceId ||
|
||||
storage.mode !== 'adopted' ||
|
||||
storage.expectedActivationDigest !==
|
||||
command.request.expectedActivationDigest ||
|
||||
typeof storage.sourcePath !== 'string' ||
|
||||
!path.isAbsolute(storage.sourcePath) ||
|
||||
path.normalize(storage.sourcePath) !== storage.sourcePath ||
|
||||
typeof storage.activationPath !== 'string' ||
|
||||
!path.isAbsolute(storage.activationPath) ||
|
||||
path.normalize(storage.activationPath) !== storage.activationPath ||
|
||||
typeof storage.targetPath !== 'string' ||
|
||||
!path.isAbsolute(storage.targetPath) ||
|
||||
path.normalize(storage.targetPath) !== storage.targetPath ||
|
||||
typeof storage.recoveryPath !== 'string' ||
|
||||
!path.isAbsolute(storage.recoveryPath) ||
|
||||
path.normalize(storage.recoveryPath) !== storage.recoveryPath ||
|
||||
typeof storage.manifestPath !== 'string' ||
|
||||
!path.isAbsolute(storage.manifestPath) ||
|
||||
path.normalize(storage.manifestPath) !== storage.manifestPath ||
|
||||
cutover.cutoverId !== command.request.cutoverId ||
|
||||
cutover.commitmentPath !== command.request.expectedTargetCommitmentPath ||
|
||||
cutover.expectedCommitmentDigest !==
|
||||
command.request.expectedLegacyCommitmentDigest
|
||||
) {
|
||||
configurationError('target application configuration binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
configDigest: cutoverDigest(config),
|
||||
targetActivationPath: storage.activationPath,
|
||||
targetLegacySourcePath: storage.sourcePath,
|
||||
targetDatabasePath: storage.targetPath,
|
||||
targetRecoveryPath: storage.recoveryPath,
|
||||
targetManifestPath: storage.manifestPath,
|
||||
});
|
||||
}
|
||||
|
||||
interface DockerMount {
|
||||
readonly source: string;
|
||||
readonly destination: string;
|
||||
readonly readWrite: boolean;
|
||||
}
|
||||
|
||||
function validMount(value: unknown): DockerMount | undefined {
|
||||
const mount = object(value, 'target container mount');
|
||||
if (
|
||||
mount.Type !== 'bind' ||
|
||||
typeof mount.Source !== 'string' ||
|
||||
typeof mount.Destination !== 'string' ||
|
||||
typeof mount.RW !== 'boolean' ||
|
||||
!path.isAbsolute(mount.Source) ||
|
||||
!path.isAbsolute(mount.Destination) ||
|
||||
path.normalize(mount.Source) !== mount.Source ||
|
||||
path.normalize(mount.Destination) !== mount.Destination
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
source: mount.Source,
|
||||
destination: mount.Destination,
|
||||
readWrite: mount.RW,
|
||||
});
|
||||
}
|
||||
|
||||
function mappedMount(
|
||||
mounts: readonly DockerMount[],
|
||||
hostPath: string,
|
||||
targetPath: string,
|
||||
label: string,
|
||||
): DockerMount {
|
||||
const matches = mounts.filter((mount) => {
|
||||
const relative = path.relative(mount.source, hostPath);
|
||||
return (
|
||||
!relative.startsWith('..') &&
|
||||
!path.isAbsolute(relative) &&
|
||||
path.join(mount.destination, relative) === targetPath
|
||||
);
|
||||
});
|
||||
if (matches.length !== 1 || matches[0]?.readWrite !== true) {
|
||||
configurationError(`${label} must have one read-write bind mapping`);
|
||||
}
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
function parsedContainer(
|
||||
output: string,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (error) {
|
||||
configurationError(`${label} inspection is invalid`, error);
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
||||
configurationError(`${label} inspection count is invalid`);
|
||||
}
|
||||
return object(parsed[0], label);
|
||||
}
|
||||
|
||||
export function parseStoppedLegacyEvidence(
|
||||
output: string,
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): Readonly<{
|
||||
identityDigest: string;
|
||||
sourceBindingDigest: string;
|
||||
}> {
|
||||
const container = parsedContainer(output, 'legacy container');
|
||||
const state = object(container.State, 'legacy container state');
|
||||
const hostConfig = object(
|
||||
container.HostConfig,
|
||||
'legacy container host config',
|
||||
);
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'legacy container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'legacy container config');
|
||||
if (
|
||||
container.Id !== command.request.expectedLegacyContainerId ||
|
||||
state.Running !== false ||
|
||||
state.Restarting !== false ||
|
||||
state.Paused !== false ||
|
||||
state.Pid !== 0 ||
|
||||
(state.Status !== 'exited' && state.Status !== 'dead') ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no') ||
|
||||
typeof container.Created !== 'string' ||
|
||||
typeof container.Name !== 'string' ||
|
||||
typeof config.Image !== 'string' ||
|
||||
!Array.isArray(container.Mounts)
|
||||
) {
|
||||
configurationError('legacy container silence cannot be reverified');
|
||||
}
|
||||
const mounts = container.Mounts.flatMap((value) => {
|
||||
const candidate = validMount(value);
|
||||
return candidate === undefined ? [] : [candidate];
|
||||
});
|
||||
const sourceMount = mappedMount(
|
||||
mounts,
|
||||
command.request.legacySourcePath,
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
'legacy source',
|
||||
);
|
||||
return Object.freeze({
|
||||
identityDigest: cutoverDigest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
}),
|
||||
sourceBindingDigest: cutoverDigest({
|
||||
sourcePathDigest: textDigest(command.request.legacySourcePath),
|
||||
databasePathDigest: textDigest(
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
),
|
||||
mountSourceDigest: textDigest(sourceMount.source),
|
||||
mountDestinationDigest: textDigest(sourceMount.destination),
|
||||
readWrite: sourceMount.readWrite,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseActiveLegacyEvidence(
|
||||
output: string,
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
): Readonly<{
|
||||
identityDigest: string;
|
||||
sourceBindingDigest: string;
|
||||
}> {
|
||||
const container = parsedContainer(output, 'legacy container');
|
||||
const state = object(container.State, 'legacy container state');
|
||||
const hostConfig = object(
|
||||
container.HostConfig,
|
||||
'legacy container host config',
|
||||
);
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'legacy container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'legacy container config');
|
||||
if (
|
||||
container.Id !== command.request.expectedLegacyContainerId ||
|
||||
state.Running !== true ||
|
||||
state.Restarting !== false ||
|
||||
state.Paused !== false ||
|
||||
!Number.isSafeInteger(state.Pid) ||
|
||||
(state.Pid as number) < 1 ||
|
||||
state.Status !== 'running' ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no') ||
|
||||
typeof container.Created !== 'string' ||
|
||||
typeof container.Name !== 'string' ||
|
||||
typeof config.Image !== 'string' ||
|
||||
!Array.isArray(container.Mounts)
|
||||
) {
|
||||
configurationError('legacy container running state cannot be proved');
|
||||
}
|
||||
const mounts = container.Mounts.flatMap((value) => {
|
||||
const candidate = validMount(value);
|
||||
return candidate === undefined ? [] : [candidate];
|
||||
});
|
||||
const sourceMount = mappedMount(
|
||||
mounts,
|
||||
command.request.legacySourcePath,
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
'legacy source',
|
||||
);
|
||||
return Object.freeze({
|
||||
identityDigest: cutoverDigest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
}),
|
||||
sourceBindingDigest: cutoverDigest({
|
||||
sourcePathDigest: textDigest(command.request.legacySourcePath),
|
||||
databasePathDigest: textDigest(
|
||||
command.request.expectedLegacyDatabasePath,
|
||||
),
|
||||
mountSourceDigest: textDigest(sourceMount.source),
|
||||
mountDestinationDigest: textDigest(sourceMount.destination),
|
||||
readWrite: sourceMount.readWrite,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseTargetContainerEvidence(
|
||||
output: string,
|
||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||
application: Readonly<TargetApplicationBinding>,
|
||||
expectedState: 'stopped' | 'active',
|
||||
): Readonly<TargetContainerEvidence> {
|
||||
const container = parsedContainer(output, 'target container');
|
||||
const state = object(container.State, 'target container state');
|
||||
const hostConfig = object(
|
||||
container.HostConfig,
|
||||
'target container host config',
|
||||
);
|
||||
const restartPolicy = object(
|
||||
hostConfig.RestartPolicy,
|
||||
'target container restart policy',
|
||||
);
|
||||
const config = object(container.Config, 'target container config');
|
||||
const stopped =
|
||||
state.Running === false &&
|
||||
state.Restarting === false &&
|
||||
state.Paused === false &&
|
||||
state.Pid === 0 &&
|
||||
(state.Status === 'created' ||
|
||||
state.Status === 'exited' ||
|
||||
state.Status === 'dead');
|
||||
const active =
|
||||
state.Running === true &&
|
||||
state.Restarting === false &&
|
||||
state.Paused === false &&
|
||||
Number.isSafeInteger(state.Pid) &&
|
||||
(state.Pid as number) > 0 &&
|
||||
state.Status === 'running';
|
||||
if (
|
||||
container.Id !== command.request.expectedTargetContainerId ||
|
||||
(expectedState === 'stopped' ? !stopped : !active) ||
|
||||
(restartPolicy.Name !== '' && restartPolicy.Name !== 'no') ||
|
||||
hostConfig.ReadonlyRootfs !== true ||
|
||||
hostConfig.Privileged === true ||
|
||||
!Array.isArray(hostConfig.SecurityOpt) ||
|
||||
!hostConfig.SecurityOpt.includes('no-new-privileges') ||
|
||||
config.Image !== command.request.expectedTargetImage ||
|
||||
JSON.stringify(config.Cmd) !==
|
||||
JSON.stringify([
|
||||
'--config',
|
||||
command.request.expectedTargetApplicationConfigPath,
|
||||
]) ||
|
||||
typeof container.Created !== 'string' ||
|
||||
typeof container.Name !== 'string' ||
|
||||
!Array.isArray(container.Mounts)
|
||||
) {
|
||||
configurationError(`target container ${expectedState} evidence is invalid`);
|
||||
}
|
||||
const mounts = container.Mounts.flatMap((value) => {
|
||||
const candidate = validMount(value);
|
||||
return candidate === undefined ? [] : [candidate];
|
||||
});
|
||||
const commitmentMount = mappedMount(
|
||||
mounts,
|
||||
legacyCommitmentPath(command),
|
||||
command.request.expectedTargetCommitmentPath,
|
||||
'target commitment',
|
||||
);
|
||||
const configMount = mappedMount(
|
||||
mounts,
|
||||
command.request.applicationConfigPath,
|
||||
command.request.expectedTargetApplicationConfigPath,
|
||||
'target application configuration',
|
||||
);
|
||||
const activationMount = mappedMount(
|
||||
mounts,
|
||||
command.request.activationPath,
|
||||
application.targetActivationPath,
|
||||
'target activation',
|
||||
);
|
||||
const sourceMount = mappedMount(
|
||||
mounts,
|
||||
command.request.legacySourcePath,
|
||||
application.targetLegacySourcePath,
|
||||
'target legacy source',
|
||||
);
|
||||
const databaseMount = mappedMount(
|
||||
mounts,
|
||||
command.request.targetDatabasePath,
|
||||
application.targetDatabasePath,
|
||||
'target database',
|
||||
);
|
||||
const recoveryMount = mappedMount(
|
||||
mounts,
|
||||
command.request.recoveryPath,
|
||||
application.targetRecoveryPath,
|
||||
'target recovery database',
|
||||
);
|
||||
const manifestMount = mappedMount(
|
||||
mounts,
|
||||
command.request.manifestPath,
|
||||
application.targetManifestPath,
|
||||
'target adoption manifest',
|
||||
);
|
||||
return Object.freeze({
|
||||
identityDigest: cutoverDigest({
|
||||
containerId: container.Id,
|
||||
created: container.Created,
|
||||
image: config.Image,
|
||||
name: container.Name,
|
||||
}),
|
||||
applicationBindingDigest: cutoverDigest({
|
||||
configDigest: application.configDigest,
|
||||
configMount,
|
||||
commitmentMount,
|
||||
activationMount,
|
||||
sourceMount,
|
||||
databaseMount,
|
||||
recoveryMount,
|
||||
manifestMount,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function readTargetStartupReceipt(
|
||||
command: Readonly<TargetStartupReceiptCommandIdentity>,
|
||||
): Readonly<TargetStartupReceiptEvidence> | null {
|
||||
const receiptPath = `${command.request.applicationConfigPath}.active.json`;
|
||||
if (!fs.existsSync(receiptPath)) return null;
|
||||
const receipt = object(
|
||||
readPrivateLocalCommandFile(receiptPath),
|
||||
'target startup receipt',
|
||||
);
|
||||
exact(
|
||||
receipt,
|
||||
[
|
||||
'activeBootAgeMs',
|
||||
'aiStatus',
|
||||
'bootId',
|
||||
'instanceId',
|
||||
'nodeExecutable',
|
||||
'nodeVersion',
|
||||
'processId',
|
||||
'processStartTicks',
|
||||
'profile',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'sha256',
|
||||
],
|
||||
'target startup receipt',
|
||||
);
|
||||
const { sha256, ...payload } = receipt;
|
||||
const receiptDigest = crypto
|
||||
.createHash('sha256')
|
||||
.update('qinglong.local-application-startup-receipt.v1\0', 'utf8')
|
||||
.update(JSON.stringify(payload), 'utf8')
|
||||
.digest('hex');
|
||||
if (
|
||||
receipt.schemaVersion !== 1 ||
|
||||
receipt.schema !== 'qinglong/local-application-startup-receipt@v1' ||
|
||||
receipt.instanceId !== command.request.instanceId ||
|
||||
receipt.profile !== command.request.profile ||
|
||||
(receipt.aiStatus !== 'deployment_excluded' &&
|
||||
receipt.aiStatus !== 'schema_absent' &&
|
||||
receipt.aiStatus !== 'inactive' &&
|
||||
receipt.aiStatus !== 'active') ||
|
||||
typeof receipt.bootId !== 'string' ||
|
||||
!BOOT_ID_PATTERN.test(receipt.bootId) ||
|
||||
!Number.isSafeInteger(receipt.activeBootAgeMs) ||
|
||||
(receipt.activeBootAgeMs as number) < 0 ||
|
||||
!Number.isSafeInteger(receipt.processId) ||
|
||||
(receipt.processId as number) < 1 ||
|
||||
typeof receipt.processStartTicks !== 'string' ||
|
||||
!START_TICKS_PATTERN.test(receipt.processStartTicks) ||
|
||||
typeof receipt.nodeExecutable !== 'string' ||
|
||||
!path.isAbsolute(receipt.nodeExecutable) ||
|
||||
path.normalize(receipt.nodeExecutable) !== receipt.nodeExecutable ||
|
||||
typeof receipt.nodeVersion !== 'string' ||
|
||||
!NODE_VERSION_PATTERN.test(receipt.nodeVersion) ||
|
||||
typeof sha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(sha256) ||
|
||||
receiptDigest !== sha256
|
||||
) {
|
||||
configurationError('target startup receipt is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
digest: receiptDigest,
|
||||
bootId: receipt.bootId,
|
||||
activeBootAgeMs: receipt.activeBootAgeMs as number,
|
||||
processId: receipt.processId as number,
|
||||
processStartTicks: receipt.processStartTicks,
|
||||
nodeExecutable: receipt.nodeExecutable,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
runLocalDeploymentDockerCommand,
|
||||
validateLocalDeploymentDockerSocket,
|
||||
type LocalDeploymentDockerRunner,
|
||||
} from '../foundation/docker';
|
||||
import { validatePrivateDirectory } from '../foundation/files';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
readLocalCutoverInstanceHead,
|
||||
} from './instanceLineage';
|
||||
import { readTargetDataReconciliationEvidence } from './targetDataEvidence';
|
||||
import {
|
||||
legacyCommitmentPath,
|
||||
parseTargetContainerEvidence,
|
||||
readLegacySilenceEvidence,
|
||||
readTargetApplicationBinding,
|
||||
type LegacySilenceEvidence,
|
||||
type TargetApplicationBinding,
|
||||
} from './targetEvidence';
|
||||
import {
|
||||
targetStopRunCommand,
|
||||
normalizeLocalDeploymentTargetStopCommand,
|
||||
type LocalDeploymentTargetReconciliationDisposition,
|
||||
type LocalDeploymentTargetStopCommand,
|
||||
type LocalDeploymentTargetStopResult,
|
||||
} from './targetStopContract';
|
||||
import {
|
||||
publishTargetRunJournalRecord,
|
||||
readTargetRunJournalRecord,
|
||||
targetRunJournalRecord,
|
||||
targetRunManualEvidence,
|
||||
targetRunPhasePath,
|
||||
targetRunSequence,
|
||||
targetStopPhasePath,
|
||||
targetStopSequence,
|
||||
verifyTargetRunManualEvidence,
|
||||
type TargetRunJournalRecord,
|
||||
type TargetRunManualReason,
|
||||
} from './target-run/targetRunJournal';
|
||||
import {
|
||||
targetStoppedEvidence,
|
||||
targetStopRequestEvidence,
|
||||
verifyTargetStoppedEvidence,
|
||||
verifyTargetStopRequestEvidence,
|
||||
type TargetStopActiveEvidence,
|
||||
} from './targetStopRecordEvidence';
|
||||
import {
|
||||
verifyTargetActiveEvidence,
|
||||
verifyTargetRequestEvidence,
|
||||
} from './target-run/targetRunRecordEvidence';
|
||||
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
|
||||
|
||||
export interface LocalDeploymentTargetStopDependencies {
|
||||
readonly runDocker?: LocalDeploymentDockerRunner;
|
||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
||||
readonly afterBarrier?: () => void;
|
||||
}
|
||||
|
||||
interface StopContext {
|
||||
readonly stopCommand: Readonly<LocalDeploymentTargetStopCommand>;
|
||||
readonly command: Readonly<LocalDeploymentTargetRunCommand>;
|
||||
readonly journal: string;
|
||||
readonly commitment: Readonly<LegacySilenceEvidence>;
|
||||
readonly application: Readonly<TargetApplicationBinding>;
|
||||
readonly uid: number;
|
||||
}
|
||||
|
||||
interface PriorActive extends TargetStopActiveEvidence {
|
||||
readonly record: Readonly<TargetRunJournalRecord>;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
|
||||
function priorActive(context: Readonly<StopContext>): Readonly<PriorActive> {
|
||||
const generation = context.command.request.generation;
|
||||
const request = readTargetRunJournalRecord(
|
||||
targetRunPhasePath(context.journal, generation, 'request'),
|
||||
context,
|
||||
{
|
||||
sequence: targetRunSequence(generation, 'request'),
|
||||
generation,
|
||||
states: [
|
||||
generation === 1
|
||||
? 'target_start_requested'
|
||||
: 'target_restart_requested',
|
||||
],
|
||||
},
|
||||
);
|
||||
const requestEvidence = verifyTargetRequestEvidence(context, request);
|
||||
const active = readTargetRunJournalRecord(
|
||||
targetRunPhasePath(context.journal, generation, 'outcome'),
|
||||
context,
|
||||
{
|
||||
sequence: targetRunSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_active'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
},
|
||||
);
|
||||
const startupReceiptDigest = verifyTargetActiveEvidence(
|
||||
context,
|
||||
active,
|
||||
requestEvidence,
|
||||
);
|
||||
return Object.freeze({
|
||||
record: active,
|
||||
activeRecordDigest: active.recordDigest,
|
||||
targetContainerIdentityDigest:
|
||||
requestEvidence.targetContainerIdentityDigest,
|
||||
targetApplicationBindingDigest:
|
||||
requestEvidence.targetApplicationBindingDigest,
|
||||
startupReceiptDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function result(
|
||||
context: Readonly<StopContext>,
|
||||
status: 'prepared' | 'existing',
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
reconciliation: LocalDeploymentTargetReconciliationDisposition,
|
||||
): Readonly<LocalDeploymentTargetStopResult> {
|
||||
const head = advanceLocalCutoverInstanceHead(
|
||||
context.command,
|
||||
context.uid,
|
||||
record.state as 'target_stopped' | 'manual_required',
|
||||
record.generation,
|
||||
record.recordDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: context.stopCommand.operation,
|
||||
status,
|
||||
state: record.state as 'target_stopped' | 'manual_required',
|
||||
cutoverId: context.command.request.cutoverId,
|
||||
generation: context.command.request.generation,
|
||||
reconciliation,
|
||||
recordDigest: record.recordDigest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function publishManual(
|
||||
context: Readonly<StopContext>,
|
||||
filePath: string,
|
||||
recordSequence: number,
|
||||
previousRecordDigest: string,
|
||||
reason: TargetRunManualReason,
|
||||
): Readonly<LocalDeploymentTargetStopResult> {
|
||||
const record = targetRunJournalRecord(
|
||||
context.command,
|
||||
recordSequence,
|
||||
'manual_required',
|
||||
previousRecordDigest,
|
||||
targetRunManualEvidence(reason),
|
||||
);
|
||||
const status = publishTargetRunJournalRecord(
|
||||
context,
|
||||
filePath,
|
||||
record,
|
||||
'target stop manual resolution',
|
||||
);
|
||||
return result(context, status, record, 'manual_review');
|
||||
}
|
||||
|
||||
function replayTerminal(
|
||||
context: Readonly<StopContext>,
|
||||
active: Readonly<PriorActive>,
|
||||
): Readonly<LocalDeploymentTargetStopResult> | undefined {
|
||||
const generation = context.command.request.generation;
|
||||
const requestPath = targetStopPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'request',
|
||||
);
|
||||
if (!fs.existsSync(requestPath)) return undefined;
|
||||
const request = readTargetRunJournalRecord(requestPath, context, {
|
||||
sequence: targetStopSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['target_stop_requested', 'manual_required'],
|
||||
previousRecordDigest: active.record.recordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (request.state === 'manual_required') {
|
||||
verifyTargetRunManualEvidence(request);
|
||||
return result(context, 'existing', request, 'manual_review');
|
||||
}
|
||||
verifyTargetStopRequestEvidence(request, active);
|
||||
const outcomePath = targetStopPhasePath(
|
||||
context.journal,
|
||||
generation,
|
||||
'outcome',
|
||||
);
|
||||
if (!fs.existsSync(outcomePath)) return undefined;
|
||||
const outcome = readTargetRunJournalRecord(outcomePath, context, {
|
||||
sequence: targetStopSequence(generation, 'outcome'),
|
||||
generation,
|
||||
states: ['target_stopped', 'manual_required'],
|
||||
previousRecordDigest: request.recordDigest,
|
||||
requestedAtMs: context.command.request.requestedAtMs,
|
||||
});
|
||||
if (outcome.state === 'manual_required') {
|
||||
verifyTargetRunManualEvidence(outcome);
|
||||
return result(context, 'existing', outcome, 'manual_review');
|
||||
}
|
||||
return result(
|
||||
context,
|
||||
'existing',
|
||||
outcome,
|
||||
verifyTargetStoppedEvidence(outcome, active).disposition,
|
||||
);
|
||||
}
|
||||
|
||||
function docker(
|
||||
context: Readonly<StopContext>,
|
||||
runDocker: LocalDeploymentDockerRunner,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
): string {
|
||||
return runDocker({
|
||||
executable: context.command.options.dockerExecutable,
|
||||
socketPath: context.command.options.dockerSocketPath,
|
||||
args,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function stopLocalDeploymentDockerTarget(
|
||||
input: unknown,
|
||||
dependencies: LocalDeploymentTargetStopDependencies = {},
|
||||
): Readonly<LocalDeploymentTargetStopResult> {
|
||||
const stopCommand = normalizeLocalDeploymentTargetStopCommand(input);
|
||||
const command = targetStopRunCommand(stopCommand);
|
||||
const identity = currentIdentity();
|
||||
const serviceRoot = path.join(command.options.deploymentRoot, 'service');
|
||||
const journal = path.dirname(legacyCommitmentPath(command));
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(serviceRoot, identity.uid, 'serviceDescriptorRoot');
|
||||
validatePrivateDirectory(journal, identity.uid, 'cutoverJournal');
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
if (
|
||||
head.profile !== command.request.profile ||
|
||||
head.cutoverId !== command.request.cutoverId ||
|
||||
head.activationDigest !== command.request.expectedActivationDigest ||
|
||||
head.generation !== command.request.generation ||
|
||||
(head.state !== 'target_active' &&
|
||||
head.state !== 'target_stopped' &&
|
||||
head.state !== 'manual_required')
|
||||
) {
|
||||
configurationError('target stop is not bound to the instance lineage head');
|
||||
}
|
||||
const commitment = readLegacySilenceEvidence(command);
|
||||
const application = readTargetApplicationBinding(command);
|
||||
const context = Object.freeze({
|
||||
stopCommand,
|
||||
command,
|
||||
journal,
|
||||
commitment,
|
||||
application,
|
||||
uid: identity.uid,
|
||||
});
|
||||
const active = priorActive(context);
|
||||
const replay = replayTerminal(context, active);
|
||||
if (replay !== undefined) return replay;
|
||||
|
||||
const validateSocket =
|
||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||
const generation = command.request.generation;
|
||||
const requestPath = targetStopPhasePath(journal, generation, 'request');
|
||||
let request: Readonly<TargetRunJournalRecord>;
|
||||
if (fs.existsSync(requestPath)) {
|
||||
request = readTargetRunJournalRecord(requestPath, context, {
|
||||
sequence: targetStopSequence(generation, 'request'),
|
||||
generation,
|
||||
states: ['target_stop_requested'],
|
||||
previousRecordDigest: active.record.recordDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
verifyTargetStopRequestEvidence(request, active);
|
||||
} else {
|
||||
try {
|
||||
const target = parseTargetContainerEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
['container', 'inspect', command.request.expectedTargetContainerId],
|
||||
30_000,
|
||||
),
|
||||
command,
|
||||
application,
|
||||
'active',
|
||||
);
|
||||
if (
|
||||
target.identityDigest !== active.targetContainerIdentityDigest ||
|
||||
target.applicationBindingDigest !==
|
||||
active.targetApplicationBindingDigest
|
||||
) {
|
||||
configurationError('active target identity changed before stop');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
requestPath,
|
||||
targetStopSequence(generation, 'request'),
|
||||
active.record.recordDigest,
|
||||
'target_stop_preflight_unproved',
|
||||
);
|
||||
}
|
||||
request = targetRunJournalRecord(
|
||||
command,
|
||||
targetStopSequence(generation, 'request'),
|
||||
'target_stop_requested',
|
||||
active.record.recordDigest,
|
||||
targetStopRequestEvidence(active),
|
||||
);
|
||||
publishTargetRunJournalRecord(
|
||||
context,
|
||||
requestPath,
|
||||
request,
|
||||
'target stop barrier',
|
||||
);
|
||||
dependencies.afterBarrier?.();
|
||||
}
|
||||
|
||||
try {
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'update',
|
||||
'--restart',
|
||||
'no',
|
||||
command.request.expectedTargetContainerId,
|
||||
],
|
||||
30_000,
|
||||
);
|
||||
} catch {
|
||||
// Stop is convergent; the exact inspection below is authoritative.
|
||||
}
|
||||
try {
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
[
|
||||
'container',
|
||||
'stop',
|
||||
'--time',
|
||||
'30',
|
||||
command.request.expectedTargetContainerId,
|
||||
],
|
||||
45_000,
|
||||
);
|
||||
} catch {
|
||||
// A lost stop response is resolved by the exact inspection below.
|
||||
}
|
||||
const outcomePath = targetStopPhasePath(journal, generation, 'outcome');
|
||||
let target;
|
||||
try {
|
||||
target = parseTargetContainerEvidence(
|
||||
docker(
|
||||
context,
|
||||
runDocker,
|
||||
['container', 'inspect', command.request.expectedTargetContainerId],
|
||||
30_000,
|
||||
),
|
||||
command,
|
||||
application,
|
||||
'stopped',
|
||||
);
|
||||
if (
|
||||
target.identityDigest !== active.targetContainerIdentityDigest ||
|
||||
target.applicationBindingDigest !== active.targetApplicationBindingDigest
|
||||
) {
|
||||
configurationError('stopped target identity changed');
|
||||
}
|
||||
} catch {
|
||||
return publishManual(
|
||||
context,
|
||||
outcomePath,
|
||||
targetStopSequence(generation, 'outcome'),
|
||||
request.recordDigest,
|
||||
'target_stop_result_unproved',
|
||||
);
|
||||
}
|
||||
const reconciliation = readTargetDataReconciliationEvidence(
|
||||
command,
|
||||
identity.uid,
|
||||
);
|
||||
const outcome = targetRunJournalRecord(
|
||||
command,
|
||||
targetStopSequence(generation, 'outcome'),
|
||||
'target_stopped',
|
||||
request.recordDigest,
|
||||
targetStoppedEvidence(active, reconciliation),
|
||||
);
|
||||
publishTargetRunJournalRecord(
|
||||
context,
|
||||
outcomePath,
|
||||
outcome,
|
||||
'target stopped commitment',
|
||||
);
|
||||
return result(context, 'prepared', outcome, reconciliation.disposition);
|
||||
}
|
||||
|
||||
export function stopLocalDeploymentDockerTargetCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalDeploymentTargetStopResult> {
|
||||
return stopLocalDeploymentDockerTarget(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import {
|
||||
normalizeLocalDeploymentTargetRunCommand,
|
||||
type LocalDeploymentTargetRunCommand,
|
||||
} from './target-run/targetRunContract';
|
||||
|
||||
export interface LocalDeploymentTargetStopCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.cutover.target-stop';
|
||||
readonly options: LocalDeploymentTargetRunCommand['options'];
|
||||
readonly request: LocalDeploymentTargetRunCommand['request'];
|
||||
}
|
||||
|
||||
export type LocalDeploymentTargetReconciliationDisposition =
|
||||
| 'rollback_candidate'
|
||||
| 'reconciliation_required'
|
||||
| 'manual_review';
|
||||
|
||||
export interface LocalDeploymentTargetStopResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.cutover.target-stop';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'target_stopped' | 'manual_required';
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly reconciliation: LocalDeploymentTargetReconciliationDisposition;
|
||||
readonly recordDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export function normalizeLocalDeploymentTargetStopCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentTargetStopCommand> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError('command must be an object');
|
||||
}
|
||||
const command = value as Record<string, unknown>;
|
||||
if (command.operation !== 'local.deployment.cutover.target-stop') {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'target stop operation is invalid',
|
||||
);
|
||||
}
|
||||
const request = command.request as Record<string, unknown> | undefined;
|
||||
const syntheticOperation =
|
||||
request?.generation === 1
|
||||
? ('local.deployment.cutover.target-start' as const)
|
||||
: ('local.deployment.cutover.target-restart' as const);
|
||||
const normalized = normalizeLocalDeploymentTargetRunCommand({
|
||||
...command,
|
||||
operation: syntheticOperation,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.cutover.target-stop' as const,
|
||||
options: normalized.options,
|
||||
request: normalized.request,
|
||||
});
|
||||
}
|
||||
|
||||
export function targetStopRunCommand(
|
||||
command: Readonly<LocalDeploymentTargetStopCommand>,
|
||||
): Readonly<LocalDeploymentTargetRunCommand> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation:
|
||||
command.request.generation === 1
|
||||
? ('local.deployment.cutover.target-start' as const)
|
||||
: ('local.deployment.cutover.target-restart' as const),
|
||||
options: command.options,
|
||||
request: command.request,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import {
|
||||
verifyTargetDataReconciliationEvidence,
|
||||
type TargetDataReconciliationEvidence,
|
||||
} from './targetDataEvidence';
|
||||
import type { TargetRunJournalRecord } from './target-run/targetRunJournal';
|
||||
|
||||
export interface TargetStopActiveEvidence {
|
||||
readonly activeRecordDigest: string;
|
||||
readonly targetContainerIdentityDigest: string;
|
||||
readonly targetApplicationBindingDigest: string;
|
||||
readonly startupReceiptDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function targetStopRequestEvidence(
|
||||
active: Readonly<TargetStopActiveEvidence>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
activeRecordDigest: active.activeRecordDigest,
|
||||
startupReceiptDigest: active.startupReceiptDigest,
|
||||
targetApplicationBindingDigest: active.targetApplicationBindingDigest,
|
||||
targetContainerIdentityDigest: active.targetContainerIdentityDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetStopRequestEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
active: Readonly<TargetStopActiveEvidence>,
|
||||
): void {
|
||||
const evidence = object(record.evidence, 'target stop request evidence');
|
||||
exact(
|
||||
evidence,
|
||||
[
|
||||
'activeRecordDigest',
|
||||
'startupReceiptDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerIdentityDigest',
|
||||
],
|
||||
'target stop request evidence',
|
||||
);
|
||||
if (
|
||||
evidence.activeRecordDigest !== active.activeRecordDigest ||
|
||||
evidence.startupReceiptDigest !== active.startupReceiptDigest ||
|
||||
evidence.targetApplicationBindingDigest !==
|
||||
active.targetApplicationBindingDigest ||
|
||||
evidence.targetContainerIdentityDigest !==
|
||||
active.targetContainerIdentityDigest
|
||||
) {
|
||||
configurationError('target stop request evidence drifted');
|
||||
}
|
||||
}
|
||||
|
||||
export function targetStoppedEvidence(
|
||||
active: Readonly<TargetStopActiveEvidence>,
|
||||
reconciliation: Readonly<TargetDataReconciliationEvidence>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
activeRecordDigest: active.activeRecordDigest,
|
||||
startupReceiptDigest: active.startupReceiptDigest,
|
||||
targetApplicationBindingDigest: active.targetApplicationBindingDigest,
|
||||
targetContainerIdentityDigest: active.targetContainerIdentityDigest,
|
||||
reconciliation,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTargetStoppedEvidence(
|
||||
record: Readonly<TargetRunJournalRecord>,
|
||||
active: Readonly<TargetStopActiveEvidence>,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
const evidence = object(record.evidence, 'target stopped evidence');
|
||||
exact(
|
||||
evidence,
|
||||
[
|
||||
'activeRecordDigest',
|
||||
'reconciliation',
|
||||
'startupReceiptDigest',
|
||||
'targetApplicationBindingDigest',
|
||||
'targetContainerIdentityDigest',
|
||||
],
|
||||
'target stopped evidence',
|
||||
);
|
||||
if (
|
||||
evidence.activeRecordDigest !== active.activeRecordDigest ||
|
||||
evidence.startupReceiptDigest !== active.startupReceiptDigest ||
|
||||
evidence.targetApplicationBindingDigest !==
|
||||
active.targetApplicationBindingDigest ||
|
||||
evidence.targetContainerIdentityDigest !==
|
||||
active.targetContainerIdentityDigest
|
||||
) {
|
||||
configurationError('target stopped evidence drifted');
|
||||
}
|
||||
return verifyTargetDataReconciliationEvidence(evidence.reconciliation);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from './contract';
|
||||
|
||||
const MAX_DOCKER_OUTPUT_BYTES = 256 * 1024;
|
||||
|
||||
export interface LocalDeploymentDockerRequest {
|
||||
readonly executable: string;
|
||||
readonly socketPath: string;
|
||||
readonly args: readonly string[];
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export type LocalDeploymentDockerRunner = (
|
||||
request: Readonly<LocalDeploymentDockerRequest>,
|
||||
) => string;
|
||||
|
||||
export function runLocalDeploymentDockerCommand(
|
||||
request: Readonly<LocalDeploymentDockerRequest>,
|
||||
): string {
|
||||
const configRoot = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-docker-command-')),
|
||||
);
|
||||
fs.chmodSync(configRoot, 0o700);
|
||||
try {
|
||||
const executableDirectory = path.dirname(request.executable);
|
||||
const result = spawnSync(
|
||||
request.executable,
|
||||
[
|
||||
'--host',
|
||||
`unix://${request.socketPath}`,
|
||||
'--config',
|
||||
configRoot,
|
||||
...request.args,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
maxBuffer: MAX_DOCKER_OUTPUT_BYTES,
|
||||
timeout: request.timeoutMs ?? 30_000,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
PATH: `${executableDirectory}:/usr/local/bin:/usr/bin:/bin`,
|
||||
HOME: configRoot,
|
||||
DOCKER_CONFIG: configRoot,
|
||||
NO_PROXY: '*',
|
||||
no_proxy: '*',
|
||||
},
|
||||
},
|
||||
);
|
||||
if (
|
||||
result.error ||
|
||||
result.status !== 0 ||
|
||||
typeof result.stdout !== 'string' ||
|
||||
Buffer.byteLength(result.stdout, 'utf8') > MAX_DOCKER_OUTPUT_BYTES
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'Docker command failed closed',
|
||||
{ cause: result.error },
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
} finally {
|
||||
fs.rmSync(configRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function validateLocalDeploymentDockerSocket(
|
||||
socketPath: string,
|
||||
uid: number,
|
||||
): void {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(socketPath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerSocketPath is unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isSocket() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(socketPath) !== socketPath ||
|
||||
(stat.uid !== 0 && stat.uid !== uid) ||
|
||||
(stat.mode & 0o002) !== 0
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'dockerSocketPath must be a canonical trusted Unix socket',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from './contract';
|
||||
|
||||
const MAX_PUBLISHED_FILE_BYTES = 64 * 1024;
|
||||
|
||||
export function validatePrivateDirectory(
|
||||
directory: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): void {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is unavailable`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o700 ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} must be a canonical current-UID 0700 directory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensurePrivateDirectory(
|
||||
directory: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): 'prepared' | 'existing' {
|
||||
let created = false;
|
||||
try {
|
||||
fs.mkdirSync(directory, { mode: 0o700 });
|
||||
created = true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} cannot be created`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
validatePrivateDirectory(directory, uid, label);
|
||||
return created ? 'prepared' : 'existing';
|
||||
}
|
||||
|
||||
function boundedBytes(contents: string, label: string): Buffer {
|
||||
const bytes = Buffer.from(contents, 'utf8');
|
||||
if (bytes.byteLength < 2 || bytes.byteLength > MAX_PUBLISHED_FILE_BYTES) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} has an invalid size`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function fileStat(
|
||||
filePath: string,
|
||||
bytes: Buffer,
|
||||
mode: number,
|
||||
uid: number,
|
||||
allowedLinks: readonly number[],
|
||||
label: string,
|
||||
): fs.Stats {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is unavailable`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== mode ||
|
||||
!allowedLinks.includes(stat.nlink) ||
|
||||
stat.size !== bytes.byteLength
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} identity is invalid`);
|
||||
}
|
||||
const actual = fs.readFileSync(filePath);
|
||||
if (!bytes.equals(actual)) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} content drifted`);
|
||||
}
|
||||
return stat;
|
||||
}
|
||||
|
||||
function stagePathFor(targetPath: string): string {
|
||||
return path.join(
|
||||
path.dirname(targetPath),
|
||||
`.${path.basename(targetPath)}.ql3-deploy-stage`,
|
||||
);
|
||||
}
|
||||
|
||||
export function preflightPublishedFile(
|
||||
targetPath: string,
|
||||
contents: string,
|
||||
mode: number,
|
||||
uid: number,
|
||||
label: string,
|
||||
): void {
|
||||
const bytes = boundedBytes(contents, label);
|
||||
const stagePath = stagePathFor(targetPath);
|
||||
const targetExists = fs.existsSync(targetPath);
|
||||
const stageExists = fs.existsSync(stagePath);
|
||||
const targetStat = targetExists
|
||||
? fileStat(targetPath, bytes, mode, uid, [1, 2], label)
|
||||
: null;
|
||||
const stageStat = stageExists
|
||||
? fileStat(stagePath, bytes, mode, uid, [1, 2], `${label} stage`)
|
||||
: null;
|
||||
if (
|
||||
(targetStat?.nlink === 2 || stageStat?.nlink === 2) &&
|
||||
(!targetStat ||
|
||||
!stageStat ||
|
||||
targetStat.dev !== stageStat.dev ||
|
||||
targetStat.ino !== stageStat.ino)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} stage identity drifted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fsyncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function writeStage(
|
||||
stagePath: string,
|
||||
bytes: Buffer,
|
||||
mode: number,
|
||||
uid: number,
|
||||
label: string,
|
||||
): void {
|
||||
let descriptor: number | undefined;
|
||||
let created = false;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
stagePath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
fs.constants.O_NOFOLLOW,
|
||||
mode,
|
||||
);
|
||||
created = true;
|
||||
fs.fchmodSync(descriptor, mode);
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (!stat.isFile() || stat.uid !== uid || stat.nlink !== 1) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} stage identity is invalid`,
|
||||
);
|
||||
}
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const written = fs.writeSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
);
|
||||
if (written < 1) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} stage write stalled`,
|
||||
);
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
fs.fsyncSync(descriptor);
|
||||
} catch (error) {
|
||||
if (created) {
|
||||
try {
|
||||
fs.unlinkSync(stagePath);
|
||||
} catch {
|
||||
// A failed cleanup leaves a deterministic fail-closed stage.
|
||||
}
|
||||
}
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} stage cannot be written`,
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function publishExactFile(
|
||||
targetPath: string,
|
||||
contents: string,
|
||||
mode: number,
|
||||
uid: number,
|
||||
label: string,
|
||||
): 'prepared' | 'existing' {
|
||||
const bytes = boundedBytes(contents, label);
|
||||
const directory = path.dirname(targetPath);
|
||||
const stagePath = stagePathFor(targetPath);
|
||||
preflightPublishedFile(targetPath, contents, mode, uid, label);
|
||||
const existed = fs.existsSync(targetPath);
|
||||
if (!fs.existsSync(stagePath) && !existed) {
|
||||
writeStage(stagePath, bytes, mode, uid, label);
|
||||
}
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
try {
|
||||
fs.linkSync(stagePath, targetPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} cannot be published`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
fsyncDirectory(directory);
|
||||
}
|
||||
fileStat(targetPath, bytes, mode, uid, [1, 2], label);
|
||||
if (fs.existsSync(stagePath)) {
|
||||
const targetStat = fs.lstatSync(targetPath);
|
||||
const stageStat = fileStat(
|
||||
stagePath,
|
||||
bytes,
|
||||
mode,
|
||||
uid,
|
||||
[1, 2],
|
||||
`${label} stage`,
|
||||
);
|
||||
if (
|
||||
stageStat.nlink === 2 &&
|
||||
(targetStat.dev !== stageStat.dev || targetStat.ino !== stageStat.ino)
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} stage identity drifted`,
|
||||
);
|
||||
}
|
||||
fs.unlinkSync(stagePath);
|
||||
fsyncDirectory(directory);
|
||||
}
|
||||
fileStat(targetPath, bytes, mode, uid, [1], label);
|
||||
return existed ? 'existing' : 'prepared';
|
||||
}
|
||||
|
||||
export function replaceExactFile(
|
||||
targetPath: string,
|
||||
expectedContents: string,
|
||||
nextContents: string,
|
||||
mode: number,
|
||||
uid: number,
|
||||
label: string,
|
||||
): 'prepared' | 'existing' {
|
||||
const expectedBytes = boundedBytes(expectedContents, `${label} expected`);
|
||||
const nextBytes = boundedBytes(nextContents, `${label} next`);
|
||||
if (expectedBytes.equals(nextBytes)) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} replacement must change content`,
|
||||
);
|
||||
}
|
||||
const directory = path.dirname(targetPath);
|
||||
const stagePath = stagePathFor(targetPath);
|
||||
let targetBytes: Buffer;
|
||||
try {
|
||||
targetBytes = fs.readFileSync(targetPath);
|
||||
} catch (error) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is unavailable`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (targetBytes.equals(nextBytes)) {
|
||||
fileStat(targetPath, nextBytes, mode, uid, [1], label);
|
||||
if (fs.existsSync(stagePath)) {
|
||||
fileStat(stagePath, nextBytes, mode, uid, [1], `${label} stage`);
|
||||
fs.unlinkSync(stagePath);
|
||||
fsyncDirectory(directory);
|
||||
}
|
||||
return 'existing';
|
||||
}
|
||||
if (!targetBytes.equals(expectedBytes)) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`${label} content does not match the expected revision`,
|
||||
);
|
||||
}
|
||||
fileStat(targetPath, expectedBytes, mode, uid, [1], label);
|
||||
if (fs.existsSync(stagePath)) {
|
||||
fileStat(stagePath, nextBytes, mode, uid, [1], `${label} stage`);
|
||||
} else {
|
||||
writeStage(stagePath, nextBytes, mode, uid, label);
|
||||
}
|
||||
fileStat(targetPath, expectedBytes, mode, uid, [1], label);
|
||||
fs.renameSync(stagePath, targetPath);
|
||||
fsyncDirectory(directory);
|
||||
fileStat(targetPath, nextBytes, mode, uid, [1], label);
|
||||
return 'prepared';
|
||||
}
|
||||
|
||||
export function syncPublishedDirectory(directory: string): void {
|
||||
fsyncDirectory(directory);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { LocalSetupCommand } from '../../lifecycle/localSetup';
|
||||
import type {
|
||||
LocalDeploymentPrepareCommand,
|
||||
LocalDeploymentProcessService,
|
||||
} from './contract';
|
||||
|
||||
const CONTAINER_ROOT = '/var/lib/qinglong3';
|
||||
|
||||
export function composeProjectName(instanceId: string): string {
|
||||
const slug = instanceId.replaceAll('.', '-').slice(0, 32);
|
||||
const suffix = crypto
|
||||
.createHash('sha256')
|
||||
.update('qinglong:local-compose-project:v1\0', 'utf8')
|
||||
.update(instanceId, 'utf8')
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
return `ql3-${slug}-${suffix}`;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentPaths {
|
||||
readonly database: string;
|
||||
readonly ownerPepperKeyring: string;
|
||||
readonly ownerPepperBackup: string;
|
||||
readonly localSecretKeyring: string;
|
||||
readonly receipts: string;
|
||||
readonly artifacts: string;
|
||||
readonly pluginStaging: string;
|
||||
readonly pluginActivation: string;
|
||||
readonly service: string;
|
||||
readonly applicationConfig: string;
|
||||
readonly composeSelection: string;
|
||||
readonly composeRevisions: string;
|
||||
readonly composeRevisionLock: string;
|
||||
readonly composeRollouts: string;
|
||||
readonly composeRolloutLock: string;
|
||||
readonly composeRolloutBackups: string;
|
||||
readonly composeRestores: string;
|
||||
readonly composeRestoreLock: string;
|
||||
readonly composeRestoreSafeguards: string;
|
||||
readonly composeEvidenceCollections: string;
|
||||
readonly composeEvidenceCollectionLock: string;
|
||||
readonly composeCollectedEvidence: string;
|
||||
readonly composeCollectedRolloutBackups: string;
|
||||
readonly composeCollectedRestoreSafeguards: string;
|
||||
}
|
||||
|
||||
export function deploymentPaths(root: string): Readonly<LocalDeploymentPaths> {
|
||||
return Object.freeze({
|
||||
database: path.join(root, 'qinglong3.sqlite'),
|
||||
ownerPepperKeyring: path.join(root, 'owner-peppers'),
|
||||
ownerPepperBackup: path.join(root, 'owner-pepper-backup'),
|
||||
localSecretKeyring: path.join(root, 'local-secret-keyring.json'),
|
||||
receipts: path.join(root, 'receipts'),
|
||||
artifacts: path.join(root, 'artifacts'),
|
||||
pluginStaging: path.join(root, 'plugin-staging'),
|
||||
pluginActivation: path.join(root, 'plugin-activation'),
|
||||
service: path.join(root, 'service'),
|
||||
applicationConfig: path.join(root, 'local-application.json'),
|
||||
composeSelection: path.join(root, 'service', 'compose.image.yaml'),
|
||||
composeRevisions: path.join(root, 'service', 'revisions'),
|
||||
composeRevisionLock: path.join(root, 'service', '.compose-revision.lock'),
|
||||
composeRollouts: path.join(root, 'service', 'rollouts'),
|
||||
composeRolloutLock: path.join(root, 'service', '.compose-rollout.lock'),
|
||||
composeRolloutBackups: path.join(root, 'service', 'rollout-backups'),
|
||||
composeRestores: path.join(root, 'service', 'restores'),
|
||||
composeRestoreLock: path.join(root, 'service', '.compose-restore.lock'),
|
||||
composeRestoreSafeguards: path.join(root, 'service', 'restore-safeguards'),
|
||||
composeEvidenceCollections: path.join(
|
||||
root,
|
||||
'service',
|
||||
'evidence-collections',
|
||||
),
|
||||
composeEvidenceCollectionLock: path.join(
|
||||
root,
|
||||
'service',
|
||||
'.compose-evidence-collection.lock',
|
||||
),
|
||||
composeCollectedEvidence: path.join(root, 'service', 'collected-evidence'),
|
||||
composeCollectedRolloutBackups: path.join(
|
||||
root,
|
||||
'service',
|
||||
'collected-evidence',
|
||||
'rollout-backups',
|
||||
),
|
||||
composeCollectedRestoreSafeguards: path.join(
|
||||
root,
|
||||
'service',
|
||||
'collected-evidence',
|
||||
'restore-safeguards',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function applicationConfiguration(
|
||||
command: Readonly<LocalDeploymentPrepareCommand>,
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
): string {
|
||||
const runtimeRoot =
|
||||
command.options.service.kind === 'compose'
|
||||
? CONTAINER_ROOT
|
||||
: command.options.deploymentRoot;
|
||||
const pageSize = command.options.profile === 'edge' ? 4 : 16;
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
schema: 'qinglong/local-application-process@v2',
|
||||
instanceId: command.options.instanceId,
|
||||
profile: command.options.profile,
|
||||
storage: {
|
||||
mode: 'fresh',
|
||||
databasePath: path.join(runtimeRoot, path.basename(paths.database)),
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
},
|
||||
runtime: {
|
||||
receiptRoot: path.join(runtimeRoot, path.basename(paths.receipts)),
|
||||
artifactRoot: path.join(runtimeRoot, path.basename(paths.artifacts)),
|
||||
secretKeyringPath: path.join(
|
||||
runtimeRoot,
|
||||
path.basename(paths.localSecretKeyring),
|
||||
),
|
||||
},
|
||||
pluginPackages: {
|
||||
stagingRoot: path.join(runtimeRoot, path.basename(paths.pluginStaging)),
|
||||
activationRoot: path.join(
|
||||
runtimeRoot,
|
||||
path.basename(paths.pluginActivation),
|
||||
),
|
||||
recoverySource: {
|
||||
mode: 'disabled',
|
||||
},
|
||||
pageSize,
|
||||
maxPages: pageSize,
|
||||
taskPublicationPageSize: pageSize,
|
||||
taskPublicationMaxPages: pageSize,
|
||||
},
|
||||
ai: {
|
||||
deployment: 'excluded',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function systemdDescriptor(
|
||||
command: Readonly<LocalDeploymentPrepareCommand>,
|
||||
configPath: string,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): string {
|
||||
const service = command.options.service as LocalDeploymentProcessService;
|
||||
const edge = command.options.profile === 'edge';
|
||||
return [
|
||||
'[Unit]',
|
||||
'Description=QingLong 3.0 local automation runtime',
|
||||
'After=local-fs.target',
|
||||
'',
|
||||
'[Service]',
|
||||
'Type=simple',
|
||||
`User=${uid}`,
|
||||
`Group=${gid}`,
|
||||
`WorkingDirectory=${command.options.deploymentRoot}`,
|
||||
`ExecStart=${service.nodeExecutable} ${service.applicationEntrypoint} --config ${configPath}`,
|
||||
'Environment=NODE_ENV=production',
|
||||
'UMask=0077',
|
||||
'KillSignal=SIGTERM',
|
||||
'TimeoutStopSec=30s',
|
||||
'Restart=on-failure',
|
||||
'RestartSec=5s',
|
||||
'RestartPreventExitStatus=64',
|
||||
'NoNewPrivileges=yes',
|
||||
'PrivateTmp=yes',
|
||||
'ProtectSystem=strict',
|
||||
`ReadWritePaths=${command.options.deploymentRoot}`,
|
||||
'ProtectKernelTunables=yes',
|
||||
'ProtectKernelModules=yes',
|
||||
'ProtectControlGroups=yes',
|
||||
'RestrictSUIDSGID=yes',
|
||||
'LockPersonality=yes',
|
||||
`LimitNOFILE=${edge ? 1024 : 4096}`,
|
||||
`TasksMax=${edge ? 64 : 256}`,
|
||||
`MemoryMax=${edge ? '128M' : '256M'}`,
|
||||
'',
|
||||
'[Install]',
|
||||
'WantedBy=multi-user.target',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function openrcDescriptor(
|
||||
command: Readonly<LocalDeploymentPrepareCommand>,
|
||||
configPath: string,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): string {
|
||||
const service = command.options.service as LocalDeploymentProcessService;
|
||||
const edge = command.options.profile === 'edge';
|
||||
return [
|
||||
'#!/sbin/openrc-run',
|
||||
'',
|
||||
'name="qinglong3"',
|
||||
'description="QingLong 3.0 local automation runtime"',
|
||||
`command="${service.nodeExecutable}"`,
|
||||
`command_args="${service.applicationEntrypoint} --config ${configPath}"`,
|
||||
`command_user="${uid}:${gid}"`,
|
||||
`directory="${command.options.deploymentRoot}"`,
|
||||
'supervisor="supervise-daemon"',
|
||||
'respawn_delay=5',
|
||||
'respawn_max=5',
|
||||
'respawn_period=60',
|
||||
'retry="TERM/30/KILL/5"',
|
||||
'umask=0077',
|
||||
`rc_ulimit="-n ${edge ? 1024 : 4096}"`,
|
||||
'',
|
||||
'depend() {',
|
||||
' need localmount',
|
||||
' after bootmisc',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function composeDescriptor(
|
||||
command: Readonly<LocalDeploymentPrepareCommand>,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): string {
|
||||
const edge = command.options.profile === 'edge';
|
||||
return [
|
||||
`name: ${composeProjectName(command.options.instanceId)}`,
|
||||
'',
|
||||
'services:',
|
||||
' qinglong3:',
|
||||
` user: "${uid}:${gid}"`,
|
||||
' read_only: true',
|
||||
' init: true',
|
||||
' network_mode: none',
|
||||
' command:',
|
||||
' - --config',
|
||||
` - ${CONTAINER_ROOT}/local-application.json`,
|
||||
' volumes:',
|
||||
' - type: bind',
|
||||
` source: ${command.options.deploymentRoot}`,
|
||||
` target: ${CONTAINER_ROOT}`,
|
||||
' tmpfs:',
|
||||
' - /tmp:rw,noexec,nosuid,nodev,size=16m',
|
||||
' cap_drop:',
|
||||
' - ALL',
|
||||
' security_opt:',
|
||||
' - no-new-privileges:true',
|
||||
' restart: unless-stopped',
|
||||
' stop_grace_period: 30s',
|
||||
` mem_limit: ${edge ? '128m' : '256m'}`,
|
||||
` pids_limit: ${edge ? 64 : 256}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function descriptor(
|
||||
command: Readonly<LocalDeploymentPrepareCommand>,
|
||||
configPath: string,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): Readonly<{ fileName: string; contents: string; mode: number }> {
|
||||
if (command.options.service.kind === 'systemd') {
|
||||
return Object.freeze({
|
||||
fileName: 'qinglong3.service',
|
||||
contents: systemdDescriptor(command, configPath, uid, gid),
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
if (command.options.service.kind === 'openrc') {
|
||||
return Object.freeze({
|
||||
fileName: 'qinglong3.openrc',
|
||||
contents: openrcDescriptor(command, configPath, uid, gid),
|
||||
mode: 0o700,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
fileName: 'compose.yaml',
|
||||
contents: composeDescriptor(command, uid, gid),
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export function setupCommand(
|
||||
command: Readonly<LocalDeploymentPrepareCommand>,
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
): Readonly<LocalSetupCommand> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.setup.prepare' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: paths.database,
|
||||
profile: command.options.profile,
|
||||
ownerPepperKeyringDirectory: paths.ownerPepperKeyring,
|
||||
ownerPepperBackupDirectory: paths.ownerPepperBackup,
|
||||
ownerPepperKeyId: command.request.ownerPepperKeyId,
|
||||
localSecretKeyringPath: paths.localSecretKeyring,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
}),
|
||||
request: Object.freeze({
|
||||
registerMutationId: command.request.registerMutationId,
|
||||
activateMutationId: command.request.activateMutationId,
|
||||
registeredAtMs: command.request.registeredAtMs,
|
||||
activatedAtMs: command.request.activatedAtMs,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Deployment composition lives with its concrete deployment capabilities.
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
normalizeLocalDeploymentPrepareCommand,
|
||||
type LocalDeploymentPrepareResult,
|
||||
} from './foundation/contract';
|
||||
import {
|
||||
initialComposeImageSelection,
|
||||
preflightActiveComposeImageSelection,
|
||||
switchLocalDeploymentComposeRevision,
|
||||
switchLocalDeploymentComposeRevisionCommandFile,
|
||||
} from './compose/composeRevision';
|
||||
import {
|
||||
preflightLocalDeploymentCompose,
|
||||
preflightLocalDeploymentComposeCommandFile,
|
||||
} from './compose/composePreflight';
|
||||
import {
|
||||
applyLocalDeploymentCompose,
|
||||
applyLocalDeploymentComposeCommandFile,
|
||||
} from './compose/composeApply';
|
||||
import {
|
||||
restoreLocalDeploymentCompose,
|
||||
restoreLocalDeploymentComposeCommitCommandFile,
|
||||
restoreLocalDeploymentComposeCommandFile,
|
||||
restoreLocalDeploymentComposePrepareCommandFile,
|
||||
} from './compose/composeRestore';
|
||||
import {
|
||||
collectLocalDeploymentComposeEvidence,
|
||||
collectLocalDeploymentComposeEvidenceCommitCommandFile,
|
||||
collectLocalDeploymentComposeEvidencePrepareCommandFile,
|
||||
} from './compose/composeEvidenceCollection';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
} from './foundation/files';
|
||||
import {
|
||||
applicationConfiguration,
|
||||
deploymentPaths,
|
||||
descriptor,
|
||||
setupCommand,
|
||||
} from './foundation/render';
|
||||
import { executeLocalSetup } from '../lifecycle/localSetup';
|
||||
import {
|
||||
inspectLocalDeploymentStatus,
|
||||
inspectLocalDeploymentStatusCommandFile,
|
||||
} from './localDeploymentStatus';
|
||||
import {
|
||||
stopLegacyDockerForLocalDeployment,
|
||||
stopLegacyDockerForLocalDeploymentCommandFile,
|
||||
} from './cutover/legacyStop';
|
||||
import {
|
||||
runLocalDeploymentDockerTarget,
|
||||
runLocalDeploymentDockerTargetCommandFile,
|
||||
} from './cutover/target-run/targetRun';
|
||||
import {
|
||||
runLocalDeploymentCutoverManualCommand,
|
||||
runLocalDeploymentCutoverManualCommandFile,
|
||||
} from './cutover/manual-resolution/manualResolution';
|
||||
import {
|
||||
stopLocalDeploymentDockerTarget,
|
||||
stopLocalDeploymentDockerTargetCommandFile,
|
||||
} from './cutover/targetStop';
|
||||
import {
|
||||
runLocalDeploymentLegacyRollback,
|
||||
runLocalDeploymentLegacyRollbackCommandFile,
|
||||
} from './cutover/legacyRollback';
|
||||
import {
|
||||
consumeLocalServiceManagerOutcome,
|
||||
consumeLocalServiceManagerOutcomeCommandFile,
|
||||
prepareLocalServiceManagerIntent,
|
||||
prepareLocalServiceManagerIntentCommandFile,
|
||||
} from './service-manager/serviceManagerIntent';
|
||||
import {
|
||||
consumeLocalServiceManagerCutoverOutcome,
|
||||
consumeLocalServiceManagerCutoverOutcomeCommandFile,
|
||||
} from './service-manager/serviceCutoverConsumer';
|
||||
|
||||
export {
|
||||
LocalDeploymentConfigurationError,
|
||||
normalizeLocalDeploymentComposeApplyCommand,
|
||||
normalizeLocalDeploymentComposeEvidenceCollectionCommand,
|
||||
normalizeLocalDeploymentComposePreflightCommand,
|
||||
normalizeLocalDeploymentComposeRestoreCommand,
|
||||
normalizeLocalDeploymentComposeRevisionCommand,
|
||||
normalizeLocalDeploymentPrepareCommand,
|
||||
normalizeLocalDeploymentStatusCommand,
|
||||
type LocalDeploymentComposeApplyCommand,
|
||||
type LocalDeploymentComposeApplyResult,
|
||||
type LocalDeploymentComposeEvidenceCollectionCommand,
|
||||
type LocalDeploymentComposeEvidenceCollectionCommitCommand,
|
||||
type LocalDeploymentComposeEvidenceCollectionPrepareCommand,
|
||||
type LocalDeploymentComposeEvidenceCollectionResult,
|
||||
type LocalDeploymentComposePreflightCommand,
|
||||
type LocalDeploymentComposePreflightResult,
|
||||
type LocalDeploymentComposeRestoreCommand,
|
||||
type LocalDeploymentComposeRestoreCommitCommand,
|
||||
type LocalDeploymentComposeRestorePrepareCommand,
|
||||
type LocalDeploymentComposeRestoreResult,
|
||||
type LocalDeploymentComposeRevisionCommand,
|
||||
type LocalDeploymentComposeRevisionResult,
|
||||
type LocalDeploymentPrepareCommand,
|
||||
type LocalDeploymentPrepareResult,
|
||||
type LocalDeploymentStatusCommand,
|
||||
type LocalDeploymentStatusResult,
|
||||
} from './foundation/contract';
|
||||
export {
|
||||
normalizeLocalDeploymentLegacyStopCommand,
|
||||
type LocalDeploymentLegacyStopCommand,
|
||||
type LocalDeploymentLegacyStopResult,
|
||||
} from './cutover/contract';
|
||||
export {
|
||||
normalizeLocalDeploymentTargetRunCommand,
|
||||
type LocalDeploymentTargetRunCommand,
|
||||
type LocalDeploymentTargetRunOperation,
|
||||
type LocalDeploymentTargetRunResult,
|
||||
} from './cutover/target-run/targetRunContract';
|
||||
export {
|
||||
normalizeLocalDeploymentTargetStopCommand,
|
||||
type LocalDeploymentTargetReconciliationDisposition,
|
||||
type LocalDeploymentTargetStopCommand,
|
||||
type LocalDeploymentTargetStopResult,
|
||||
} from './cutover/targetStopContract';
|
||||
export { type LocalDeploymentTargetStopDependencies } from './cutover/targetStop';
|
||||
export {
|
||||
EMPTY_ROLLBACK_PREPARATION_DIGEST,
|
||||
normalizeLocalDeploymentLegacyRollbackCommand,
|
||||
type LocalDeploymentLegacyRollbackCommand,
|
||||
type LocalDeploymentLegacyRollbackOperation,
|
||||
type LocalDeploymentLegacyRollbackResult,
|
||||
} from './cutover/legacyRollbackContract';
|
||||
export { type LocalDeploymentLegacyRollbackDependencies } from './cutover/legacyRollback';
|
||||
export {
|
||||
EMPTY_RESOLUTION_DIGEST,
|
||||
normalizeLocalDeploymentCutoverManualCommand,
|
||||
type LocalDeploymentCutoverManualCommand,
|
||||
type LocalDeploymentCutoverManualOperation,
|
||||
} from './cutover/manual-resolution/manualResolutionContract';
|
||||
export {
|
||||
type LocalDeploymentCutoverManualDependencies,
|
||||
type LocalDeploymentCutoverManualResult,
|
||||
type LocalDeploymentCutoverObservationState,
|
||||
} from './cutover/manual-resolution/manualResolution';
|
||||
export {
|
||||
applyLocalDeploymentCompose,
|
||||
applyLocalDeploymentComposeCommandFile,
|
||||
collectLocalDeploymentComposeEvidence,
|
||||
collectLocalDeploymentComposeEvidenceCommitCommandFile,
|
||||
collectLocalDeploymentComposeEvidencePrepareCommandFile,
|
||||
inspectLocalDeploymentStatus,
|
||||
inspectLocalDeploymentStatusCommandFile,
|
||||
preflightLocalDeploymentCompose,
|
||||
preflightLocalDeploymentComposeCommandFile,
|
||||
restoreLocalDeploymentCompose,
|
||||
restoreLocalDeploymentComposeCommitCommandFile,
|
||||
restoreLocalDeploymentComposeCommandFile,
|
||||
restoreLocalDeploymentComposePrepareCommandFile,
|
||||
runLocalDeploymentCutoverManualCommand,
|
||||
runLocalDeploymentCutoverManualCommandFile,
|
||||
runLocalDeploymentLegacyRollback,
|
||||
runLocalDeploymentLegacyRollbackCommandFile,
|
||||
runLocalDeploymentDockerTarget,
|
||||
runLocalDeploymentDockerTargetCommandFile,
|
||||
switchLocalDeploymentComposeRevision,
|
||||
switchLocalDeploymentComposeRevisionCommandFile,
|
||||
stopLegacyDockerForLocalDeployment,
|
||||
stopLegacyDockerForLocalDeploymentCommandFile,
|
||||
stopLocalDeploymentDockerTarget,
|
||||
stopLocalDeploymentDockerTargetCommandFile,
|
||||
consumeLocalServiceManagerOutcome,
|
||||
consumeLocalServiceManagerOutcomeCommandFile,
|
||||
consumeLocalServiceManagerCutoverOutcome,
|
||||
consumeLocalServiceManagerCutoverOutcomeCommandFile,
|
||||
prepareLocalServiceManagerIntent,
|
||||
prepareLocalServiceManagerIntentCommandFile,
|
||||
};
|
||||
export {
|
||||
localServiceManagerIntentDigest,
|
||||
normalizeLocalServiceBridgeCommand,
|
||||
normalizeLocalServiceManagerIntent,
|
||||
type LocalServiceBridgeCommand,
|
||||
type LocalServiceManagerIntent,
|
||||
} from './service-manager/serviceBridgeContract';
|
||||
export {
|
||||
normalizeLocalServiceManagerOutcome,
|
||||
type LocalServiceManagerOutcome,
|
||||
} from './service-manager/serviceOutcomeContract';
|
||||
export {
|
||||
type LocalServiceManagerIntentPrepareCommand,
|
||||
type LocalServiceManagerIntentPrepareResult,
|
||||
type LocalServiceManagerOutcomeConsumeCommand,
|
||||
type LocalServiceManagerOutcomeConsumeResult,
|
||||
} from './service-manager/serviceManagerIntent';
|
||||
export {
|
||||
type LocalServiceManagerCutoverConsumeCommand,
|
||||
type LocalServiceManagerCutoverConsumeResult,
|
||||
type LocalServiceManagerCutoverDependencies,
|
||||
} from './service-manager/serviceCutoverConsumer';
|
||||
export {
|
||||
type LocalServiceManagerCutoverEvidence,
|
||||
type LocalServiceManagerCutoverRecord,
|
||||
type LocalServiceManagerCutoverState,
|
||||
} from './service-manager/serviceCutoverJournal';
|
||||
|
||||
export async function prepareLocalDeployment(
|
||||
input: unknown,
|
||||
): Promise<Readonly<LocalDeploymentPrepareResult>> {
|
||||
const command = normalizeLocalDeploymentPrepareCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const paths = deploymentPaths(command.options.deploymentRoot);
|
||||
const directories = [
|
||||
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||
[paths.ownerPepperKeyring, 'ownerPepperKeyringDirectory'],
|
||||
[paths.ownerPepperBackup, 'ownerPepperBackupDirectory'],
|
||||
[paths.receipts, 'receiptRoot'],
|
||||
[paths.artifacts, 'artifactRoot'],
|
||||
[paths.pluginStaging, 'pluginStagingRoot'],
|
||||
[paths.pluginActivation, 'pluginActivationRoot'],
|
||||
[paths.service, 'serviceDescriptorRoot'],
|
||||
...(command.options.service.kind === 'compose'
|
||||
? ([
|
||||
[paths.composeRevisions, 'composeRevisionRoot'],
|
||||
[paths.composeRollouts, 'composeRolloutRoot'],
|
||||
[paths.composeRolloutBackups, 'composeRolloutBackupRoot'],
|
||||
[paths.composeRestores, 'composeRestoreRoot'],
|
||||
[paths.composeRestoreSafeguards, 'composeRestoreSafeguardRoot'],
|
||||
[paths.composeEvidenceCollections, 'composeEvidenceCollectionRoot'],
|
||||
[paths.composeCollectedEvidence, 'composeCollectedEvidenceRoot'],
|
||||
[
|
||||
paths.composeCollectedRolloutBackups,
|
||||
'composeCollectedRolloutBackupRoot',
|
||||
],
|
||||
[
|
||||
paths.composeCollectedRestoreSafeguards,
|
||||
'composeCollectedRestoreSafeguardRoot',
|
||||
],
|
||||
] as const)
|
||||
: []),
|
||||
] as const;
|
||||
const directoryStatuses = directories.map(([directory, label]) =>
|
||||
ensurePrivateDirectory(directory, identity.uid, label),
|
||||
);
|
||||
const applicationConfig = applicationConfiguration(command, paths);
|
||||
const serviceDescriptor = descriptor(
|
||||
command,
|
||||
paths.applicationConfig,
|
||||
identity.uid,
|
||||
identity.gid,
|
||||
);
|
||||
const descriptorPath = path.join(paths.service, serviceDescriptor.fileName);
|
||||
const composeSelection =
|
||||
command.options.service.kind === 'compose'
|
||||
? initialComposeImageSelection(command)
|
||||
: undefined;
|
||||
|
||||
preflightPublishedFile(
|
||||
paths.applicationConfig,
|
||||
applicationConfig,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'application configuration',
|
||||
);
|
||||
preflightPublishedFile(
|
||||
descriptorPath,
|
||||
serviceDescriptor.contents,
|
||||
serviceDescriptor.mode,
|
||||
identity.uid,
|
||||
'service descriptor',
|
||||
);
|
||||
if (composeSelection !== undefined) {
|
||||
preflightPublishedFile(
|
||||
path.join(paths.composeRevisions, '1.yaml'),
|
||||
composeSelection,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'initial compose revision',
|
||||
);
|
||||
preflightActiveComposeImageSelection(
|
||||
paths.composeSelection,
|
||||
paths.composeRevisions,
|
||||
composeSelection,
|
||||
identity.uid,
|
||||
);
|
||||
}
|
||||
|
||||
const setup = await executeLocalSetup(setupCommand(command, paths));
|
||||
const applicationStatus = publishExactFile(
|
||||
paths.applicationConfig,
|
||||
applicationConfig,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'application configuration',
|
||||
);
|
||||
const serviceStatus = publishExactFile(
|
||||
descriptorPath,
|
||||
serviceDescriptor.contents,
|
||||
serviceDescriptor.mode,
|
||||
identity.uid,
|
||||
'service descriptor',
|
||||
);
|
||||
const composeRevisionStatus =
|
||||
composeSelection === undefined
|
||||
? 'existing'
|
||||
: publishExactFile(
|
||||
path.join(paths.composeRevisions, '1.yaml'),
|
||||
composeSelection,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'initial compose revision',
|
||||
);
|
||||
const composeSelectionStatus =
|
||||
composeSelection === undefined
|
||||
? 'existing'
|
||||
: preflightActiveComposeImageSelection(
|
||||
paths.composeSelection,
|
||||
paths.composeRevisions,
|
||||
composeSelection,
|
||||
identity.uid,
|
||||
) === 'existing'
|
||||
? 'existing'
|
||||
: publishExactFile(
|
||||
paths.composeSelection,
|
||||
composeSelection,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'active compose selection',
|
||||
);
|
||||
const createdDirectories = directoryStatuses.filter(
|
||||
(status) => status === 'prepared',
|
||||
).length;
|
||||
const prepared =
|
||||
createdDirectories > 0 ||
|
||||
setup.status === 'prepared' ||
|
||||
applicationStatus === 'prepared' ||
|
||||
serviceStatus === 'prepared';
|
||||
const deploymentPrepared =
|
||||
prepared ||
|
||||
composeSelectionStatus === 'prepared' ||
|
||||
composeRevisionStatus === 'prepared';
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
status: deploymentPrepared ? ('prepared' as const) : ('existing' as const),
|
||||
profile: command.options.profile,
|
||||
service: Object.freeze({
|
||||
kind: command.options.service.kind,
|
||||
status: serviceStatus,
|
||||
}),
|
||||
applicationConfiguration: Object.freeze({
|
||||
schema: 'qinglong/local-application-process@v2' as const,
|
||||
status: applicationStatus,
|
||||
}),
|
||||
directories: Object.freeze({
|
||||
created: createdDirectories,
|
||||
existing: directoryStatuses.length - createdDirectories,
|
||||
}),
|
||||
setup,
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareLocalDeploymentCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalDeploymentPrepareResult>> {
|
||||
return prepareLocalDeployment(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Keep the deployment binary beside its composition authority.
|
||||
import {
|
||||
applyLocalDeploymentComposeCommandFile,
|
||||
collectLocalDeploymentComposeEvidenceCommitCommandFile,
|
||||
collectLocalDeploymentComposeEvidencePrepareCommandFile,
|
||||
consumeLocalServiceManagerOutcomeCommandFile,
|
||||
consumeLocalServiceManagerCutoverOutcomeCommandFile,
|
||||
inspectLocalDeploymentStatusCommandFile,
|
||||
preflightLocalDeploymentComposeCommandFile,
|
||||
prepareLocalServiceManagerIntentCommandFile,
|
||||
prepareLocalDeploymentCommandFile,
|
||||
restoreLocalDeploymentComposeCommitCommandFile,
|
||||
restoreLocalDeploymentComposePrepareCommandFile,
|
||||
runLocalDeploymentCutoverManualCommandFile,
|
||||
runLocalDeploymentLegacyRollbackCommandFile,
|
||||
runLocalDeploymentDockerTargetCommandFile,
|
||||
stopLocalDeploymentDockerTargetCommandFile,
|
||||
stopLegacyDockerForLocalDeploymentCommandFile,
|
||||
switchLocalDeploymentComposeRevisionCommandFile,
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
(argv[0] !== 'prepare' &&
|
||||
argv[0] !== 'status' &&
|
||||
argv[0] !== 'service-intent-prepare' &&
|
||||
argv[0] !== 'service-outcome-consume' &&
|
||||
argv[0] !== 'service-cutover-consume' &&
|
||||
argv[0] !== 'cutover-legacy-stop' &&
|
||||
argv[0] !== 'cutover-target-start' &&
|
||||
argv[0] !== 'cutover-target-restart' &&
|
||||
argv[0] !== 'cutover-target-stop' &&
|
||||
argv[0] !== 'cutover-legacy-rollback-prepare' &&
|
||||
argv[0] !== 'cutover-legacy-rollback-commit' &&
|
||||
argv[0] !== 'cutover-manual-diagnose' &&
|
||||
argv[0] !== 'cutover-manual-resolution-prepare' &&
|
||||
argv[0] !== 'cutover-manual-resolution-commit' &&
|
||||
argv[0] !== 'compose-revision' &&
|
||||
argv[0] !== 'compose-preflight' &&
|
||||
argv[0] !== 'compose-apply' &&
|
||||
argv[0] !== 'compose-restore-prepare' &&
|
||||
argv[0] !== 'compose-restore-commit' &&
|
||||
argv[0] !== 'compose-evidence-collect-prepare' &&
|
||||
argv[0] !== 'compose-evidence-collect-commit') ||
|
||||
argv[1] !== '--command-file'
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_LOCAL_DEPLOYMENT_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const output = await (argv[0] === 'prepare'
|
||||
? prepareLocalDeploymentCommandFile(argv[2]!)
|
||||
: argv[0] === 'status'
|
||||
? inspectLocalDeploymentStatusCommandFile(argv[2]!)
|
||||
: argv[0] === 'service-intent-prepare'
|
||||
? prepareLocalServiceManagerIntentCommandFile(argv[2]!)
|
||||
: argv[0] === 'service-outcome-consume'
|
||||
? consumeLocalServiceManagerOutcomeCommandFile(argv[2]!)
|
||||
: argv[0] === 'service-cutover-consume'
|
||||
? consumeLocalServiceManagerCutoverOutcomeCommandFile(argv[2]!)
|
||||
: argv[0] === 'cutover-legacy-stop'
|
||||
? stopLegacyDockerForLocalDeploymentCommandFile(argv[2]!)
|
||||
: argv[0] === 'cutover-target-start' ||
|
||||
argv[0] === 'cutover-target-restart'
|
||||
? runLocalDeploymentDockerTargetCommandFile(argv[2]!)
|
||||
: argv[0] === 'cutover-target-stop'
|
||||
? stopLocalDeploymentDockerTargetCommandFile(argv[2]!)
|
||||
: argv[0] === 'cutover-legacy-rollback-prepare' ||
|
||||
argv[0] === 'cutover-legacy-rollback-commit'
|
||||
? runLocalDeploymentLegacyRollbackCommandFile(
|
||||
argv[2]!,
|
||||
argv[0] === 'cutover-legacy-rollback-prepare'
|
||||
? 'local.deployment.cutover.legacy-rollback-prepare'
|
||||
: 'local.deployment.cutover.legacy-rollback-commit',
|
||||
)
|
||||
: argv[0] === 'cutover-manual-diagnose' ||
|
||||
argv[0] === 'cutover-manual-resolution-prepare' ||
|
||||
argv[0] === 'cutover-manual-resolution-commit'
|
||||
? runLocalDeploymentCutoverManualCommandFile(
|
||||
argv[2]!,
|
||||
argv[0] === 'cutover-manual-diagnose'
|
||||
? 'local.deployment.cutover.manual-diagnose'
|
||||
: argv[0] === 'cutover-manual-resolution-prepare'
|
||||
? 'local.deployment.cutover.manual-resolution-prepare'
|
||||
: 'local.deployment.cutover.manual-resolution-commit',
|
||||
)
|
||||
: argv[0] === 'compose-revision'
|
||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-preflight'
|
||||
? preflightLocalDeploymentComposeCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-apply'
|
||||
? applyLocalDeploymentComposeCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-restore-prepare'
|
||||
? restoreLocalDeploymentComposePrepareCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-restore-commit'
|
||||
? restoreLocalDeploymentComposeCommitCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-evidence-collect-prepare'
|
||||
? collectLocalDeploymentComposeEvidencePrepareCommandFile(argv[2]!)
|
||||
: collectLocalDeploymentComposeEvidenceCommitCommandFile(argv[2]!));
|
||||
process.stdout.write(`${JSON.stringify(output)}\n`);
|
||||
if (
|
||||
argv[0] === 'compose-apply' &&
|
||||
'status' in output &&
|
||||
output.status !== 'active'
|
||||
) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'QL3_LOCAL_DEPLOYMENT_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,237 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { inspectActiveComposeImageSelection } from './compose/composeRevision';
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
normalizeLocalDeploymentStatusCommand,
|
||||
type LocalDeploymentProfile,
|
||||
type LocalDeploymentServiceKind,
|
||||
type LocalDeploymentStatusResult,
|
||||
} from './foundation/contract';
|
||||
import { validatePrivateDirectory } from './foundation/files';
|
||||
import {
|
||||
deploymentPaths,
|
||||
type LocalDeploymentPaths,
|
||||
} from './foundation/render';
|
||||
|
||||
const MAX_OBSERVED_FILE_BYTES = 64 * 1024;
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
function boundedPrivateFile(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
mode: number,
|
||||
label: string,
|
||||
): string {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
configurationError(`${label} is unavailable`, error);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== mode ||
|
||||
stat.nlink !== 1 ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_OBSERVED_FILE_BYTES
|
||||
) {
|
||||
configurationError(`${label} identity is invalid`);
|
||||
}
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function applicationProfile(
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
uid: number,
|
||||
): LocalDeploymentProfile {
|
||||
const contents = boundedPrivateFile(
|
||||
paths.applicationConfig,
|
||||
uid,
|
||||
0o600,
|
||||
'application configuration',
|
||||
);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(contents);
|
||||
} catch (error) {
|
||||
configurationError('application configuration is invalid', error);
|
||||
}
|
||||
const candidate = value as {
|
||||
readonly schema?: unknown;
|
||||
readonly profile?: unknown;
|
||||
};
|
||||
if (
|
||||
candidate?.schema !== 'qinglong/local-application-process@v2' ||
|
||||
(candidate.profile !== 'edge' && candidate.profile !== 'standalone')
|
||||
) {
|
||||
configurationError('application configuration binding drifted');
|
||||
}
|
||||
return candidate.profile;
|
||||
}
|
||||
|
||||
function serviceKind(
|
||||
paths: Readonly<LocalDeploymentPaths>,
|
||||
uid: number,
|
||||
): LocalDeploymentServiceKind {
|
||||
const candidates = [
|
||||
{
|
||||
kind: 'systemd' as const,
|
||||
fileName: 'qinglong3.service',
|
||||
mode: 0o600,
|
||||
markers: [
|
||||
'[Unit]\n',
|
||||
'Description=QingLong 3.0 local automation runtime',
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'openrc' as const,
|
||||
fileName: 'qinglong3.openrc',
|
||||
mode: 0o700,
|
||||
markers: [
|
||||
'#!/sbin/openrc-run\n',
|
||||
'description="QingLong 3.0 local automation runtime"',
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'compose' as const,
|
||||
fileName: 'compose.yaml',
|
||||
mode: 0o600,
|
||||
markers: ['name: ql3-', '\nservices:\n qinglong3:\n'],
|
||||
},
|
||||
].filter((candidate) =>
|
||||
fs.existsSync(path.join(paths.service, candidate.fileName)),
|
||||
);
|
||||
if (candidates.length !== 1) {
|
||||
configurationError('service descriptor selection drifted');
|
||||
}
|
||||
const selected = candidates[0]!;
|
||||
const contents = boundedPrivateFile(
|
||||
path.join(paths.service, selected.fileName),
|
||||
uid,
|
||||
selected.mode,
|
||||
'service descriptor',
|
||||
);
|
||||
if (selected.markers.some((marker) => !contents.includes(marker))) {
|
||||
configurationError('service descriptor binding drifted');
|
||||
}
|
||||
return selected.kind;
|
||||
}
|
||||
|
||||
function fence(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): 'idle' | 'in_flight' {
|
||||
if (!fs.existsSync(filePath)) return 'idle';
|
||||
boundedPrivateFile(filePath, uid, 0o600, label);
|
||||
return 'in_flight';
|
||||
}
|
||||
|
||||
export function inspectLocalDeploymentStatus(
|
||||
input: unknown,
|
||||
): Readonly<LocalDeploymentStatusResult> {
|
||||
const command = normalizeLocalDeploymentStatusCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const paths = deploymentPaths(command.options.deploymentRoot);
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validatePrivateDirectory(
|
||||
paths.service,
|
||||
identity.uid,
|
||||
'serviceDescriptorRoot',
|
||||
);
|
||||
const profile = applicationProfile(paths, identity.uid);
|
||||
const kind = serviceKind(paths, identity.uid);
|
||||
const common = {
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.status' as const,
|
||||
status: 'observed' as const,
|
||||
observation: 'durable' as const,
|
||||
profile,
|
||||
applicationConfiguration: Object.freeze({
|
||||
schema: 'qinglong/local-application-process@v2' as const,
|
||||
state: 'present' as const,
|
||||
}),
|
||||
runtime: Object.freeze({ health: 'unobserved' as const }),
|
||||
};
|
||||
if (kind !== 'compose') {
|
||||
return Object.freeze({
|
||||
...common,
|
||||
service: Object.freeze({
|
||||
kind,
|
||||
descriptor: 'present' as const,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
validatePrivateDirectory(
|
||||
paths.composeRevisions,
|
||||
identity.uid,
|
||||
'composeRevisionRoot',
|
||||
);
|
||||
const selection = inspectActiveComposeImageSelection(
|
||||
paths.composeSelection,
|
||||
paths.composeRevisions,
|
||||
identity.uid,
|
||||
);
|
||||
const fences = Object.freeze({
|
||||
revision: fence(
|
||||
paths.composeRevisionLock,
|
||||
identity.uid,
|
||||
'compose revision lock',
|
||||
),
|
||||
rollout: fence(
|
||||
paths.composeRolloutLock,
|
||||
identity.uid,
|
||||
'compose rollout lock',
|
||||
),
|
||||
restore: fence(
|
||||
paths.composeRestoreLock,
|
||||
identity.uid,
|
||||
'compose restore lock',
|
||||
),
|
||||
evidenceCollection: fence(
|
||||
paths.composeEvidenceCollectionLock,
|
||||
identity.uid,
|
||||
'compose evidence collection lock',
|
||||
),
|
||||
});
|
||||
const recoveryRequired = Object.values(fences).some(
|
||||
(state) => state === 'in_flight',
|
||||
);
|
||||
return Object.freeze({
|
||||
...common,
|
||||
service: Object.freeze({
|
||||
kind: 'compose' as const,
|
||||
descriptor: 'present' as const,
|
||||
generation: selection.generation,
|
||||
rollbackTargetGeneration:
|
||||
selection.rollbackTargetGeneration === 0
|
||||
? null
|
||||
: selection.rollbackTargetGeneration,
|
||||
transition: recoveryRequired
|
||||
? ('recovery_required' as const)
|
||||
: ('stable' as const),
|
||||
fences,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function inspectLocalDeploymentStatusCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalDeploymentStatusResult> {
|
||||
return inspectLocalDeploymentStatus(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import {
|
||||
normalizeLocalServiceBridgeCommand,
|
||||
normalizeLocalServiceManagerIntent,
|
||||
localServiceManagerIntentPath,
|
||||
localServiceManagerOutcomePath,
|
||||
type LocalServiceBridgeCommand,
|
||||
type LocalServiceBridgeManager,
|
||||
type LocalServiceManagerIntent,
|
||||
} from './serviceBridgeContract';
|
||||
import {
|
||||
ensureRootServiceBridgeDirectory,
|
||||
publishServiceBridgeFile,
|
||||
readOwnerPrivateJsonFile,
|
||||
readServiceBridgeFile,
|
||||
validateServiceBridgeDirectory,
|
||||
} from './serviceBridgeFiles';
|
||||
import {
|
||||
localServiceManagerObservationDigest,
|
||||
localServiceManagerOutcomeDigest,
|
||||
normalizeLocalServiceManagerObservation,
|
||||
normalizeLocalServiceManagerOutcome,
|
||||
type LocalServiceManagerManualReason,
|
||||
type LocalServiceManagerMutationDisposition,
|
||||
type LocalServiceManagerObservation,
|
||||
type LocalServiceManagerOutcome,
|
||||
} from './serviceOutcomeContract';
|
||||
|
||||
const MAX_MANAGER_OUTPUT_BYTES = 64 * 1024;
|
||||
const MANAGER_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface ServiceBridgeBarrier {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-local-service-bridge-barrier';
|
||||
readonly actionId: string;
|
||||
readonly intentDigest: string;
|
||||
readonly descriptorDigest: string;
|
||||
readonly managerKind: 'systemd' | 'openrc';
|
||||
readonly preObservation: Readonly<LocalServiceManagerObservation>;
|
||||
readonly createdAtMs: number;
|
||||
readonly barrierDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceBridgeRunResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.execute';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: LocalServiceManagerOutcome['state'];
|
||||
readonly actionId: string;
|
||||
readonly outcomeDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerRunRequest {
|
||||
readonly executable: string;
|
||||
readonly args: readonly string[];
|
||||
readonly timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerRunResult {
|
||||
readonly status: number | null;
|
||||
readonly signal: NodeJS.Signals | null;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly responseLost: boolean;
|
||||
}
|
||||
|
||||
export interface LocalServiceBridgeDependencies {
|
||||
readonly runManager?: (
|
||||
request: Readonly<LocalServiceManagerRunRequest>,
|
||||
) => Readonly<LocalServiceManagerRunResult>;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function assertRootIdentity(): void {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
typeof process.getgid !== 'function' ||
|
||||
typeof process.getegid !== 'function' ||
|
||||
process.getuid() !== 0 ||
|
||||
process.geteuid() !== 0 ||
|
||||
process.getgid() !== 0 ||
|
||||
process.getegid() !== 0
|
||||
) {
|
||||
configurationError('service bridge requires matching root identities');
|
||||
}
|
||||
}
|
||||
|
||||
function trustedRootExecutable(filePath: string, label: string): void {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
configurationError(`${label} is unavailable`, error);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== 0 ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(stat.mode & 0o111) === 0 ||
|
||||
fs.realpathSync(filePath) !== filePath
|
||||
) {
|
||||
configurationError(`${label} must be a canonical root-owned executable`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRootDestinationParent(destinationPath: string): void {
|
||||
const directory = path.dirname(destinationPath);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory);
|
||||
} catch (error) {
|
||||
configurationError('service descriptor destination is unavailable', error);
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== 0 ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
configurationError('service descriptor destination is not trusted');
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRunManager(
|
||||
request: Readonly<LocalServiceManagerRunRequest>,
|
||||
): Readonly<LocalServiceManagerRunResult> {
|
||||
const result = spawnSync(request.executable, [...request.args], {
|
||||
encoding: 'utf8',
|
||||
env: Object.freeze({
|
||||
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
LANG: 'C',
|
||||
LC_ALL: 'C',
|
||||
}),
|
||||
timeout: request.timeoutMs,
|
||||
maxBuffer: MAX_MANAGER_OUTPUT_BYTES,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
return Object.freeze({
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
responseLost: result.error !== undefined || result.status === null,
|
||||
});
|
||||
}
|
||||
|
||||
function run(
|
||||
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
|
||||
executable: string,
|
||||
args: readonly string[],
|
||||
): Readonly<LocalServiceManagerRunResult> {
|
||||
const result = runManager({
|
||||
executable,
|
||||
args: Object.freeze([...args]),
|
||||
timeoutMs: MANAGER_TIMEOUT_MS,
|
||||
});
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== 'object' ||
|
||||
(result.status !== null && !Number.isSafeInteger(result.status)) ||
|
||||
(result.signal !== null && typeof result.signal !== 'string') ||
|
||||
typeof result.stdout !== 'string' ||
|
||||
typeof result.stderr !== 'string' ||
|
||||
typeof result.responseLost !== 'boolean' ||
|
||||
Buffer.byteLength(result.stdout, 'utf8') > MAX_MANAGER_OUTPUT_BYTES ||
|
||||
Buffer.byteLength(result.stderr, 'utf8') > MAX_MANAGER_OUTPUT_BYTES
|
||||
) {
|
||||
configurationError('service manager runner returned an invalid result');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function systemdObservation(
|
||||
manager: Extract<LocalServiceBridgeManager, { kind: 'systemd' }>,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
|
||||
now: () => number,
|
||||
): Readonly<LocalServiceManagerObservation> {
|
||||
const result = run(runManager, manager.executable, [
|
||||
'show',
|
||||
'qinglong3.service',
|
||||
'--no-page',
|
||||
'--property=LoadState,ActiveState,SubState,FragmentPath,MainPID,UnitFileState',
|
||||
]);
|
||||
const fields = new Map<string, string>();
|
||||
if (!result.responseLost && result.status === 0) {
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const separator = line.indexOf('=');
|
||||
if (separator > 0)
|
||||
fields.set(line.slice(0, separator), line.slice(separator + 1));
|
||||
}
|
||||
}
|
||||
const rawPid = Number(fields.get('MainPID') ?? '0');
|
||||
const loadState = fields.get('LoadState');
|
||||
const activeState = fields.get('ActiveState');
|
||||
const enabledState = fields.get('UnitFileState');
|
||||
const payload = Object.freeze({
|
||||
managerKind: 'systemd' as const,
|
||||
serviceName: 'qinglong3' as const,
|
||||
fragmentPath:
|
||||
fields.get('FragmentPath') ?? intent.descriptor.destinationPath,
|
||||
loadState:
|
||||
loadState === 'loaded'
|
||||
? ('loaded' as const)
|
||||
: loadState === 'not-found'
|
||||
? ('not-found' as const)
|
||||
: ('unknown' as const),
|
||||
activeState:
|
||||
activeState === 'active'
|
||||
? ('active' as const)
|
||||
: activeState === 'inactive'
|
||||
? ('inactive' as const)
|
||||
: activeState === 'failed'
|
||||
? ('failed' as const)
|
||||
: ('unknown' as const),
|
||||
subState: (fields.get('SubState') ?? 'unknown').slice(0, 128),
|
||||
enabledState:
|
||||
enabledState === 'enabled'
|
||||
? ('enabled' as const)
|
||||
: enabledState === 'disabled'
|
||||
? ('disabled' as const)
|
||||
: enabledState === 'static'
|
||||
? ('static' as const)
|
||||
: ('unknown' as const),
|
||||
mainPid:
|
||||
Number.isSafeInteger(rawPid) && rawPid >= 0 && rawPid <= 0x7fffffff
|
||||
? rawPid
|
||||
: 0,
|
||||
observedAtMs: now(),
|
||||
});
|
||||
return normalizeLocalServiceManagerObservation({
|
||||
...payload,
|
||||
observationDigest: localServiceManagerObservationDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function openrcObservation(
|
||||
manager: Extract<LocalServiceBridgeManager, { kind: 'openrc' }>,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
|
||||
now: () => number,
|
||||
): Readonly<LocalServiceManagerObservation> {
|
||||
const service = run(runManager, manager.serviceExecutable, [
|
||||
'qinglong3',
|
||||
'status',
|
||||
]);
|
||||
const enabled = run(runManager, manager.updateExecutable, [
|
||||
'show',
|
||||
'default',
|
||||
]);
|
||||
const active = !service.responseLost && service.status === 0;
|
||||
const stopped =
|
||||
!service.responseLost &&
|
||||
service.status !== null &&
|
||||
(service.stdout.includes('stopped') || service.status === 3);
|
||||
const payload = Object.freeze({
|
||||
managerKind: 'openrc' as const,
|
||||
serviceName: 'qinglong3' as const,
|
||||
fragmentPath: intent.descriptor.destinationPath,
|
||||
loadState: fs.existsSync(intent.descriptor.destinationPath)
|
||||
? ('loaded' as const)
|
||||
: ('not-found' as const),
|
||||
activeState: active
|
||||
? ('active' as const)
|
||||
: stopped
|
||||
? ('inactive' as const)
|
||||
: ('unknown' as const),
|
||||
subState: active ? 'started' : stopped ? 'stopped' : 'unknown',
|
||||
enabledState:
|
||||
!enabled.responseLost &&
|
||||
enabled.status === 0 &&
|
||||
enabled.stdout.split('\n').some((line) => /\bqinglong3\b/.test(line))
|
||||
? ('enabled' as const)
|
||||
: ('unknown' as const),
|
||||
mainPid: 0,
|
||||
observedAtMs: now(),
|
||||
});
|
||||
return normalizeLocalServiceManagerObservation({
|
||||
...payload,
|
||||
observationDigest: localServiceManagerObservationDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function inspectManager(
|
||||
manager: LocalServiceBridgeManager,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
|
||||
now: () => number,
|
||||
): Readonly<LocalServiceManagerObservation> {
|
||||
return manager.kind === 'systemd'
|
||||
? systemdObservation(manager, intent, runManager, now)
|
||||
: openrcObservation(manager, intent, runManager, now);
|
||||
}
|
||||
|
||||
function managerExecutables(
|
||||
manager: LocalServiceBridgeManager,
|
||||
): readonly string[] {
|
||||
return manager.kind === 'systemd'
|
||||
? Object.freeze([manager.executable])
|
||||
: Object.freeze([manager.serviceExecutable, manager.updateExecutable]);
|
||||
}
|
||||
|
||||
function executeMutation(
|
||||
manager: LocalServiceBridgeManager,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
runManager: NonNullable<LocalServiceBridgeDependencies['runManager']>,
|
||||
): Readonly<{ failed: boolean; responseLost: boolean }> {
|
||||
const results: LocalServiceManagerRunResult[] = [];
|
||||
if (manager.kind === 'systemd') {
|
||||
if (intent.action === 'install-enable-start') {
|
||||
results.push(run(runManager, manager.executable, ['daemon-reload']));
|
||||
results.push(
|
||||
run(runManager, manager.executable, ['enable', 'qinglong3.service']),
|
||||
);
|
||||
results.push(
|
||||
run(runManager, manager.executable, ['start', 'qinglong3.service']),
|
||||
);
|
||||
} else {
|
||||
results.push(
|
||||
run(runManager, manager.executable, [
|
||||
intent.action,
|
||||
'qinglong3.service',
|
||||
]),
|
||||
);
|
||||
}
|
||||
} else if (intent.action === 'install-enable-start') {
|
||||
results.push(
|
||||
run(runManager, manager.updateExecutable, [
|
||||
'add',
|
||||
'qinglong3',
|
||||
'default',
|
||||
]),
|
||||
);
|
||||
results.push(
|
||||
run(runManager, manager.serviceExecutable, ['qinglong3', 'start']),
|
||||
);
|
||||
} else if (intent.action === 'restart') {
|
||||
results.push(
|
||||
run(runManager, manager.serviceExecutable, ['qinglong3', 'stop']),
|
||||
);
|
||||
results.push(
|
||||
run(runManager, manager.serviceExecutable, ['qinglong3', 'start']),
|
||||
);
|
||||
} else {
|
||||
results.push(
|
||||
run(runManager, manager.serviceExecutable, ['qinglong3', intent.action]),
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
failed: results.some(
|
||||
(result) => !result.responseLost && result.status !== 0,
|
||||
),
|
||||
responseLost: results.some((result) => result.responseLost),
|
||||
});
|
||||
}
|
||||
|
||||
function desiredStateProved(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
before: Readonly<LocalServiceManagerObservation>,
|
||||
after: Readonly<LocalServiceManagerObservation>,
|
||||
): boolean {
|
||||
if (
|
||||
after.managerKind !== intent.service.kind ||
|
||||
after.fragmentPath !== intent.descriptor.destinationPath ||
|
||||
after.loadState !== 'loaded'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (intent.action === 'stop') {
|
||||
return after.activeState === 'inactive' && after.mainPid === 0;
|
||||
}
|
||||
if (
|
||||
after.activeState !== 'active' ||
|
||||
(intent.action === 'install-enable-start' &&
|
||||
after.enabledState !== 'enabled')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (intent.service.kind === 'openrc') return true;
|
||||
if (after.mainPid < 1) return false;
|
||||
return (
|
||||
intent.action !== 'restart' ||
|
||||
before.mainPid === 0 ||
|
||||
before.mainPid !== after.mainPid
|
||||
);
|
||||
}
|
||||
|
||||
function barrierRecord(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
observation: Readonly<LocalServiceManagerObservation>,
|
||||
now: () => number,
|
||||
): Readonly<ServiceBridgeBarrier> {
|
||||
const payload = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: 'qinglong3-local-service-bridge-barrier' as const,
|
||||
actionId: intent.actionId,
|
||||
intentDigest: intent.intentDigest,
|
||||
descriptorDigest: intent.descriptor.sha256,
|
||||
managerKind: intent.service.kind,
|
||||
preObservation: observation,
|
||||
createdAtMs: now(),
|
||||
});
|
||||
return Object.freeze({ ...payload, barrierDigest: digest(payload) });
|
||||
}
|
||||
|
||||
function normalizeBarrier(
|
||||
value: unknown,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
): Readonly<ServiceBridgeBarrier> {
|
||||
const barrier = object(value, 'service bridge barrier');
|
||||
exact(
|
||||
barrier,
|
||||
[
|
||||
'actionId',
|
||||
'barrierDigest',
|
||||
'createdAtMs',
|
||||
'descriptorDigest',
|
||||
'intentDigest',
|
||||
'kind',
|
||||
'managerKind',
|
||||
'preObservation',
|
||||
'schemaVersion',
|
||||
],
|
||||
'service bridge barrier',
|
||||
);
|
||||
const observation = normalizeLocalServiceManagerObservation(
|
||||
barrier.preObservation,
|
||||
);
|
||||
const { barrierDigest, ...payload } = barrier;
|
||||
if (
|
||||
barrier.schemaVersion !== 1 ||
|
||||
barrier.kind !== 'qinglong3-local-service-bridge-barrier' ||
|
||||
barrier.actionId !== intent.actionId ||
|
||||
barrier.intentDigest !== intent.intentDigest ||
|
||||
barrier.descriptorDigest !== intent.descriptor.sha256 ||
|
||||
barrier.managerKind !== intent.service.kind ||
|
||||
observation.managerKind !== intent.service.kind ||
|
||||
!Number.isSafeInteger(barrier.createdAtMs) ||
|
||||
(barrier.createdAtMs as number) < 0 ||
|
||||
typeof barrierDigest !== 'string' ||
|
||||
digest({ ...payload, preObservation: observation }) !== barrierDigest
|
||||
) {
|
||||
configurationError('service bridge barrier drifted');
|
||||
}
|
||||
return barrier as unknown as Readonly<ServiceBridgeBarrier>;
|
||||
}
|
||||
|
||||
function outcomeRecord(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
state: LocalServiceManagerOutcome['state'],
|
||||
disposition: LocalServiceManagerMutationDisposition,
|
||||
reason: LocalServiceManagerManualReason | null,
|
||||
observation: Readonly<LocalServiceManagerObservation>,
|
||||
now: () => number,
|
||||
): Readonly<LocalServiceManagerOutcome> {
|
||||
const payload = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: 'qinglong3-local-service-manager-outcome' as const,
|
||||
actionId: intent.actionId,
|
||||
action: intent.action,
|
||||
intentDigest: intent.intentDigest,
|
||||
descriptorDigest: intent.descriptor.sha256,
|
||||
state,
|
||||
mutationDisposition: disposition,
|
||||
manualReason: reason,
|
||||
observation,
|
||||
completedAtMs: Math.max(now(), observation.observedAtMs),
|
||||
});
|
||||
return normalizeLocalServiceManagerOutcome({
|
||||
...payload,
|
||||
outcomeDigest: localServiceManagerOutcomeDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function readRootJson(filePath: string, label: string): unknown {
|
||||
const file = readOwnerPrivateJsonFile(filePath, label);
|
||||
if (file.uid !== 0 || file.gid !== 0) {
|
||||
configurationError(`${label} is not root-owned`);
|
||||
}
|
||||
return file.value;
|
||||
}
|
||||
|
||||
function publishOutcome(
|
||||
actionDirectory: string,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
outcome: Readonly<LocalServiceManagerOutcome>,
|
||||
): 'prepared' | 'existing' {
|
||||
const contents = `${JSON.stringify(outcome, null, 2)}\n`;
|
||||
const rootStatus = publishServiceBridgeFile(
|
||||
path.join(actionDirectory, 'outcome.json'),
|
||||
contents,
|
||||
0o600,
|
||||
0,
|
||||
0,
|
||||
'service bridge root outcome',
|
||||
);
|
||||
publishServiceBridgeFile(
|
||||
intent.outcomePath,
|
||||
contents,
|
||||
0o600,
|
||||
intent.service.uid,
|
||||
intent.service.gid,
|
||||
'service bridge Owner outcome',
|
||||
);
|
||||
return rootStatus;
|
||||
}
|
||||
|
||||
function validateIntentMaterial(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
): Buffer {
|
||||
validateServiceBridgeDirectory(
|
||||
intent.deployment.root,
|
||||
intent.service.uid,
|
||||
intent.service.gid,
|
||||
0o700,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validateServiceBridgeDirectory(
|
||||
path.join(intent.deployment.root, 'service'),
|
||||
intent.service.uid,
|
||||
intent.service.gid,
|
||||
0o700,
|
||||
'serviceDescriptorRoot',
|
||||
);
|
||||
validateServiceBridgeDirectory(
|
||||
path.dirname(intent.outcomePath),
|
||||
intent.service.uid,
|
||||
intent.service.gid,
|
||||
0o700,
|
||||
'serviceManagerOutcomeRoot',
|
||||
);
|
||||
const application = readServiceBridgeFile(
|
||||
intent.deployment.applicationConfigPath,
|
||||
{
|
||||
uid: intent.service.uid,
|
||||
gid: intent.service.gid,
|
||||
mode: 0o600,
|
||||
},
|
||||
'application configuration',
|
||||
);
|
||||
const descriptor = readServiceBridgeFile(
|
||||
intent.descriptor.sourcePath,
|
||||
{
|
||||
uid: intent.service.uid,
|
||||
gid: intent.service.gid,
|
||||
mode: intent.descriptor.sourceMode,
|
||||
maximumBytes: 64 * 1024,
|
||||
},
|
||||
'service descriptor',
|
||||
);
|
||||
try {
|
||||
if (
|
||||
sha256(application) !== intent.deployment.applicationConfigSha256 ||
|
||||
sha256(descriptor) !== intent.descriptor.sha256
|
||||
) {
|
||||
configurationError('service manager source material drifted');
|
||||
}
|
||||
return descriptor;
|
||||
} finally {
|
||||
application.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function destinationMatches(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
): boolean {
|
||||
try {
|
||||
const bytes = readServiceBridgeFile(
|
||||
intent.descriptor.destinationPath,
|
||||
{
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
mode: intent.descriptor.destinationMode,
|
||||
maximumBytes: 64 * 1024,
|
||||
},
|
||||
'installed service descriptor',
|
||||
);
|
||||
try {
|
||||
return sha256(bytes) === intent.descriptor.sha256;
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function runLocalServiceBridge(
|
||||
input: unknown,
|
||||
dependencies: Readonly<LocalServiceBridgeDependencies> = {},
|
||||
): Readonly<LocalServiceBridgeRunResult> {
|
||||
assertRootIdentity();
|
||||
const command = normalizeLocalServiceBridgeCommand(input);
|
||||
const runManager = dependencies.runManager ?? defaultRunManager;
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const ownedIntent = readOwnerPrivateJsonFile(
|
||||
command.request.intentPath,
|
||||
'service manager intent',
|
||||
);
|
||||
const intent = normalizeLocalServiceManagerIntent(ownedIntent.value);
|
||||
if (
|
||||
intent.intentDigest !== command.request.expectedIntentDigest ||
|
||||
ownedIntent.uid !== intent.service.uid ||
|
||||
ownedIntent.gid !== intent.service.gid ||
|
||||
command.request.intentPath !==
|
||||
localServiceManagerIntentPath(intent.deployment.root, intent.actionId) ||
|
||||
intent.outcomePath !==
|
||||
localServiceManagerOutcomePath(intent.deployment.root, intent.actionId) ||
|
||||
command.options.manager.kind !== intent.service.kind
|
||||
) {
|
||||
configurationError('service bridge command and Owner intent drifted');
|
||||
}
|
||||
for (const executable of managerExecutables(command.options.manager)) {
|
||||
trustedRootExecutable(executable, 'service manager executable');
|
||||
}
|
||||
validateRootDestinationParent(intent.descriptor.destinationPath);
|
||||
const descriptor = validateIntentMaterial(intent);
|
||||
ensureRootServiceBridgeDirectory(
|
||||
command.options.controllerRoot,
|
||||
'serviceBridgeControllerRoot',
|
||||
);
|
||||
const actionDirectory = path.join(
|
||||
command.options.controllerRoot,
|
||||
intent.actionId,
|
||||
);
|
||||
ensureRootServiceBridgeDirectory(actionDirectory, 'serviceBridgeActionRoot');
|
||||
const barrierPath = path.join(actionDirectory, 'barrier.json');
|
||||
const rootOutcomePath = path.join(actionDirectory, 'outcome.json');
|
||||
try {
|
||||
if (fs.existsSync(rootOutcomePath)) {
|
||||
const existing = normalizeLocalServiceManagerOutcome(
|
||||
readRootJson(rootOutcomePath, 'service bridge root outcome'),
|
||||
);
|
||||
if (
|
||||
existing.actionId !== intent.actionId ||
|
||||
existing.intentDigest !== intent.intentDigest ||
|
||||
existing.descriptorDigest !== intent.descriptor.sha256
|
||||
) {
|
||||
configurationError('service bridge root outcome drifted');
|
||||
}
|
||||
publishOutcome(actionDirectory, intent, existing);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.service-manager.execute' as const,
|
||||
status: 'existing' as const,
|
||||
state: existing.state,
|
||||
actionId: existing.actionId,
|
||||
outcomeDigest: existing.outcomeDigest,
|
||||
});
|
||||
}
|
||||
const replay = fs.existsSync(barrierPath);
|
||||
const barrier = replay
|
||||
? normalizeBarrier(
|
||||
readRootJson(barrierPath, 'service bridge barrier'),
|
||||
intent,
|
||||
)
|
||||
: barrierRecord(
|
||||
intent,
|
||||
inspectManager(command.options.manager, intent, runManager, now),
|
||||
now,
|
||||
);
|
||||
if (!replay) {
|
||||
publishServiceBridgeFile(
|
||||
barrierPath,
|
||||
`${JSON.stringify(barrier, null, 2)}\n`,
|
||||
0o600,
|
||||
0,
|
||||
0,
|
||||
'service bridge barrier',
|
||||
);
|
||||
}
|
||||
let descriptorReady = destinationMatches(intent);
|
||||
if (
|
||||
!replay &&
|
||||
intent.action === 'install-enable-start' &&
|
||||
!descriptorReady
|
||||
) {
|
||||
publishServiceBridgeFile(
|
||||
intent.descriptor.destinationPath,
|
||||
descriptor.toString('utf8'),
|
||||
intent.descriptor.destinationMode,
|
||||
0,
|
||||
0,
|
||||
'installed service descriptor',
|
||||
);
|
||||
descriptorReady = destinationMatches(intent);
|
||||
}
|
||||
if (!descriptorReady) {
|
||||
const observation = inspectManager(
|
||||
command.options.manager,
|
||||
intent,
|
||||
runManager,
|
||||
now,
|
||||
);
|
||||
const outcome = outcomeRecord(
|
||||
intent,
|
||||
'manual_required',
|
||||
replay ? 'replay-inspected' : 'executed',
|
||||
'descriptor_install_unproved',
|
||||
observation,
|
||||
now,
|
||||
);
|
||||
const status = publishOutcome(actionDirectory, intent, outcome);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.service-manager.execute' as const,
|
||||
status,
|
||||
state: outcome.state,
|
||||
actionId: outcome.actionId,
|
||||
outcomeDigest: outcome.outcomeDigest,
|
||||
});
|
||||
}
|
||||
const mutation = replay
|
||||
? Object.freeze({ failed: false, responseLost: false })
|
||||
: executeMutation(command.options.manager, intent, runManager);
|
||||
const observation = inspectManager(
|
||||
command.options.manager,
|
||||
intent,
|
||||
runManager,
|
||||
now,
|
||||
);
|
||||
const proved = desiredStateProved(
|
||||
intent,
|
||||
barrier.preObservation,
|
||||
observation,
|
||||
);
|
||||
const disposition: LocalServiceManagerMutationDisposition = replay
|
||||
? 'replay-inspected'
|
||||
: mutation.responseLost
|
||||
? 'response-loss-inspected'
|
||||
: 'executed';
|
||||
const manualReason: LocalServiceManagerManualReason | null = proved
|
||||
? null
|
||||
: mutation.failed
|
||||
? 'manager_command_failed'
|
||||
: 'manager_state_unproved';
|
||||
const outcome = outcomeRecord(
|
||||
intent,
|
||||
proved
|
||||
? intent.action === 'stop'
|
||||
? 'stopped'
|
||||
: 'active'
|
||||
: 'manual_required',
|
||||
disposition,
|
||||
manualReason,
|
||||
observation,
|
||||
now,
|
||||
);
|
||||
const status = publishOutcome(actionDirectory, intent, outcome);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.service-manager.execute' as const,
|
||||
status,
|
||||
state: outcome.state,
|
||||
actionId: outcome.actionId,
|
||||
outcomeDigest: outcome.outcomeDigest,
|
||||
});
|
||||
} finally {
|
||||
descriptor.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function runLocalServiceBridgeCommandFile(
|
||||
commandFile: string,
|
||||
dependencies: Readonly<LocalServiceBridgeDependencies> = {},
|
||||
): Readonly<LocalServiceBridgeRunResult> {
|
||||
return runLocalServiceBridge(
|
||||
readPrivateLocalCommandFile(commandFile),
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalServiceBridgeCommandFile } from './serviceBridge';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-service-bridge run --command-file /absolute/root-owned-command.json';
|
||||
|
||||
function main(argv: readonly string[]): void {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 3 || argv[0] !== 'run' || argv[1] !== '--command-file') {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_SERVICE_BRIDGE_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = runLocalServiceBridgeCommandFile(argv[2]!);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
if (result.state === 'manual_required') process.exitCode = 2;
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'QL3_SERVICE_BRIDGE_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main(process.argv.slice(2));
|
||||
@@ -0,0 +1,453 @@
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_TARGET_GENERATION = 15;
|
||||
|
||||
export type LocalServiceManagerKind = 'systemd' | 'openrc';
|
||||
export type LocalServiceManagerAction =
|
||||
| 'install-enable-start'
|
||||
| 'start'
|
||||
| 'restart'
|
||||
| 'stop';
|
||||
|
||||
export type LocalServiceManagerIntentLineage =
|
||||
| Readonly<{ mode: 'fresh' }>
|
||||
| Readonly<{
|
||||
mode: 'adopted';
|
||||
cutoverId: string;
|
||||
generation: number;
|
||||
expectedActivationDigest: string;
|
||||
previousRecordDigest: string;
|
||||
}>;
|
||||
|
||||
export interface LocalServiceManagerIntent {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-local-service-manager-intent';
|
||||
readonly actionId: string;
|
||||
readonly action: LocalServiceManagerAction;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly service: Readonly<{
|
||||
kind: LocalServiceManagerKind;
|
||||
name: 'qinglong3';
|
||||
uid: number;
|
||||
gid: number;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly deployment: Readonly<{
|
||||
root: string;
|
||||
applicationConfigPath: string;
|
||||
applicationConfigSha256: string;
|
||||
}>;
|
||||
readonly descriptor: Readonly<{
|
||||
sourcePath: string;
|
||||
destinationPath: string;
|
||||
sha256: string;
|
||||
sourceMode: number;
|
||||
destinationMode: number;
|
||||
}>;
|
||||
readonly lineage: LocalServiceManagerIntentLineage;
|
||||
readonly outcomePath: string;
|
||||
readonly requestedAtMs: number;
|
||||
readonly intentDigest: string;
|
||||
}
|
||||
|
||||
export type LocalServiceBridgeManager =
|
||||
| Readonly<{
|
||||
kind: 'systemd';
|
||||
executable: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: 'openrc';
|
||||
serviceExecutable: string;
|
||||
updateExecutable: string;
|
||||
}>;
|
||||
|
||||
export interface LocalServiceBridgeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.execute';
|
||||
readonly options: Readonly<{
|
||||
controllerRoot: string;
|
||||
allowRootController: true;
|
||||
manager: LocalServiceBridgeManager;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
intentPath: string;
|
||||
expectedIntentDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function localServiceManagerIntentPath(
|
||||
deploymentRoot: string,
|
||||
actionId: string,
|
||||
): string {
|
||||
return path.join(
|
||||
deploymentRoot,
|
||||
'service',
|
||||
'service-manager-intents',
|
||||
`${actionId}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function localServiceManagerOutcomePath(
|
||||
deploymentRoot: string,
|
||||
actionId: string,
|
||||
): string {
|
||||
return path.join(
|
||||
deploymentRoot,
|
||||
'service',
|
||||
'service-manager-outcomes',
|
||||
`${actionId}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeAbsolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
configurationError(`${label} must be a supervisor-safe absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown, label: string, maximum: number): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
configurationError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function normalizedLineage(value: unknown): LocalServiceManagerIntentLineage {
|
||||
const lineage = object(value, 'lineage');
|
||||
if (lineage.mode === 'fresh') {
|
||||
exact(lineage, ['mode'], 'lineage');
|
||||
return Object.freeze({ mode: 'fresh' as const });
|
||||
}
|
||||
exact(
|
||||
lineage,
|
||||
[
|
||||
'cutoverId',
|
||||
'expectedActivationDigest',
|
||||
'generation',
|
||||
'mode',
|
||||
'previousRecordDigest',
|
||||
],
|
||||
'lineage',
|
||||
);
|
||||
if (
|
||||
lineage.mode !== 'adopted' ||
|
||||
typeof lineage.cutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(lineage.cutoverId) ||
|
||||
!Number.isSafeInteger(lineage.generation) ||
|
||||
(lineage.generation as number) < 1 ||
|
||||
(lineage.generation as number) > MAX_TARGET_GENERATION ||
|
||||
typeof lineage.expectedActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(lineage.expectedActivationDigest) ||
|
||||
typeof lineage.previousRecordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(lineage.previousRecordDigest)
|
||||
) {
|
||||
configurationError('adopted lineage is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: 'adopted' as const,
|
||||
cutoverId: lineage.cutoverId,
|
||||
generation: lineage.generation as number,
|
||||
expectedActivationDigest: lineage.expectedActivationDigest,
|
||||
previousRecordDigest: lineage.previousRecordDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function localServiceManagerIntentDigest(
|
||||
value: Omit<LocalServiceManagerIntent, 'intentDigest'>,
|
||||
): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceManagerIntent(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerIntent> {
|
||||
const intent = object(value, 'service manager intent');
|
||||
exact(
|
||||
intent,
|
||||
[
|
||||
'action',
|
||||
'actionId',
|
||||
'deployment',
|
||||
'descriptor',
|
||||
'instanceId',
|
||||
'intentDigest',
|
||||
'kind',
|
||||
'lineage',
|
||||
'outcomePath',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
'schemaVersion',
|
||||
'service',
|
||||
],
|
||||
'service manager intent',
|
||||
);
|
||||
const service = object(intent.service, 'service');
|
||||
exact(service, ['allowRootService', 'gid', 'kind', 'name', 'uid'], 'service');
|
||||
if (
|
||||
(service.kind !== 'systemd' && service.kind !== 'openrc') ||
|
||||
service.name !== 'qinglong3' ||
|
||||
typeof service.allowRootService !== 'boolean'
|
||||
) {
|
||||
configurationError('service identity is invalid');
|
||||
}
|
||||
const uid = safeInteger(service.uid, 'service uid', 0x7fffffff);
|
||||
const gid = safeInteger(service.gid, 'service gid', 0x7fffffff);
|
||||
if ((uid === 0) !== service.allowRootService) {
|
||||
configurationError('allowRootService does not match the service uid');
|
||||
}
|
||||
const deployment = object(intent.deployment, 'deployment');
|
||||
exact(
|
||||
deployment,
|
||||
['applicationConfigPath', 'applicationConfigSha256', 'root'],
|
||||
'deployment',
|
||||
);
|
||||
const root = safeAbsolutePath(deployment.root, 'deployment root');
|
||||
const applicationConfigPath = safeAbsolutePath(
|
||||
deployment.applicationConfigPath,
|
||||
'application config path',
|
||||
);
|
||||
const descriptor = object(intent.descriptor, 'descriptor');
|
||||
exact(
|
||||
descriptor,
|
||||
[
|
||||
'destinationMode',
|
||||
'destinationPath',
|
||||
'sha256',
|
||||
'sourceMode',
|
||||
'sourcePath',
|
||||
],
|
||||
'descriptor',
|
||||
);
|
||||
const sourcePath = safeAbsolutePath(
|
||||
descriptor.sourcePath,
|
||||
'descriptor source',
|
||||
);
|
||||
const destinationPath = safeAbsolutePath(
|
||||
descriptor.destinationPath,
|
||||
'descriptor destination',
|
||||
);
|
||||
const expectedSource = path.join(
|
||||
root,
|
||||
'service',
|
||||
service.kind === 'systemd' ? 'qinglong3.service' : 'qinglong3.openrc',
|
||||
);
|
||||
const expectedDestination =
|
||||
service.kind === 'systemd'
|
||||
? '/etc/systemd/system/qinglong3.service'
|
||||
: '/etc/init.d/qinglong3';
|
||||
const expectedSourceMode = service.kind === 'systemd' ? 0o600 : 0o700;
|
||||
const expectedDestinationMode = service.kind === 'systemd' ? 0o644 : 0o755;
|
||||
const lineage = normalizedLineage(intent.lineage);
|
||||
if (
|
||||
intent.schemaVersion !== 1 ||
|
||||
intent.kind !== 'qinglong3-local-service-manager-intent' ||
|
||||
typeof intent.actionId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(intent.actionId) ||
|
||||
(intent.action !== 'install-enable-start' &&
|
||||
intent.action !== 'start' &&
|
||||
intent.action !== 'restart' &&
|
||||
intent.action !== 'stop') ||
|
||||
(intent.profile !== 'edge' && intent.profile !== 'standalone') ||
|
||||
typeof intent.instanceId !== 'string' ||
|
||||
!INSTANCE_ID_PATTERN.test(intent.instanceId) ||
|
||||
applicationConfigPath !== path.join(root, 'local-application.json') ||
|
||||
typeof deployment.applicationConfigSha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(deployment.applicationConfigSha256) ||
|
||||
sourcePath !== expectedSource ||
|
||||
destinationPath !== expectedDestination ||
|
||||
descriptor.sourceMode !== expectedSourceMode ||
|
||||
descriptor.destinationMode !== expectedDestinationMode ||
|
||||
typeof descriptor.sha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(descriptor.sha256) ||
|
||||
typeof intent.outcomePath !== 'string' ||
|
||||
safeAbsolutePath(intent.outcomePath, 'outcome path') !==
|
||||
path.join(
|
||||
root,
|
||||
'service',
|
||||
'service-manager-outcomes',
|
||||
`${intent.actionId}.json`,
|
||||
) ||
|
||||
!Number.isSafeInteger(intent.requestedAtMs) ||
|
||||
(intent.requestedAtMs as number) < 0 ||
|
||||
typeof intent.intentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(intent.intentDigest)
|
||||
) {
|
||||
configurationError('service manager intent binding is invalid');
|
||||
}
|
||||
if (
|
||||
lineage.mode === 'adopted' &&
|
||||
((lineage.generation === 1 &&
|
||||
intent.action !== 'install-enable-start' &&
|
||||
intent.action !== 'start' &&
|
||||
intent.action !== 'stop') ||
|
||||
(lineage.generation >= 2 &&
|
||||
intent.action !== 'restart' &&
|
||||
intent.action !== 'stop'))
|
||||
) {
|
||||
configurationError(
|
||||
'service manager action does not match the adopted generation',
|
||||
);
|
||||
}
|
||||
const payload = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: 'qinglong3-local-service-manager-intent' as const,
|
||||
actionId: intent.actionId,
|
||||
action: intent.action,
|
||||
profile: intent.profile,
|
||||
instanceId: intent.instanceId,
|
||||
service: Object.freeze({
|
||||
kind: service.kind,
|
||||
name: 'qinglong3' as const,
|
||||
uid,
|
||||
gid,
|
||||
allowRootService: service.allowRootService,
|
||||
}),
|
||||
deployment: Object.freeze({
|
||||
root,
|
||||
applicationConfigPath,
|
||||
applicationConfigSha256: deployment.applicationConfigSha256,
|
||||
}),
|
||||
descriptor: Object.freeze({
|
||||
sourcePath,
|
||||
destinationPath,
|
||||
sha256: descriptor.sha256,
|
||||
sourceMode: expectedSourceMode,
|
||||
destinationMode: expectedDestinationMode,
|
||||
}),
|
||||
lineage,
|
||||
outcomePath: intent.outcomePath,
|
||||
requestedAtMs: intent.requestedAtMs as number,
|
||||
});
|
||||
if (localServiceManagerIntentDigest(payload) !== intent.intentDigest) {
|
||||
configurationError('service manager intent digest is invalid');
|
||||
}
|
||||
return Object.freeze({ ...payload, intentDigest: intent.intentDigest });
|
||||
}
|
||||
|
||||
function normalizedManager(value: unknown): LocalServiceBridgeManager {
|
||||
const manager = object(value, 'manager');
|
||||
if (manager.kind === 'systemd') {
|
||||
exact(manager, ['executable', 'kind'], 'manager');
|
||||
return Object.freeze({
|
||||
kind: 'systemd' as const,
|
||||
executable: safeAbsolutePath(manager.executable, 'systemd executable'),
|
||||
});
|
||||
}
|
||||
exact(manager, ['kind', 'serviceExecutable', 'updateExecutable'], 'manager');
|
||||
if (manager.kind !== 'openrc') configurationError('manager kind is invalid');
|
||||
return Object.freeze({
|
||||
kind: 'openrc' as const,
|
||||
serviceExecutable: safeAbsolutePath(
|
||||
manager.serviceExecutable,
|
||||
'OpenRC service executable',
|
||||
),
|
||||
updateExecutable: safeAbsolutePath(
|
||||
manager.updateExecutable,
|
||||
'OpenRC update executable',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceBridgeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceBridgeCommand> {
|
||||
const command = object(value, 'service bridge command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
const options = object(command.options, 'options');
|
||||
exact(
|
||||
options,
|
||||
['allowRootController', 'controllerRoot', 'manager'],
|
||||
'options',
|
||||
);
|
||||
const request = object(command.request, 'request');
|
||||
exact(request, ['expectedIntentDigest', 'intentPath'], 'request');
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !== 'local.deployment.service-manager.execute' ||
|
||||
options.allowRootController !== true ||
|
||||
typeof request.expectedIntentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedIntentDigest)
|
||||
) {
|
||||
configurationError('service bridge command binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.service-manager.execute' as const,
|
||||
options: Object.freeze({
|
||||
controllerRoot: safeAbsolutePath(
|
||||
options.controllerRoot,
|
||||
'controller root',
|
||||
),
|
||||
allowRootController: true as const,
|
||||
manager: normalizedManager(options.manager),
|
||||
}),
|
||||
request: Object.freeze({
|
||||
intentPath: safeAbsolutePath(request.intentPath, 'intent path'),
|
||||
expectedIntentDigest: request.expectedIntentDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
|
||||
const MAX_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
export interface OwnedPrivateJson {
|
||||
readonly uid: number;
|
||||
readonly gid: number;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
function fsyncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateServiceBridgeDirectory(
|
||||
directory: string,
|
||||
uid: number,
|
||||
gid: number | undefined,
|
||||
mode: number,
|
||||
label: string,
|
||||
): void {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory);
|
||||
} catch (error) {
|
||||
configurationError(`${label} is unavailable`, error);
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(gid !== undefined && stat.gid !== gid) ||
|
||||
(stat.mode & 0o777) !== mode ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
configurationError(`${label} identity is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureRootServiceBridgeDirectory(
|
||||
directory: string,
|
||||
label: string,
|
||||
): 'prepared' | 'existing' {
|
||||
let created = false;
|
||||
try {
|
||||
fs.mkdirSync(directory, { mode: 0o700 });
|
||||
created = true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
configurationError(`${label} cannot be created`, error);
|
||||
}
|
||||
}
|
||||
validateServiceBridgeDirectory(directory, 0, 0, 0o700, label);
|
||||
return created ? 'prepared' : 'existing';
|
||||
}
|
||||
|
||||
function readExactFile(
|
||||
filePath: string,
|
||||
expected: Readonly<{
|
||||
uid?: number;
|
||||
gid?: number;
|
||||
mode: number;
|
||||
maximumBytes?: number;
|
||||
}>,
|
||||
label: string,
|
||||
): Readonly<{ uid: number; gid: number; bytes: Buffer }> {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
const maximumBytes = expected.maximumBytes ?? MAX_FILE_BYTES;
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
(expected.uid !== undefined && Number(before.uid) !== expected.uid) ||
|
||||
(expected.gid !== undefined && Number(before.gid) !== expected.gid) ||
|
||||
(Number(before.mode) & 0o777) !== expected.mode ||
|
||||
before.nlink !== 1n ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(maximumBytes) ||
|
||||
fs.realpathSync(filePath) !== filePath
|
||||
) {
|
||||
configurationError(`${label} identity is invalid`);
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
opened.uid !== before.uid ||
|
||||
opened.gid !== before.gid ||
|
||||
(Number(opened.mode) & 0o777) !== expected.mode ||
|
||||
opened.nlink !== 1n
|
||||
) {
|
||||
configurationError(`${label} identity changed while opening`);
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(Number(opened.size));
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const read = fs.readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
null,
|
||||
);
|
||||
if (read === 0) break;
|
||||
offset += read;
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
offset !== bytes.byteLength ||
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.uid !== opened.uid ||
|
||||
after.gid !== opened.gid ||
|
||||
(Number(after.mode) & 0o777) !== expected.mode ||
|
||||
after.nlink !== 1n
|
||||
) {
|
||||
bytes.fill(0);
|
||||
configurationError(`${label} identity changed while reading`);
|
||||
}
|
||||
return Object.freeze({
|
||||
uid: Number(after.uid),
|
||||
gid: Number(after.gid),
|
||||
bytes,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError(`${label} cannot be read`, error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function readOwnerPrivateJsonFile(
|
||||
filePath: string,
|
||||
label: string,
|
||||
): Readonly<OwnedPrivateJson> {
|
||||
const material = readExactFile(filePath, { mode: 0o600 }, label);
|
||||
try {
|
||||
return Object.freeze({
|
||||
uid: material.uid,
|
||||
gid: material.gid,
|
||||
value: JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(material.bytes),
|
||||
) as unknown,
|
||||
});
|
||||
} catch (error) {
|
||||
return configurationError(`${label} JSON is invalid`, error);
|
||||
} finally {
|
||||
material.bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function readServiceBridgeFile(
|
||||
filePath: string,
|
||||
expected: Readonly<{
|
||||
uid: number;
|
||||
gid: number;
|
||||
mode: number;
|
||||
maximumBytes?: number;
|
||||
}>,
|
||||
label: string,
|
||||
): Buffer {
|
||||
return readExactFile(filePath, expected, label).bytes;
|
||||
}
|
||||
|
||||
function stagePathFor(targetPath: string): string {
|
||||
return path.join(
|
||||
path.dirname(targetPath),
|
||||
`.${path.basename(targetPath)}.ql3-service-bridge-stage`,
|
||||
);
|
||||
}
|
||||
|
||||
function validatePublishedFile(
|
||||
filePath: string,
|
||||
bytes: Buffer,
|
||||
mode: number,
|
||||
uid: number,
|
||||
gid: number,
|
||||
allowedLinks: readonly number[],
|
||||
label: string,
|
||||
): fs.Stats {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
stat.gid !== gid ||
|
||||
(stat.mode & 0o777) !== mode ||
|
||||
!allowedLinks.includes(stat.nlink) ||
|
||||
stat.size !== bytes.byteLength ||
|
||||
!bytes.equals(fs.readFileSync(filePath))
|
||||
) {
|
||||
configurationError(`${label} drifted`);
|
||||
}
|
||||
return stat;
|
||||
}
|
||||
|
||||
function writeOwnedStage(
|
||||
stagePath: string,
|
||||
bytes: Buffer,
|
||||
mode: number,
|
||||
uid: number,
|
||||
gid: number,
|
||||
label: string,
|
||||
): void {
|
||||
let descriptor: number | undefined;
|
||||
let created = false;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
stagePath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
created = true;
|
||||
const opened = fs.fstatSync(descriptor);
|
||||
if (!opened.isFile() || opened.uid !== 0 || opened.nlink !== 1) {
|
||||
configurationError(`${label} stage identity is invalid`);
|
||||
}
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const written = fs.writeSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
);
|
||||
if (written < 1) configurationError(`${label} stage write stalled`);
|
||||
offset += written;
|
||||
}
|
||||
fs.fchownSync(descriptor, uid, gid);
|
||||
fs.fchmodSync(descriptor, mode);
|
||||
fs.fsyncSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor);
|
||||
if (
|
||||
!after.isFile() ||
|
||||
after.uid !== uid ||
|
||||
after.gid !== gid ||
|
||||
(after.mode & 0o777) !== mode ||
|
||||
after.nlink !== 1 ||
|
||||
after.size !== bytes.byteLength
|
||||
) {
|
||||
configurationError(`${label} stage ownership is invalid`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (created) {
|
||||
try {
|
||||
fs.unlinkSync(stagePath);
|
||||
} catch {
|
||||
// A deterministic stage is intentionally left fail-closed.
|
||||
}
|
||||
}
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
configurationError(`${label} stage cannot be written`, error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function publishServiceBridgeFile(
|
||||
targetPath: string,
|
||||
contents: string,
|
||||
mode: number,
|
||||
uid: number,
|
||||
gid: number,
|
||||
label: string,
|
||||
): 'prepared' | 'existing' {
|
||||
const bytes = Buffer.from(contents, 'utf8');
|
||||
if (bytes.byteLength < 2 || bytes.byteLength > MAX_FILE_BYTES) {
|
||||
configurationError(`${label} has an invalid size`);
|
||||
}
|
||||
const directory = path.dirname(targetPath);
|
||||
const stagePath = stagePathFor(targetPath);
|
||||
const existed = fs.existsSync(targetPath);
|
||||
if (existed) {
|
||||
validatePublishedFile(targetPath, bytes, mode, uid, gid, [1, 2], label);
|
||||
}
|
||||
if (fs.existsSync(stagePath)) {
|
||||
validatePublishedFile(
|
||||
stagePath,
|
||||
bytes,
|
||||
mode,
|
||||
uid,
|
||||
gid,
|
||||
[1, 2],
|
||||
`${label} stage`,
|
||||
);
|
||||
} else if (!existed) {
|
||||
writeOwnedStage(stagePath, bytes, mode, uid, gid, label);
|
||||
}
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
try {
|
||||
fs.linkSync(stagePath, targetPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
configurationError(`${label} cannot be published`, error);
|
||||
}
|
||||
}
|
||||
fsyncDirectory(directory);
|
||||
}
|
||||
const target = validatePublishedFile(
|
||||
targetPath,
|
||||
bytes,
|
||||
mode,
|
||||
uid,
|
||||
gid,
|
||||
[1, 2],
|
||||
label,
|
||||
);
|
||||
if (fs.existsSync(stagePath)) {
|
||||
const stage = validatePublishedFile(
|
||||
stagePath,
|
||||
bytes,
|
||||
mode,
|
||||
uid,
|
||||
gid,
|
||||
[1, 2],
|
||||
`${label} stage`,
|
||||
);
|
||||
if (target.dev !== stage.dev || target.ino !== stage.ino) {
|
||||
configurationError(`${label} stage identity drifted`);
|
||||
}
|
||||
fs.unlinkSync(stagePath);
|
||||
fsyncDirectory(directory);
|
||||
}
|
||||
validatePublishedFile(targetPath, bytes, mode, uid, gid, [1], label);
|
||||
return existed ? 'existing' : 'prepared';
|
||||
}
|
||||
+862
@@ -0,0 +1,862 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
readPrivateLocalCommandFile,
|
||||
readPrivateLocalJsonFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
readLocalCutoverInstanceHead,
|
||||
} from '../cutover/instanceLineage';
|
||||
import {
|
||||
cutoverDigest,
|
||||
readTargetStartupReceipt,
|
||||
type TargetStartupReceiptEvidence,
|
||||
} from '../cutover/targetEvidence';
|
||||
import {
|
||||
localServiceManagerIntentPath,
|
||||
localServiceManagerOutcomePath,
|
||||
normalizeLocalServiceManagerIntent,
|
||||
type LocalServiceManagerIntent,
|
||||
} from './serviceBridgeContract';
|
||||
import {
|
||||
consumeLocalServiceManagerOutcome,
|
||||
type LocalServiceManagerOutcomeConsumeCommand,
|
||||
} from './serviceManagerIntent';
|
||||
import {
|
||||
normalizeLocalServiceManagerOutcome,
|
||||
type LocalServiceManagerOutcome,
|
||||
} from './serviceOutcomeContract';
|
||||
import {
|
||||
localServiceManagerCutoverRecord,
|
||||
localServiceManagerCutoverRecordPath,
|
||||
normalizeLocalServiceManagerCutoverRecord,
|
||||
publishLocalServiceManagerCutoverRecord,
|
||||
readLocalServiceManagerActiveRecord,
|
||||
type LocalServiceManagerCutoverEvidence,
|
||||
type LocalServiceManagerCutoverRecord,
|
||||
type LocalServiceManagerCutoverState,
|
||||
} from './serviceCutoverJournal';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
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 START_TICKS_PATTERN = /^[1-9][0-9]{0,19}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface LocalServiceManagerCutoverConsumeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.cutover.consume';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
allowRootService: boolean;
|
||||
startupTimeoutMs: number;
|
||||
startupPollMs: number;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
actionId: string;
|
||||
expectedIntentDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerCutoverConsumeResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.cutover.consume';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: LocalServiceManagerCutoverState;
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly recordDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerCutoverDependencies {
|
||||
readonly procRoot?: string;
|
||||
readonly now?: () => number;
|
||||
readonly wait?: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
interface AdoptedBinding {
|
||||
readonly cutoverId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly commitmentDigest: string;
|
||||
readonly commitmentPath: string;
|
||||
readonly activationPath: string;
|
||||
readonly sourcePath: string;
|
||||
readonly targetPath: string;
|
||||
readonly recoveryPath: string;
|
||||
readonly manifestPath: string;
|
||||
readonly applicationConfigDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeAbsolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
configurationError(`${label} must be a supervisor-safe absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerCutoverConsumeCommand> {
|
||||
const command = object(value, 'service manager cutover command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
const options = object(command.options, 'options');
|
||||
exact(
|
||||
options,
|
||||
['allowRootService', 'deploymentRoot', 'startupPollMs', 'startupTimeoutMs'],
|
||||
'options',
|
||||
);
|
||||
const request = object(command.request, 'request');
|
||||
exact(request, ['actionId', 'expectedIntentDigest'], 'request');
|
||||
const identity = currentIdentity();
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !== 'local.deployment.service-manager.cutover.consume' ||
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService ||
|
||||
!Number.isSafeInteger(options.startupTimeoutMs) ||
|
||||
(options.startupTimeoutMs as number) < 100 ||
|
||||
(options.startupTimeoutMs as number) > 120_000 ||
|
||||
!Number.isSafeInteger(options.startupPollMs) ||
|
||||
(options.startupPollMs as number) < 10 ||
|
||||
(options.startupPollMs as number) > 1_000 ||
|
||||
(options.startupPollMs as number) > (options.startupTimeoutMs as number) ||
|
||||
typeof request.actionId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(request.actionId) ||
|
||||
typeof request.expectedIntentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedIntentDigest)
|
||||
) {
|
||||
configurationError('service manager cutover command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.service-manager.cutover.consume' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
allowRootService: options.allowRootService,
|
||||
startupTimeoutMs: options.startupTimeoutMs as number,
|
||||
startupPollMs: options.startupPollMs as number,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
actionId: request.actionId,
|
||||
expectedIntentDigest: request.expectedIntentDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function readIntentAndOutcome(
|
||||
command: Readonly<LocalServiceManagerCutoverConsumeCommand>,
|
||||
): Readonly<{
|
||||
intent: Readonly<LocalServiceManagerIntent>;
|
||||
outcome: Readonly<LocalServiceManagerOutcome>;
|
||||
}> {
|
||||
const intent = normalizeLocalServiceManagerIntent(
|
||||
readPrivateLocalJsonFile(
|
||||
localServiceManagerIntentPath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.actionId,
|
||||
),
|
||||
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
|
||||
),
|
||||
);
|
||||
const outcome = normalizeLocalServiceManagerOutcome(
|
||||
readPrivateLocalJsonFile(
|
||||
localServiceManagerOutcomePath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.actionId,
|
||||
),
|
||||
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
|
||||
),
|
||||
);
|
||||
if (
|
||||
intent.intentDigest !== command.request.expectedIntentDigest ||
|
||||
outcome.actionId !== intent.actionId ||
|
||||
outcome.action !== intent.action ||
|
||||
outcome.intentDigest !== intent.intentDigest
|
||||
) {
|
||||
configurationError('service manager cutover outcome binding drifted');
|
||||
}
|
||||
return Object.freeze({ intent, outcome });
|
||||
}
|
||||
|
||||
function adoptedBinding(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
): Readonly<AdoptedBinding> {
|
||||
if (intent.lineage.mode !== 'adopted') {
|
||||
configurationError('fresh service outcome has no cutover lineage');
|
||||
}
|
||||
const config = object(
|
||||
readPrivateLocalCommandFile(intent.deployment.applicationConfigPath),
|
||||
'adopted application configuration',
|
||||
);
|
||||
const storage = object(config.storage, 'adopted storage');
|
||||
const cutover = object(config.cutover, 'adopted cutover');
|
||||
const expectedCommitmentPath = path.join(
|
||||
intent.deployment.root,
|
||||
'service',
|
||||
'cutovers',
|
||||
intent.lineage.cutoverId,
|
||||
'0002-legacy-stopped.json',
|
||||
);
|
||||
if (
|
||||
config.schema !== 'qinglong/local-application-process@v3' ||
|
||||
config.profile !== intent.profile ||
|
||||
config.instanceId !== intent.instanceId ||
|
||||
storage.mode !== 'adopted' ||
|
||||
storage.expectedActivationDigest !==
|
||||
intent.lineage.expectedActivationDigest ||
|
||||
cutover.cutoverId !== intent.lineage.cutoverId ||
|
||||
typeof cutover.expectedCommitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(cutover.expectedCommitmentDigest)
|
||||
) {
|
||||
configurationError('adopted application configuration drifted');
|
||||
}
|
||||
const binding = Object.freeze({
|
||||
cutoverId: cutover.cutoverId,
|
||||
activationDigest: storage.expectedActivationDigest,
|
||||
commitmentDigest: cutover.expectedCommitmentDigest,
|
||||
commitmentPath: safeAbsolutePath(cutover.commitmentPath, 'commitmentPath'),
|
||||
activationPath: safeAbsolutePath(storage.activationPath, 'activationPath'),
|
||||
sourcePath: safeAbsolutePath(storage.sourcePath, 'sourcePath'),
|
||||
targetPath: safeAbsolutePath(storage.targetPath, 'targetPath'),
|
||||
recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'),
|
||||
manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'),
|
||||
applicationConfigDigest: intent.deployment.applicationConfigSha256,
|
||||
});
|
||||
if (binding.commitmentPath !== expectedCommitmentPath) {
|
||||
configurationError('adopted application material binding drifted');
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
function textDigest(value: string): string {
|
||||
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function privateFileIdentity(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): Readonly<{ device: string; inode: string; digest: string }> {
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
(Number(stat.mode) & 0o077) !== 0 ||
|
||||
stat.nlink !== 1n ||
|
||||
fs.realpathSync(filePath) !== filePath
|
||||
) {
|
||||
configurationError(`${label} identity is invalid`);
|
||||
}
|
||||
return Object.freeze({
|
||||
device: stat.dev.toString(),
|
||||
inode: stat.ino.toString(),
|
||||
digest: cutoverDigest({
|
||||
pathDigest: textDigest(filePath),
|
||||
device: stat.dev.toString(),
|
||||
inode: stat.ino.toString(),
|
||||
uid: Number(stat.uid),
|
||||
mode: Number(stat.mode) & 0o777,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError(`${label} cannot be inspected`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function privateFileSha256(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): string {
|
||||
let descriptor: number | undefined;
|
||||
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
Number(opened.uid) !== uid ||
|
||||
(Number(opened.mode) & 0o077) !== 0 ||
|
||||
opened.nlink !== 1n
|
||||
) {
|
||||
configurationError(`${label} identity changed while opening`);
|
||||
}
|
||||
const hash = crypto.createHash('sha256');
|
||||
for (;;) {
|
||||
const count = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null);
|
||||
if (count === 0) break;
|
||||
hash.update(buffer.subarray(0, count));
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeNs !== opened.mtimeNs ||
|
||||
after.ctimeNs !== opened.ctimeNs ||
|
||||
after.nlink !== 1n
|
||||
) {
|
||||
configurationError(`${label} changed while hashing`);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError(`${label} cannot be hashed`, error);
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyAdoptedEvidence(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
binding: Readonly<AdoptedBinding>,
|
||||
uid: number,
|
||||
): string {
|
||||
const activation = object(
|
||||
readPrivateLocalCommandFile(binding.activationPath),
|
||||
'activation',
|
||||
);
|
||||
const { activationDigest, ...activationPayload } = activation;
|
||||
const commitment = object(
|
||||
readPrivateLocalCommandFile(binding.commitmentPath),
|
||||
'legacy silence commitment',
|
||||
);
|
||||
const { commitmentDigest, ...commitmentPayload } = commitment;
|
||||
const manifestDocument = object(
|
||||
readPrivateLocalCommandFile(binding.manifestPath),
|
||||
'adoption manifest',
|
||||
);
|
||||
const { manifestDigest, ...manifestPayload } = manifestDocument;
|
||||
if (
|
||||
activation.schemaVersion !== 1 ||
|
||||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
||||
activation.state !== 'prepared' ||
|
||||
activation.profile !== intent.profile ||
|
||||
activation.sourcePathDigest !== textDigest(binding.sourcePath) ||
|
||||
activation.targetPathDigest !== textDigest(binding.targetPath) ||
|
||||
typeof activation.recoverySha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.recoverySha256) ||
|
||||
typeof activation.adoptionManifestDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(activation.adoptionManifestDigest) ||
|
||||
activationDigest !== binding.activationDigest ||
|
||||
cutoverDigest(activationPayload) !== activationDigest ||
|
||||
commitment.schemaVersion !== 1 ||
|
||||
commitment.kind !== 'qinglong3-local-legacy-silence-commitment' ||
|
||||
commitment.state !== 'legacy_stopped' ||
|
||||
commitment.cutoverId !== binding.cutoverId ||
|
||||
commitment.profile !== intent.profile ||
|
||||
commitment.instanceId !== intent.instanceId ||
|
||||
commitment.activationDigest !== binding.activationDigest ||
|
||||
commitmentDigest !== binding.commitmentDigest ||
|
||||
cutoverDigest(commitmentPayload) !== commitmentDigest ||
|
||||
typeof manifestDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(manifestDigest) ||
|
||||
cutoverDigest(manifestPayload) !== manifestDigest ||
|
||||
activation.adoptionManifestDigest !== manifestDigest
|
||||
) {
|
||||
configurationError('adopted activation or commitment drifted');
|
||||
}
|
||||
const target = privateFileIdentity(
|
||||
binding.targetPath,
|
||||
uid,
|
||||
'target database',
|
||||
);
|
||||
const source = privateFileIdentity(binding.sourcePath, uid, 'legacy source');
|
||||
const recovery = privateFileIdentity(binding.recoveryPath, uid, 'recovery');
|
||||
const manifestIdentity = privateFileIdentity(
|
||||
binding.manifestPath,
|
||||
uid,
|
||||
'manifest',
|
||||
);
|
||||
const sourceSha256 = privateFileSha256(
|
||||
binding.sourcePath,
|
||||
uid,
|
||||
'legacy source',
|
||||
);
|
||||
const recoverySha256 = privateFileSha256(
|
||||
binding.recoveryPath,
|
||||
uid,
|
||||
'recovery',
|
||||
);
|
||||
if (
|
||||
activation.targetDevice !== target.device ||
|
||||
activation.targetInode !== target.inode ||
|
||||
sourceSha256 !== activation.recoverySha256 ||
|
||||
recoverySha256 !== activation.recoverySha256
|
||||
) {
|
||||
configurationError('adopted data evidence drifted');
|
||||
}
|
||||
return cutoverDigest({
|
||||
target: target.digest,
|
||||
source: source.digest,
|
||||
recovery: recovery.digest,
|
||||
recoverySha256,
|
||||
manifest: manifestIdentity.digest,
|
||||
manifestDigest,
|
||||
sourceSha256,
|
||||
});
|
||||
}
|
||||
|
||||
function parseProcessStartTicks(contents: string): string {
|
||||
const commandEnd = contents.lastIndexOf(') ');
|
||||
const fields =
|
||||
commandEnd < 2
|
||||
? []
|
||||
: contents
|
||||
.slice(commandEnd + 2)
|
||||
.trim()
|
||||
.split(/\s+/u);
|
||||
const startTicks = fields[19];
|
||||
if (!startTicks || !/^[1-9][0-9]{0,19}$/.test(startTicks)) {
|
||||
configurationError('service process stat is invalid');
|
||||
}
|
||||
return startTicks;
|
||||
}
|
||||
|
||||
function readShutdownReceipt(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
startup: Readonly<TargetStartupReceiptEvidence>,
|
||||
): string | null {
|
||||
const receiptPath = `${intent.deployment.applicationConfigPath}.stopped.json`;
|
||||
if (!fs.existsSync(receiptPath)) return null;
|
||||
const receipt = object(
|
||||
readPrivateLocalJsonFile(receiptPath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
}),
|
||||
'application shutdown receipt',
|
||||
);
|
||||
exact(
|
||||
receipt,
|
||||
[
|
||||
'bootId',
|
||||
'instanceId',
|
||||
'nodeExecutable',
|
||||
'nodeVersion',
|
||||
'processId',
|
||||
'processStartTicks',
|
||||
'profile',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'sha256',
|
||||
'signal',
|
||||
'stoppedBootAgeMs',
|
||||
'stopResult',
|
||||
'startupReceiptDigest',
|
||||
],
|
||||
'application shutdown receipt',
|
||||
);
|
||||
const { sha256, ...payload } = receipt;
|
||||
const digest = crypto
|
||||
.createHash('sha256')
|
||||
.update('qinglong.local-application-shutdown-receipt.v1\0', 'utf8')
|
||||
.update(JSON.stringify(payload), 'utf8')
|
||||
.digest('hex');
|
||||
if (
|
||||
receipt.schemaVersion !== 1 ||
|
||||
receipt.schema !== 'qinglong/local-application-shutdown-receipt@v1' ||
|
||||
receipt.instanceId !== intent.instanceId ||
|
||||
receipt.profile !== intent.profile ||
|
||||
receipt.signal !== 'SIGTERM' ||
|
||||
receipt.stopResult !== 'stopped' ||
|
||||
receipt.startupReceiptDigest !== startup.digest ||
|
||||
receipt.bootId !== startup.bootId ||
|
||||
!BOOT_ID_PATTERN.test(receipt.bootId as string) ||
|
||||
!Number.isSafeInteger(receipt.stoppedBootAgeMs) ||
|
||||
(receipt.stoppedBootAgeMs as number) < startup.activeBootAgeMs ||
|
||||
receipt.processId !== startup.processId ||
|
||||
receipt.processStartTicks !== startup.processStartTicks ||
|
||||
!START_TICKS_PATTERN.test(receipt.processStartTicks as string) ||
|
||||
receipt.nodeExecutable !== startup.nodeExecutable ||
|
||||
typeof receipt.nodeVersion !== 'string' ||
|
||||
!/^v24\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(
|
||||
receipt.nodeVersion,
|
||||
) ||
|
||||
typeof sha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(sha256) ||
|
||||
digest !== sha256
|
||||
) {
|
||||
configurationError('application shutdown receipt is invalid');
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function processIdentity(
|
||||
receipt: Readonly<TargetStartupReceiptEvidence>,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
outcome: Readonly<LocalServiceManagerOutcome>,
|
||||
procRoot: string,
|
||||
): string {
|
||||
if (
|
||||
outcome.observation.mainPid !== 0 &&
|
||||
outcome.observation.mainPid !== receipt.processId
|
||||
) {
|
||||
configurationError('manager PID does not match the startup receipt');
|
||||
}
|
||||
try {
|
||||
const processRoot = path.join(procRoot, String(receipt.processId));
|
||||
const before = fs.lstatSync(processRoot, { bigint: true });
|
||||
const firstTicks = parseProcessStartTicks(
|
||||
fs.readFileSync(path.join(processRoot, 'stat'), 'utf8'),
|
||||
);
|
||||
const executable = fs.realpathSync(path.join(processRoot, 'exe'));
|
||||
const secondTicks = parseProcessStartTicks(
|
||||
fs.readFileSync(path.join(processRoot, 'stat'), 'utf8'),
|
||||
);
|
||||
const after = fs.lstatSync(processRoot, { bigint: true });
|
||||
if (
|
||||
Number(before.uid) !== intent.service.uid ||
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
firstTicks !== receipt.processStartTicks ||
|
||||
secondTicks !== firstTicks ||
|
||||
executable !== receipt.nodeExecutable
|
||||
) {
|
||||
configurationError('startup receipt process identity drifted');
|
||||
}
|
||||
return cutoverDigest({
|
||||
bootId: receipt.bootId,
|
||||
processId: receipt.processId,
|
||||
processStartTicks: receipt.processStartTicks,
|
||||
nodeExecutable: receipt.nodeExecutable,
|
||||
uid: intent.service.uid,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('startup receipt process is unavailable', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitActiveReceipt(
|
||||
command: Readonly<LocalServiceManagerCutoverConsumeCommand>,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
outcome: Readonly<LocalServiceManagerOutcome>,
|
||||
previousReceiptDigest: string | null,
|
||||
dependencies: LocalServiceManagerCutoverDependencies,
|
||||
): Promise<Readonly<{
|
||||
receiptDigest: string;
|
||||
processIdentityDigest: string;
|
||||
}> | null> {
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const wait =
|
||||
dependencies.wait ??
|
||||
((milliseconds: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||
const procRoot = dependencies.procRoot ?? '/proc';
|
||||
const deadline = now() + command.options.startupTimeoutMs;
|
||||
for (;;) {
|
||||
try {
|
||||
const receipt = readTargetStartupReceipt({
|
||||
request: {
|
||||
applicationConfigPath: intent.deployment.applicationConfigPath,
|
||||
instanceId: intent.instanceId,
|
||||
profile: intent.profile,
|
||||
},
|
||||
});
|
||||
if (receipt !== null && receipt.digest !== previousReceiptDigest) {
|
||||
return Object.freeze({
|
||||
receiptDigest: receipt.digest,
|
||||
processIdentityDigest: processIdentity(
|
||||
receipt,
|
||||
intent,
|
||||
outcome,
|
||||
procRoot,
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// A service manager may report active just before the application
|
||||
// atomically replaces its receipt. Retry only this Owner-side read.
|
||||
}
|
||||
if (now() >= deadline) return null;
|
||||
await wait(command.options.startupPollMs);
|
||||
}
|
||||
}
|
||||
|
||||
function desiredState(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
outcome: Readonly<LocalServiceManagerOutcome>,
|
||||
): LocalServiceManagerCutoverState {
|
||||
if (outcome.state === 'manual_required') return 'manual_required';
|
||||
if (intent.action === 'stop' && outcome.state === 'stopped') {
|
||||
return 'target_stopped';
|
||||
}
|
||||
if (intent.action !== 'stop' && outcome.state === 'active') {
|
||||
return 'target_active';
|
||||
}
|
||||
return configurationError('manager outcome state does not match its action');
|
||||
}
|
||||
|
||||
function replayResult(
|
||||
command: Readonly<LocalServiceManagerCutoverConsumeCommand>,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
outcome: Readonly<LocalServiceManagerOutcome>,
|
||||
): Readonly<LocalServiceManagerCutoverConsumeResult> | undefined {
|
||||
if (intent.lineage.mode !== 'adopted') return undefined;
|
||||
const state = desiredState(intent, outcome);
|
||||
const recordPath = localServiceManagerCutoverRecordPath(intent, state);
|
||||
if (!fs.existsSync(recordPath)) return undefined;
|
||||
const record = normalizeLocalServiceManagerCutoverRecord(
|
||||
readPrivateLocalCommandFile(recordPath),
|
||||
);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
intent.deployment.root,
|
||||
intent.instanceId,
|
||||
currentIdentity().uid,
|
||||
);
|
||||
if (
|
||||
record.actionId !== intent.actionId ||
|
||||
record.intentDigest !== intent.intentDigest ||
|
||||
record.evidence.managerOutcomeDigest !== outcome.outcomeDigest ||
|
||||
head.cutoverId !== intent.lineage.cutoverId ||
|
||||
head.generation !== intent.lineage.generation ||
|
||||
head.state !== record.state ||
|
||||
head.sourceRecordDigest !== record.recordDigest
|
||||
) {
|
||||
configurationError('service manager cutover replay drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: 'existing' as const,
|
||||
state: record.state,
|
||||
cutoverId: record.cutoverId,
|
||||
generation: record.generation,
|
||||
recordDigest: record.recordDigest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export async function consumeLocalServiceManagerCutoverOutcome(
|
||||
input: unknown,
|
||||
dependencies: LocalServiceManagerCutoverDependencies = {},
|
||||
): Promise<Readonly<LocalServiceManagerCutoverConsumeResult>> {
|
||||
const command = normalizeCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const { intent, outcome } = readIntentAndOutcome(command);
|
||||
const replay = replayResult(command, intent, outcome);
|
||||
if (replay !== undefined) return replay;
|
||||
consumeLocalServiceManagerOutcome({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.outcome.consume',
|
||||
options: {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
allowRootService: command.options.allowRootService,
|
||||
},
|
||||
request: command.request,
|
||||
} satisfies LocalServiceManagerOutcomeConsumeCommand);
|
||||
if (intent.lineage.mode !== 'adopted') {
|
||||
configurationError('fresh service outcome cannot advance cutover lineage');
|
||||
}
|
||||
const state = desiredState(intent, outcome);
|
||||
const binding = adoptedBinding(intent);
|
||||
const targetDataIdentityDigest = verifyAdoptedEvidence(
|
||||
intent,
|
||||
binding,
|
||||
identity.uid,
|
||||
);
|
||||
let startupReceiptDigest: string | null = null;
|
||||
let shutdownReceiptDigest: string | null = null;
|
||||
let processIdentityDigest: string | null = null;
|
||||
let manualReason: string | null = outcome.manualReason;
|
||||
let finalState = state;
|
||||
if (state === 'target_active') {
|
||||
let previousReceiptDigest: string | null = null;
|
||||
if (intent.lineage.generation > 1) {
|
||||
const previous = readLocalServiceManagerActiveRecord(
|
||||
intent.deployment.root,
|
||||
intent.lineage.cutoverId,
|
||||
intent.lineage.generation - 1,
|
||||
);
|
||||
if (previous.recordDigest !== intent.lineage.previousRecordDigest) {
|
||||
configurationError('previous active record lost the lineage CAS');
|
||||
}
|
||||
previousReceiptDigest = previous.evidence.startupReceiptDigest;
|
||||
}
|
||||
const active = await awaitActiveReceipt(
|
||||
command,
|
||||
intent,
|
||||
outcome,
|
||||
previousReceiptDigest,
|
||||
dependencies,
|
||||
);
|
||||
if (active === null) {
|
||||
finalState = 'manual_required';
|
||||
manualReason = 'application_startup_receipt_unproved';
|
||||
} else {
|
||||
startupReceiptDigest = active.receiptDigest;
|
||||
processIdentityDigest = active.processIdentityDigest;
|
||||
}
|
||||
} else if (state === 'target_stopped') {
|
||||
const previous = readLocalServiceManagerActiveRecord(
|
||||
intent.deployment.root,
|
||||
intent.lineage.cutoverId,
|
||||
intent.lineage.generation,
|
||||
);
|
||||
if (previous.recordDigest !== intent.lineage.previousRecordDigest) {
|
||||
configurationError('stopped service lost the active lineage CAS');
|
||||
}
|
||||
startupReceiptDigest = previous.evidence.startupReceiptDigest;
|
||||
processIdentityDigest = previous.evidence.processIdentityDigest;
|
||||
const staleReceipt = readTargetStartupReceipt({
|
||||
request: {
|
||||
applicationConfigPath: intent.deployment.applicationConfigPath,
|
||||
instanceId: intent.instanceId,
|
||||
profile: intent.profile,
|
||||
},
|
||||
});
|
||||
if (staleReceipt === null || staleReceipt.digest !== startupReceiptDigest) {
|
||||
finalState = 'manual_required';
|
||||
manualReason = 'stopped_process_identity_unproved';
|
||||
} else {
|
||||
shutdownReceiptDigest = readShutdownReceipt(intent, staleReceipt);
|
||||
if (shutdownReceiptDigest === null) {
|
||||
finalState = 'manual_required';
|
||||
manualReason = 'application_shutdown_receipt_unproved';
|
||||
} else {
|
||||
try {
|
||||
processIdentity(
|
||||
staleReceipt,
|
||||
intent,
|
||||
outcome,
|
||||
dependencies.procRoot ?? '/proc',
|
||||
);
|
||||
finalState = 'manual_required';
|
||||
manualReason = 'stopped_process_still_active';
|
||||
} catch {
|
||||
// The exact receipted PID/start identity no longer exists, as required.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const evidence: Readonly<LocalServiceManagerCutoverEvidence> = Object.freeze({
|
||||
managerOutcomeDigest: outcome.outcomeDigest,
|
||||
managerObservationDigest: outcome.observation.observationDigest,
|
||||
applicationConfigDigest: binding.applicationConfigDigest,
|
||||
activationDigest: binding.activationDigest,
|
||||
commitmentDigest: binding.commitmentDigest,
|
||||
targetDataIdentityDigest,
|
||||
startupReceiptDigest,
|
||||
shutdownReceiptDigest,
|
||||
processIdentityDigest,
|
||||
manualReason,
|
||||
});
|
||||
const record = localServiceManagerCutoverRecord(
|
||||
intent,
|
||||
outcome,
|
||||
finalState,
|
||||
evidence,
|
||||
);
|
||||
const status = publishLocalServiceManagerCutoverRecord(
|
||||
intent,
|
||||
record,
|
||||
identity.uid,
|
||||
);
|
||||
const head = advanceLocalCutoverInstanceHead(
|
||||
{
|
||||
options: { deploymentRoot: intent.deployment.root },
|
||||
request: {
|
||||
cutoverId: intent.lineage.cutoverId,
|
||||
profile: intent.profile,
|
||||
instanceId: intent.instanceId,
|
||||
expectedActivationDigest: intent.lineage.expectedActivationDigest,
|
||||
requestedAtMs: outcome.completedAtMs,
|
||||
},
|
||||
},
|
||||
identity.uid,
|
||||
finalState,
|
||||
intent.lineage.generation,
|
||||
record.recordDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status,
|
||||
state: finalState,
|
||||
cutoverId: intent.lineage.cutoverId,
|
||||
generation: intent.lineage.generation,
|
||||
recordDigest: record.recordDigest,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function consumeLocalServiceManagerCutoverOutcomeCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalServiceManagerCutoverConsumeResult>> {
|
||||
return consumeLocalServiceManagerCutoverOutcome(
|
||||
readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import { preflightPublishedFile, publishExactFile } from '../foundation/files';
|
||||
import { cutoverDigest } from '../cutover/targetEvidence';
|
||||
import type {
|
||||
LocalServiceManagerAction,
|
||||
LocalServiceManagerIntent,
|
||||
} from './serviceBridgeContract';
|
||||
import type { LocalServiceManagerOutcome } from './serviceOutcomeContract';
|
||||
|
||||
const SCHEMA = 'qinglong3-local-service-manager-cutover-record';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export type LocalServiceManagerCutoverState =
|
||||
| 'target_active'
|
||||
| 'target_stopped'
|
||||
| 'manual_required';
|
||||
|
||||
export interface LocalServiceManagerCutoverEvidence {
|
||||
readonly managerOutcomeDigest: string;
|
||||
readonly managerObservationDigest: string;
|
||||
readonly applicationConfigDigest: string;
|
||||
readonly activationDigest: string;
|
||||
readonly commitmentDigest: string;
|
||||
readonly targetDataIdentityDigest: string;
|
||||
readonly startupReceiptDigest: string | null;
|
||||
readonly shutdownReceiptDigest: string | null;
|
||||
readonly processIdentityDigest: string | null;
|
||||
readonly manualReason: string | null;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerCutoverRecord {
|
||||
readonly schema: typeof SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly actionId: string;
|
||||
readonly action: LocalServiceManagerAction;
|
||||
readonly state: LocalServiceManagerCutoverState;
|
||||
readonly cutoverId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly generation: number;
|
||||
readonly previousRecordDigest: string;
|
||||
readonly intentDigest: string;
|
||||
readonly requestedAtMs: number;
|
||||
readonly completedAtMs: number;
|
||||
readonly evidence: Readonly<LocalServiceManagerCutoverEvidence>;
|
||||
readonly recordDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function generationName(generation: number): string {
|
||||
return String(generation).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function localServiceManagerActiveRecordPath(
|
||||
deploymentRoot: string,
|
||||
cutoverId: string,
|
||||
generation: number,
|
||||
): string {
|
||||
return path.join(
|
||||
deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
cutoverId,
|
||||
`service-manager-g${generationName(generation)}-active.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function localServiceManagerCutoverRecordPath(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
state: LocalServiceManagerCutoverState,
|
||||
): string {
|
||||
if (intent.lineage.mode !== 'adopted') {
|
||||
configurationError('fresh service intent has no cutover journal');
|
||||
}
|
||||
const suffix =
|
||||
state === 'target_active'
|
||||
? 'active'
|
||||
: state === 'target_stopped'
|
||||
? 'stopped'
|
||||
: `manual-${intent.actionId}`;
|
||||
return path.join(
|
||||
intent.deployment.root,
|
||||
'service',
|
||||
'cutovers',
|
||||
intent.lineage.cutoverId,
|
||||
`service-manager-g${generationName(
|
||||
intent.lineage.generation,
|
||||
)}-${suffix}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function localServiceManagerCutoverRecord(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
outcome: Readonly<LocalServiceManagerOutcome>,
|
||||
state: LocalServiceManagerCutoverState,
|
||||
evidence: Readonly<LocalServiceManagerCutoverEvidence>,
|
||||
): Readonly<LocalServiceManagerCutoverRecord> {
|
||||
if (intent.lineage.mode !== 'adopted') {
|
||||
configurationError('fresh service intent has no cutover lineage');
|
||||
}
|
||||
const payload = Object.freeze({
|
||||
schema: SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
actionId: intent.actionId,
|
||||
action: intent.action,
|
||||
state,
|
||||
cutoverId: intent.lineage.cutoverId,
|
||||
profile: intent.profile,
|
||||
instanceId: intent.instanceId,
|
||||
activationDigest: intent.lineage.expectedActivationDigest,
|
||||
generation: intent.lineage.generation,
|
||||
previousRecordDigest: intent.lineage.previousRecordDigest,
|
||||
intentDigest: intent.intentDigest,
|
||||
requestedAtMs: intent.requestedAtMs,
|
||||
completedAtMs: outcome.completedAtMs,
|
||||
evidence,
|
||||
});
|
||||
return Object.freeze({ ...payload, recordDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceManagerCutoverRecord(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerCutoverRecord> {
|
||||
const record = object(value, 'service manager cutover record');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'action',
|
||||
'actionId',
|
||||
'activationDigest',
|
||||
'completedAtMs',
|
||||
'cutoverId',
|
||||
'evidence',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'intentDigest',
|
||||
'previousRecordDigest',
|
||||
'profile',
|
||||
'recordDigest',
|
||||
'requestedAtMs',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'service manager cutover record',
|
||||
);
|
||||
const evidence = object(record.evidence, 'service manager cutover evidence');
|
||||
exact(
|
||||
evidence,
|
||||
[
|
||||
'activationDigest',
|
||||
'applicationConfigDigest',
|
||||
'commitmentDigest',
|
||||
'managerObservationDigest',
|
||||
'managerOutcomeDigest',
|
||||
'manualReason',
|
||||
'processIdentityDigest',
|
||||
'shutdownReceiptDigest',
|
||||
'startupReceiptDigest',
|
||||
'targetDataIdentityDigest',
|
||||
],
|
||||
'service manager cutover evidence',
|
||||
);
|
||||
const nullableDigest = (candidate: unknown): boolean =>
|
||||
candidate === null ||
|
||||
(typeof candidate === 'string' && DIGEST_PATTERN.test(candidate));
|
||||
const { recordDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
typeof record.actionId !== 'string' ||
|
||||
(record.action !== 'install-enable-start' &&
|
||||
record.action !== 'start' &&
|
||||
record.action !== 'restart' &&
|
||||
record.action !== 'stop') ||
|
||||
(record.state !== 'target_active' &&
|
||||
record.state !== 'target_stopped' &&
|
||||
record.state !== 'manual_required') ||
|
||||
typeof record.cutoverId !== 'string' ||
|
||||
(record.profile !== 'edge' && record.profile !== 'standalone') ||
|
||||
typeof record.instanceId !== 'string' ||
|
||||
typeof record.activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.activationDigest) ||
|
||||
!Number.isSafeInteger(record.generation) ||
|
||||
(record.generation as number) < 1 ||
|
||||
typeof record.previousRecordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.previousRecordDigest) ||
|
||||
typeof record.intentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.intentDigest) ||
|
||||
!Number.isSafeInteger(record.requestedAtMs) ||
|
||||
(record.requestedAtMs as number) < 0 ||
|
||||
!Number.isSafeInteger(record.completedAtMs) ||
|
||||
(record.completedAtMs as number) < (record.requestedAtMs as number) ||
|
||||
typeof evidence.managerOutcomeDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.managerOutcomeDigest) ||
|
||||
typeof evidence.managerObservationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.managerObservationDigest) ||
|
||||
typeof evidence.applicationConfigDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.applicationConfigDigest) ||
|
||||
typeof evidence.activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.activationDigest) ||
|
||||
typeof evidence.commitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.commitmentDigest) ||
|
||||
typeof evidence.targetDataIdentityDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(evidence.targetDataIdentityDigest) ||
|
||||
!nullableDigest(evidence.startupReceiptDigest) ||
|
||||
!nullableDigest(evidence.shutdownReceiptDigest) ||
|
||||
!nullableDigest(evidence.processIdentityDigest) ||
|
||||
(evidence.manualReason !== null &&
|
||||
(typeof evidence.manualReason !== 'string' ||
|
||||
Buffer.byteLength(evidence.manualReason, 'utf8') > 128)) ||
|
||||
typeof recordDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(recordDigest) ||
|
||||
cutoverDigest(payload) !== recordDigest
|
||||
) {
|
||||
configurationError('service manager cutover record drifted');
|
||||
}
|
||||
return record as unknown as Readonly<LocalServiceManagerCutoverRecord>;
|
||||
}
|
||||
|
||||
export function publishLocalServiceManagerCutoverRecord(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
record: Readonly<LocalServiceManagerCutoverRecord>,
|
||||
uid: number,
|
||||
): 'prepared' | 'existing' {
|
||||
const filePath = localServiceManagerCutoverRecordPath(intent, record.state);
|
||||
const contents = `${JSON.stringify(record, null, 2)}\n`;
|
||||
preflightPublishedFile(
|
||||
filePath,
|
||||
contents,
|
||||
0o600,
|
||||
uid,
|
||||
'service manager cutover record',
|
||||
);
|
||||
return publishExactFile(
|
||||
filePath,
|
||||
contents,
|
||||
0o600,
|
||||
uid,
|
||||
'service manager cutover record',
|
||||
);
|
||||
}
|
||||
|
||||
export function readLocalServiceManagerActiveRecord(
|
||||
deploymentRoot: string,
|
||||
cutoverId: string,
|
||||
generation: number,
|
||||
): Readonly<LocalServiceManagerCutoverRecord> {
|
||||
const filePath = localServiceManagerActiveRecordPath(
|
||||
deploymentRoot,
|
||||
cutoverId,
|
||||
generation,
|
||||
);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
configurationError('previous service manager active record is unavailable');
|
||||
}
|
||||
const record = normalizeLocalServiceManagerCutoverRecord(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
);
|
||||
if (
|
||||
record.state !== 'target_active' ||
|
||||
record.cutoverId !== cutoverId ||
|
||||
record.generation !== generation
|
||||
) {
|
||||
configurationError('previous service manager active record drifted');
|
||||
}
|
||||
return record;
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
readPrivateLocalJsonFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
} from '../foundation/contract';
|
||||
import {
|
||||
assertLocalCutoverTargetHead,
|
||||
localCutoverInstanceHeadPath,
|
||||
} from '../cutover/instanceLineage';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../foundation/files';
|
||||
import {
|
||||
localServiceManagerIntentDigest,
|
||||
localServiceManagerIntentPath,
|
||||
localServiceManagerOutcomePath,
|
||||
normalizeLocalServiceManagerIntent,
|
||||
type LocalServiceManagerAction,
|
||||
type LocalServiceManagerIntent,
|
||||
type LocalServiceManagerIntentLineage,
|
||||
type LocalServiceManagerKind,
|
||||
} from './serviceBridgeContract';
|
||||
import {
|
||||
normalizeLocalServiceManagerOutcome,
|
||||
type LocalServiceManagerOutcome,
|
||||
} from './serviceOutcomeContract';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const MAX_DESCRIPTOR_BYTES = 64 * 1024;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface LocalServiceManagerIntentPrepareCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.intent.prepare';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
actionId: string;
|
||||
action: LocalServiceManagerAction;
|
||||
serviceKind: LocalServiceManagerKind;
|
||||
lineage: LocalServiceManagerIntentLineage;
|
||||
requestedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerIntentPrepareResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.intent.prepare';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly actionId: string;
|
||||
readonly intentPath: string;
|
||||
readonly intentDigest: string;
|
||||
readonly outcomePath: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerOutcomeConsumeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.outcome.consume';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
actionId: string;
|
||||
expectedIntentDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerOutcomeConsumeResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.outcome.consume';
|
||||
readonly status: 'verified';
|
||||
readonly actionId: string;
|
||||
readonly state: LocalServiceManagerOutcome['state'];
|
||||
readonly outcomeDigest: string;
|
||||
readonly observationDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeAbsolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
configurationError(`${label} must be a supervisor-safe absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateIdentity(allowRootService: unknown): Readonly<{
|
||||
uid: number;
|
||||
gid: number;
|
||||
}> {
|
||||
const identity = currentIdentity();
|
||||
if (
|
||||
typeof allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== allowRootService
|
||||
) {
|
||||
configurationError('allowRootService does not match the current identity');
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
function privateFileBytes(
|
||||
filePath: string,
|
||||
expectedMode: number,
|
||||
uid: number,
|
||||
gid: number,
|
||||
maximumBytes: number,
|
||||
label: string,
|
||||
): Buffer {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
Number(before.gid) !== gid ||
|
||||
(Number(before.mode) & 0o777) !== expectedMode ||
|
||||
before.nlink !== 1n ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(maximumBytes) ||
|
||||
fs.realpathSync(filePath) !== filePath
|
||||
) {
|
||||
configurationError(`${label} identity is invalid`);
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
Number(opened.uid) !== uid ||
|
||||
Number(opened.gid) !== gid ||
|
||||
(Number(opened.mode) & 0o777) !== expectedMode ||
|
||||
opened.nlink !== 1n
|
||||
) {
|
||||
configurationError(`${label} identity changed while opening`);
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(Number(opened.size));
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const read = fs.readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
null,
|
||||
);
|
||||
if (read === 0) break;
|
||||
offset += read;
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
offset !== bytes.byteLength ||
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
Number(after.uid) !== uid ||
|
||||
Number(after.gid) !== gid ||
|
||||
(Number(after.mode) & 0o777) !== expectedMode ||
|
||||
after.nlink !== 1n
|
||||
) {
|
||||
bytes.fill(0);
|
||||
configurationError(`${label} identity changed while reading`);
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError(`${label} cannot be read`, error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function parseApplicationIdentity(bytes: Buffer): Readonly<{
|
||||
profile: 'edge' | 'standalone';
|
||||
instanceId: string;
|
||||
deployment:
|
||||
| Readonly<{ mode: 'fresh' }>
|
||||
| Readonly<{
|
||||
mode: 'adopted';
|
||||
cutoverId: string;
|
||||
expectedActivationDigest: string;
|
||||
expectedCommitmentDigest: string;
|
||||
commitmentPath: string;
|
||||
activationPath: string;
|
||||
legacySourcePath: string;
|
||||
targetDatabasePath: string;
|
||||
recoveryPath: string;
|
||||
manifestPath: string;
|
||||
}>;
|
||||
}> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
|
||||
) as unknown;
|
||||
} catch (error) {
|
||||
configurationError('application configuration is invalid', error);
|
||||
}
|
||||
const application = object(value, 'application configuration');
|
||||
if (
|
||||
(application.schema !== 'qinglong/local-application-process@v2' &&
|
||||
application.schema !== 'qinglong/local-application-process@v3') ||
|
||||
(application.profile !== 'edge' && application.profile !== 'standalone') ||
|
||||
typeof application.instanceId !== 'string'
|
||||
) {
|
||||
configurationError('application identity is invalid');
|
||||
}
|
||||
const storage = object(application.storage, 'application storage');
|
||||
if (application.schema === 'qinglong/local-application-process@v2') {
|
||||
if (storage.mode !== 'fresh') {
|
||||
configurationError('v2 service application must use fresh storage');
|
||||
}
|
||||
return Object.freeze({
|
||||
profile: application.profile,
|
||||
instanceId: application.instanceId,
|
||||
deployment: Object.freeze({ mode: 'fresh' as const }),
|
||||
});
|
||||
}
|
||||
const cutover = object(application.cutover, 'application cutover');
|
||||
if (
|
||||
storage.mode !== 'adopted' ||
|
||||
typeof cutover.cutoverId !== 'string' ||
|
||||
typeof storage.expectedActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(storage.expectedActivationDigest) ||
|
||||
typeof cutover.expectedCommitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(cutover.expectedCommitmentDigest)
|
||||
) {
|
||||
configurationError('adopted application binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
profile: application.profile,
|
||||
instanceId: application.instanceId,
|
||||
deployment: Object.freeze({
|
||||
mode: 'adopted' as const,
|
||||
cutoverId: cutover.cutoverId,
|
||||
expectedActivationDigest: storage.expectedActivationDigest,
|
||||
expectedCommitmentDigest: cutover.expectedCommitmentDigest,
|
||||
commitmentPath: safeAbsolutePath(
|
||||
cutover.commitmentPath,
|
||||
'commitmentPath',
|
||||
),
|
||||
activationPath: safeAbsolutePath(
|
||||
storage.activationPath,
|
||||
'activationPath',
|
||||
),
|
||||
legacySourcePath: safeAbsolutePath(
|
||||
storage.sourcePath,
|
||||
'legacySourcePath',
|
||||
),
|
||||
targetDatabasePath: safeAbsolutePath(
|
||||
storage.targetPath,
|
||||
'targetDatabasePath',
|
||||
),
|
||||
recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'),
|
||||
manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function assertApplicationLineageBinding(
|
||||
application: ReturnType<typeof parseApplicationIdentity>,
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
): void {
|
||||
if (intent.lineage.mode === 'fresh') {
|
||||
if (application.deployment.mode !== 'fresh') {
|
||||
configurationError('fresh service intent cannot start adopted storage');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
application.deployment.mode !== 'adopted' ||
|
||||
application.deployment.cutoverId !== intent.lineage.cutoverId ||
|
||||
application.deployment.expectedActivationDigest !==
|
||||
intent.lineage.expectedActivationDigest ||
|
||||
((intent.action === 'install-enable-start' || intent.action === 'start') &&
|
||||
intent.lineage.generation === 1 &&
|
||||
application.deployment.expectedCommitmentDigest !==
|
||||
intent.lineage.previousRecordDigest)
|
||||
) {
|
||||
configurationError(
|
||||
'service intent does not match adopted application binding',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function intentDirectory(deploymentRoot: string): string {
|
||||
return path.join(deploymentRoot, 'service', 'service-manager-intents');
|
||||
}
|
||||
|
||||
function outcomeDirectory(deploymentRoot: string): string {
|
||||
return path.join(deploymentRoot, 'service', 'service-manager-outcomes');
|
||||
}
|
||||
|
||||
function assertIntentLineageHead(
|
||||
intent: Readonly<LocalServiceManagerIntent>,
|
||||
uid: number,
|
||||
): void {
|
||||
const headPath = localCutoverInstanceHeadPath(
|
||||
intent.deployment.root,
|
||||
intent.instanceId,
|
||||
);
|
||||
if (intent.lineage.mode === 'fresh') {
|
||||
if (fs.existsSync(headPath)) {
|
||||
configurationError(
|
||||
'fresh service intent cannot bypass an instance lineage head',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const head = assertLocalCutoverTargetHead(
|
||||
Object.freeze({
|
||||
options: Object.freeze({ deploymentRoot: intent.deployment.root }),
|
||||
request: Object.freeze({
|
||||
cutoverId: intent.lineage.cutoverId,
|
||||
profile: intent.profile,
|
||||
instanceId: intent.instanceId,
|
||||
expectedActivationDigest: intent.lineage.expectedActivationDigest,
|
||||
requestedAtMs: intent.requestedAtMs,
|
||||
}),
|
||||
}),
|
||||
uid,
|
||||
);
|
||||
const expected =
|
||||
intent.action === 'restart'
|
||||
? Object.freeze({
|
||||
state: 'target_active' as const,
|
||||
generation: intent.lineage.generation - 1,
|
||||
})
|
||||
: intent.action === 'stop'
|
||||
? Object.freeze({
|
||||
state: 'target_active' as const,
|
||||
generation: intent.lineage.generation,
|
||||
})
|
||||
: Object.freeze({ state: 'legacy_stopped' as const, generation: 0 });
|
||||
if (
|
||||
head.state !== expected.state ||
|
||||
head.generation !== expected.generation ||
|
||||
head.sourceRecordDigest !== intent.lineage.previousRecordDigest
|
||||
) {
|
||||
configurationError(
|
||||
'service intent lost the instance lineage compare-and-swap',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePrepareCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerIntentPrepareCommand> {
|
||||
const command = object(value, 'service manager intent command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
const options = object(command.options, 'options');
|
||||
exact(options, ['allowRootService', 'deploymentRoot'], 'options');
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
['action', 'actionId', 'lineage', 'requestedAtMs', 'serviceKind'],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !== 'local.deployment.service-manager.intent.prepare' ||
|
||||
typeof request.actionId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(request.actionId) ||
|
||||
(request.action !== 'install-enable-start' &&
|
||||
request.action !== 'start' &&
|
||||
request.action !== 'restart' &&
|
||||
request.action !== 'stop') ||
|
||||
(request.serviceKind !== 'systemd' && request.serviceKind !== 'openrc') ||
|
||||
!Number.isSafeInteger(request.requestedAtMs) ||
|
||||
(request.requestedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('service manager intent command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.service-manager.intent.prepare' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
allowRootService: options.allowRootService as boolean,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
actionId: request.actionId,
|
||||
action: request.action,
|
||||
serviceKind: request.serviceKind,
|
||||
lineage: request.lineage as LocalServiceManagerIntentLineage,
|
||||
requestedAtMs: request.requestedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareLocalServiceManagerIntent(
|
||||
input: unknown,
|
||||
): Readonly<LocalServiceManagerIntentPrepareResult> {
|
||||
const command = normalizePrepareCommand(input);
|
||||
const identity = validateIdentity(command.options.allowRootService);
|
||||
const root = command.options.deploymentRoot;
|
||||
const serviceRoot = path.join(root, 'service');
|
||||
validatePrivateDirectory(root, identity.uid, 'deploymentRoot');
|
||||
validatePrivateDirectory(serviceRoot, identity.uid, 'serviceDescriptorRoot');
|
||||
ensurePrivateDirectory(
|
||||
intentDirectory(root),
|
||||
identity.uid,
|
||||
'serviceManagerIntentRoot',
|
||||
);
|
||||
ensurePrivateDirectory(
|
||||
outcomeDirectory(root),
|
||||
identity.uid,
|
||||
'serviceManagerOutcomeRoot',
|
||||
);
|
||||
const applicationConfigPath = path.join(root, 'local-application.json');
|
||||
const applicationBytes = privateFileBytes(
|
||||
applicationConfigPath,
|
||||
0o600,
|
||||
identity.uid,
|
||||
identity.gid,
|
||||
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
'application configuration',
|
||||
);
|
||||
const descriptorPath = path.join(
|
||||
serviceRoot,
|
||||
command.request.serviceKind === 'systemd'
|
||||
? 'qinglong3.service'
|
||||
: 'qinglong3.openrc',
|
||||
);
|
||||
const sourceMode = command.request.serviceKind === 'systemd' ? 0o600 : 0o700;
|
||||
const descriptorBytes = privateFileBytes(
|
||||
descriptorPath,
|
||||
sourceMode,
|
||||
identity.uid,
|
||||
identity.gid,
|
||||
MAX_DESCRIPTOR_BYTES,
|
||||
'service descriptor',
|
||||
);
|
||||
try {
|
||||
const application = parseApplicationIdentity(applicationBytes);
|
||||
const payload: Omit<LocalServiceManagerIntent, 'intentDigest'> =
|
||||
Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: 'qinglong3-local-service-manager-intent' as const,
|
||||
actionId: command.request.actionId,
|
||||
action: command.request.action,
|
||||
profile: application.profile,
|
||||
instanceId: application.instanceId,
|
||||
service: Object.freeze({
|
||||
kind: command.request.serviceKind,
|
||||
name: 'qinglong3' as const,
|
||||
uid: identity.uid,
|
||||
gid: identity.gid,
|
||||
allowRootService: command.options.allowRootService,
|
||||
}),
|
||||
deployment: Object.freeze({
|
||||
root,
|
||||
applicationConfigPath,
|
||||
applicationConfigSha256: sha256(applicationBytes),
|
||||
}),
|
||||
descriptor: Object.freeze({
|
||||
sourcePath: descriptorPath,
|
||||
destinationPath:
|
||||
command.request.serviceKind === 'systemd'
|
||||
? '/etc/systemd/system/qinglong3.service'
|
||||
: '/etc/init.d/qinglong3',
|
||||
sha256: sha256(descriptorBytes),
|
||||
sourceMode,
|
||||
destinationMode:
|
||||
command.request.serviceKind === 'systemd' ? 0o644 : 0o755,
|
||||
}),
|
||||
lineage: command.request.lineage,
|
||||
outcomePath: localServiceManagerOutcomePath(
|
||||
root,
|
||||
command.request.actionId,
|
||||
),
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
const intent = normalizeLocalServiceManagerIntent({
|
||||
...payload,
|
||||
intentDigest: localServiceManagerIntentDigest(payload),
|
||||
});
|
||||
assertApplicationLineageBinding(application, intent);
|
||||
assertIntentLineageHead(intent, identity.uid);
|
||||
const intentPath = localServiceManagerIntentPath(root, intent.actionId);
|
||||
const contents = `${JSON.stringify(intent, null, 2)}\n`;
|
||||
preflightPublishedFile(
|
||||
intentPath,
|
||||
contents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'service manager intent',
|
||||
);
|
||||
const status = publishExactFile(
|
||||
intentPath,
|
||||
contents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'service manager intent',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.service-manager.intent.prepare' as const,
|
||||
status,
|
||||
actionId: intent.actionId,
|
||||
intentPath,
|
||||
intentDigest: intent.intentDigest,
|
||||
outcomePath: intent.outcomePath,
|
||||
});
|
||||
} finally {
|
||||
applicationBytes.fill(0);
|
||||
descriptorBytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConsumeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerOutcomeConsumeCommand> {
|
||||
const command = object(value, 'service manager outcome command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
const options = object(command.options, 'options');
|
||||
exact(options, ['allowRootService', 'deploymentRoot'], 'options');
|
||||
const request = object(command.request, 'request');
|
||||
exact(request, ['actionId', 'expectedIntentDigest'], 'request');
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !== 'local.deployment.service-manager.outcome.consume' ||
|
||||
typeof request.actionId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(request.actionId) ||
|
||||
typeof request.expectedIntentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedIntentDigest)
|
||||
) {
|
||||
configurationError('service manager outcome command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.service-manager.outcome.consume' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
allowRootService: options.allowRootService as boolean,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
actionId: request.actionId,
|
||||
expectedIntentDigest: request.expectedIntentDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function privateJsonGid(filePath: string, gid: number, label: string): unknown {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
configurationError(`${label} is unavailable`, error);
|
||||
}
|
||||
if (stat.gid !== gid) configurationError(`${label} group identity drifted`);
|
||||
return readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
export function consumeLocalServiceManagerOutcome(
|
||||
input: unknown,
|
||||
): Readonly<LocalServiceManagerOutcomeConsumeResult> {
|
||||
const command = normalizeConsumeCommand(input);
|
||||
const identity = validateIdentity(command.options.allowRootService);
|
||||
validatePrivateDirectory(
|
||||
command.options.deploymentRoot,
|
||||
identity.uid,
|
||||
'deploymentRoot',
|
||||
);
|
||||
const intent = normalizeLocalServiceManagerIntent(
|
||||
privateJsonGid(
|
||||
localServiceManagerIntentPath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.actionId,
|
||||
),
|
||||
identity.gid,
|
||||
'service manager intent',
|
||||
),
|
||||
);
|
||||
const outcome = normalizeLocalServiceManagerOutcome(
|
||||
privateJsonGid(
|
||||
localServiceManagerOutcomePath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.actionId,
|
||||
),
|
||||
identity.gid,
|
||||
'service manager outcome',
|
||||
),
|
||||
);
|
||||
if (
|
||||
intent.actionId !== command.request.actionId ||
|
||||
intent.intentDigest !== command.request.expectedIntentDigest ||
|
||||
intent.service.uid !== identity.uid ||
|
||||
intent.service.gid !== identity.gid ||
|
||||
intent.service.allowRootService !== command.options.allowRootService ||
|
||||
intent.deployment.root !== command.options.deploymentRoot ||
|
||||
intent.outcomePath !==
|
||||
localServiceManagerOutcomePath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.actionId,
|
||||
) ||
|
||||
outcome.actionId !== intent.actionId ||
|
||||
outcome.action !== intent.action ||
|
||||
outcome.intentDigest !== intent.intentDigest ||
|
||||
outcome.descriptorDigest !== intent.descriptor.sha256 ||
|
||||
outcome.observation.managerKind !== intent.service.kind ||
|
||||
outcome.observation.fragmentPath !== intent.descriptor.destinationPath
|
||||
) {
|
||||
configurationError('service manager outcome binding drifted');
|
||||
}
|
||||
assertIntentLineageHead(intent, identity.uid);
|
||||
const applicationBytes = privateFileBytes(
|
||||
intent.deployment.applicationConfigPath,
|
||||
0o600,
|
||||
identity.uid,
|
||||
identity.gid,
|
||||
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
'application configuration',
|
||||
);
|
||||
const descriptorBytes = privateFileBytes(
|
||||
intent.descriptor.sourcePath,
|
||||
intent.descriptor.sourceMode,
|
||||
identity.uid,
|
||||
identity.gid,
|
||||
MAX_DESCRIPTOR_BYTES,
|
||||
'service descriptor',
|
||||
);
|
||||
try {
|
||||
if (
|
||||
sha256(applicationBytes) !== intent.deployment.applicationConfigSha256 ||
|
||||
sha256(descriptorBytes) !== intent.descriptor.sha256
|
||||
) {
|
||||
configurationError('service manager source material drifted');
|
||||
}
|
||||
} finally {
|
||||
applicationBytes.fill(0);
|
||||
descriptorBytes.fill(0);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.service-manager.outcome.consume' as const,
|
||||
status: 'verified' as const,
|
||||
actionId: outcome.actionId,
|
||||
state: outcome.state,
|
||||
outcomeDigest: outcome.outcomeDigest,
|
||||
observationDigest: outcome.observation.observationDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareLocalServiceManagerIntentCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalServiceManagerIntentPrepareResult> {
|
||||
return prepareLocalServiceManagerIntent(
|
||||
readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function consumeLocalServiceManagerOutcomeCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalServiceManagerOutcomeConsumeResult> {
|
||||
return consumeLocalServiceManagerOutcome(
|
||||
readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
}),
|
||||
);
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
||||
import type {
|
||||
LocalServiceManagerAction,
|
||||
LocalServiceManagerKind,
|
||||
} from './serviceBridgeContract';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const MAX_PID = 0x7fffffff;
|
||||
|
||||
export type LocalServiceManagerOutcomeState =
|
||||
| 'active'
|
||||
| 'stopped'
|
||||
| 'manual_required';
|
||||
|
||||
export type LocalServiceManagerMutationDisposition =
|
||||
| 'executed'
|
||||
| 'response-loss-inspected'
|
||||
| 'replay-inspected';
|
||||
|
||||
export type LocalServiceManagerManualReason =
|
||||
| 'descriptor_install_unproved'
|
||||
| 'manager_command_failed'
|
||||
| 'manager_state_unproved';
|
||||
|
||||
export interface LocalServiceManagerObservation {
|
||||
readonly managerKind: LocalServiceManagerKind;
|
||||
readonly serviceName: 'qinglong3';
|
||||
readonly fragmentPath: string;
|
||||
readonly loadState: 'loaded' | 'not-found' | 'unknown';
|
||||
readonly activeState: 'active' | 'inactive' | 'failed' | 'unknown';
|
||||
readonly subState: string;
|
||||
readonly enabledState: 'enabled' | 'disabled' | 'static' | 'unknown';
|
||||
readonly mainPid: number;
|
||||
readonly observedAtMs: number;
|
||||
readonly observationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerOutcome {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-local-service-manager-outcome';
|
||||
readonly actionId: string;
|
||||
readonly action: LocalServiceManagerAction;
|
||||
readonly intentDigest: string;
|
||||
readonly descriptorDigest: string;
|
||||
readonly state: LocalServiceManagerOutcomeState;
|
||||
readonly mutationDisposition: LocalServiceManagerMutationDisposition;
|
||||
readonly manualReason: LocalServiceManagerManualReason | null;
|
||||
readonly observation: Readonly<LocalServiceManagerObservation>;
|
||||
readonly completedAtMs: number;
|
||||
readonly outcomeDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(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)
|
||||
) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function localServiceManagerObservationDigest(
|
||||
value: Omit<LocalServiceManagerObservation, 'observationDigest'>,
|
||||
): string {
|
||||
return digest(value);
|
||||
}
|
||||
|
||||
export function localServiceManagerOutcomeDigest(
|
||||
value: Omit<LocalServiceManagerOutcome, 'outcomeDigest'>,
|
||||
): string {
|
||||
return digest(value);
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceManagerObservation(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerObservation> {
|
||||
const observation = object(value, 'service manager observation');
|
||||
exact(
|
||||
observation,
|
||||
[
|
||||
'activeState',
|
||||
'enabledState',
|
||||
'fragmentPath',
|
||||
'loadState',
|
||||
'mainPid',
|
||||
'managerKind',
|
||||
'observationDigest',
|
||||
'observedAtMs',
|
||||
'serviceName',
|
||||
'subState',
|
||||
],
|
||||
'service manager observation',
|
||||
);
|
||||
if (
|
||||
(observation.managerKind !== 'systemd' &&
|
||||
observation.managerKind !== 'openrc') ||
|
||||
observation.serviceName !== 'qinglong3' ||
|
||||
typeof observation.fragmentPath !== 'string' ||
|
||||
(observation.loadState !== 'loaded' &&
|
||||
observation.loadState !== 'not-found' &&
|
||||
observation.loadState !== 'unknown') ||
|
||||
(observation.activeState !== 'active' &&
|
||||
observation.activeState !== 'inactive' &&
|
||||
observation.activeState !== 'failed' &&
|
||||
observation.activeState !== 'unknown') ||
|
||||
typeof observation.subState !== 'string' ||
|
||||
Buffer.byteLength(observation.subState, 'utf8') > 128 ||
|
||||
(observation.enabledState !== 'enabled' &&
|
||||
observation.enabledState !== 'disabled' &&
|
||||
observation.enabledState !== 'static' &&
|
||||
observation.enabledState !== 'unknown') ||
|
||||
!Number.isSafeInteger(observation.mainPid) ||
|
||||
(observation.mainPid as number) < 0 ||
|
||||
(observation.mainPid as number) > MAX_PID ||
|
||||
!Number.isSafeInteger(observation.observedAtMs) ||
|
||||
(observation.observedAtMs as number) < 0 ||
|
||||
typeof observation.observationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(observation.observationDigest)
|
||||
) {
|
||||
configurationError('service manager observation is invalid');
|
||||
}
|
||||
const payload = Object.freeze({
|
||||
managerKind: observation.managerKind,
|
||||
serviceName: 'qinglong3' as const,
|
||||
fragmentPath: observation.fragmentPath,
|
||||
loadState: observation.loadState,
|
||||
activeState: observation.activeState,
|
||||
subState: observation.subState,
|
||||
enabledState: observation.enabledState,
|
||||
mainPid: observation.mainPid as number,
|
||||
observedAtMs: observation.observedAtMs as number,
|
||||
});
|
||||
if (
|
||||
localServiceManagerObservationDigest(payload) !==
|
||||
observation.observationDigest
|
||||
) {
|
||||
configurationError('service manager observation digest is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
observationDigest: observation.observationDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceManagerOutcome(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerOutcome> {
|
||||
const outcome = object(value, 'service manager outcome');
|
||||
exact(
|
||||
outcome,
|
||||
[
|
||||
'action',
|
||||
'actionId',
|
||||
'completedAtMs',
|
||||
'descriptorDigest',
|
||||
'intentDigest',
|
||||
'kind',
|
||||
'manualReason',
|
||||
'mutationDisposition',
|
||||
'observation',
|
||||
'outcomeDigest',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'service manager outcome',
|
||||
);
|
||||
const observation = normalizeLocalServiceManagerObservation(
|
||||
outcome.observation,
|
||||
);
|
||||
if (
|
||||
outcome.schemaVersion !== 1 ||
|
||||
outcome.kind !== 'qinglong3-local-service-manager-outcome' ||
|
||||
typeof outcome.actionId !== 'string' ||
|
||||
(outcome.action !== 'install-enable-start' &&
|
||||
outcome.action !== 'start' &&
|
||||
outcome.action !== 'restart' &&
|
||||
outcome.action !== 'stop') ||
|
||||
typeof outcome.intentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(outcome.intentDigest) ||
|
||||
typeof outcome.descriptorDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(outcome.descriptorDigest) ||
|
||||
(outcome.state !== 'active' &&
|
||||
outcome.state !== 'stopped' &&
|
||||
outcome.state !== 'manual_required') ||
|
||||
(outcome.mutationDisposition !== 'executed' &&
|
||||
outcome.mutationDisposition !== 'response-loss-inspected' &&
|
||||
outcome.mutationDisposition !== 'replay-inspected') ||
|
||||
(outcome.manualReason !== null &&
|
||||
outcome.manualReason !== 'descriptor_install_unproved' &&
|
||||
outcome.manualReason !== 'manager_command_failed' &&
|
||||
outcome.manualReason !== 'manager_state_unproved') ||
|
||||
(outcome.state === 'manual_required') !== (outcome.manualReason !== null) ||
|
||||
!Number.isSafeInteger(outcome.completedAtMs) ||
|
||||
(outcome.completedAtMs as number) < observation.observedAtMs ||
|
||||
typeof outcome.outcomeDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(outcome.outcomeDigest)
|
||||
) {
|
||||
configurationError('service manager outcome is invalid');
|
||||
}
|
||||
const payload = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
kind: 'qinglong3-local-service-manager-outcome' as const,
|
||||
actionId: outcome.actionId,
|
||||
action: outcome.action,
|
||||
intentDigest: outcome.intentDigest,
|
||||
descriptorDigest: outcome.descriptorDigest,
|
||||
state: outcome.state,
|
||||
mutationDisposition: outcome.mutationDisposition,
|
||||
manualReason: outcome.manualReason,
|
||||
observation,
|
||||
completedAtMs: outcome.completedAtMs as number,
|
||||
});
|
||||
if (localServiceManagerOutcomeDigest(payload) !== outcome.outcomeDigest) {
|
||||
configurationError('service manager outcome digest is invalid');
|
||||
}
|
||||
return Object.freeze({ ...payload, outcomeDigest: outcome.outcomeDigest });
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
// Local lifecycle owns reviewed legacy adoption ceremonies.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
inspectLegacySqlitePath,
|
||||
publishReviewedLegacyCrontabAdoption,
|
||||
type CommitReviewedLegacyCrontabAdoptionOptions,
|
||||
} from '@qinglong/local-admin';
|
||||
import {
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
LegacyCrontabDecisionIssuerKeyringFileProvider,
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile,
|
||||
} from '@qinglong/local-admin/decision-issuer';
|
||||
import { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
import { openLocalSqliteBootstrapDatabase } from '@qinglong/local-sqlite/bootstrap';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const UUID_V7_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const LOCAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
interface PathIdentity {
|
||||
readonly path: string;
|
||||
readonly kind: 'directory' | 'file';
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly size: bigint;
|
||||
readonly modifiedAtNs: bigint;
|
||||
readonly changedAtNs: bigint;
|
||||
readonly uid: number;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
interface DeploymentProofOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly filePaths: readonly string[];
|
||||
readonly mutableFilePaths?: readonly string[];
|
||||
readonly directoryPaths: readonly string[];
|
||||
readonly missingPaths?: readonly string[];
|
||||
}
|
||||
|
||||
export interface IssueLegacyCrontabAdoptionDecisionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'legacy-crontab.decision.issue';
|
||||
readonly options: {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly issuerKeyringPath: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly sourcePath: string;
|
||||
readonly reviewFilePath: string;
|
||||
readonly authorizationPath: string;
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly decisionId: string;
|
||||
readonly legacyTimezone?: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
readonly lifetimeMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommitLegacyCrontabAdoptionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'legacy-crontab.adoption.commit';
|
||||
readonly options: {
|
||||
readonly deploymentRoot: string;
|
||||
readonly targetPath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly issuerKeyringPath: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly sourcePath: string;
|
||||
readonly authorizationPath: string;
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly expectedDecisionId: string;
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly legacyTimezone?: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueLegacyCrontabAdoptionDecisionCommandResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'legacy-crontab.decision.issue';
|
||||
readonly review: {
|
||||
readonly decisionCount: number;
|
||||
readonly fileBytes: number;
|
||||
readonly fileDigest: string;
|
||||
};
|
||||
readonly authorization: {
|
||||
readonly decisionId: string;
|
||||
readonly decisionCount: number;
|
||||
readonly fileBytes: number;
|
||||
readonly fileDigest: string;
|
||||
readonly keyId: string;
|
||||
};
|
||||
readonly receipt: {
|
||||
readonly reviewerSubjectId: string;
|
||||
readonly issuedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly decisionDigest: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommitLegacyCrontabAdoptionCommandResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'legacy-crontab.adoption.commit';
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly adoption: {
|
||||
readonly mutationId: string;
|
||||
readonly decisionId: string;
|
||||
readonly projectId: string;
|
||||
readonly publicationDigest: string;
|
||||
readonly adoptedTaskCount: number;
|
||||
readonly adoptedTriggerCount: number;
|
||||
readonly skippedCount: number;
|
||||
readonly auditEventId: string;
|
||||
readonly createdAtMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type LegacyCrontabAdoptionCommand =
|
||||
| IssueLegacyCrontabAdoptionDecisionCommand
|
||||
| CommitLegacyCrontabAdoptionCommand;
|
||||
|
||||
export type LegacyCrontabAdoptionCommandResult =
|
||||
| IssueLegacyCrontabAdoptionDecisionCommandResult
|
||||
| CommitLegacyCrontabAdoptionCommandResult;
|
||||
|
||||
export class LegacyCrontabAdoptionCliConfigurationError extends TypeError {
|
||||
readonly code = 'LEGACY_CRONTAB_ADOPTION_CLI_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Legacy Crontab adoption CLI configuration is invalid: ${message}`);
|
||||
this.name = 'LegacyCrontabAdoptionCliConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacyCrontabAdoptionCliAuthenticationError extends Error {
|
||||
readonly code = 'LEGACY_CRONTAB_ADOPTION_CLI_AUTHENTICATION_FAILED';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Legacy Crontab adoption CLI authentication failed: ${message}`);
|
||||
this.name = 'LegacyCrontabAdoptionCliAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function identity(
|
||||
targetPath: string,
|
||||
kind: PathIdentity['kind'],
|
||||
uid: number,
|
||||
): PathIdentity {
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(targetPath, { bigint: true });
|
||||
} catch (error) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
`${kind} is unavailable`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
const expectedKind =
|
||||
kind === 'directory' ? stat.isDirectory() : stat.isFile();
|
||||
const mode = Number(stat.mode) & 0o777;
|
||||
if (
|
||||
!expectedKind ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
mode !== (kind === 'directory' ? 0o700 : 0o600)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
`${kind} ownership or private mode is invalid`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
path: targetPath,
|
||||
kind,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
size: stat.size,
|
||||
modifiedAtNs: stat.mtimeNs,
|
||||
changedAtNs: stat.ctimeNs,
|
||||
uid,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
function sameIdentity(expected: PathIdentity, mutableFile: boolean): void {
|
||||
const actual = identity(expected.path, expected.kind, expected.uid);
|
||||
if (
|
||||
actual.device !== expected.device ||
|
||||
actual.inode !== expected.inode ||
|
||||
actual.mode !== expected.mode ||
|
||||
(expected.kind === 'file' &&
|
||||
!mutableFile &&
|
||||
(actual.size !== expected.size ||
|
||||
actual.modifiedAtNs !== expected.modifiedAtNs ||
|
||||
actual.changedAtNs !== expected.changedAtNs))
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
`${expected.kind} identity changed during command execution`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function descendants(deploymentRoot: string, targetPath: string): string[] {
|
||||
const relative = path.relative(deploymentRoot, targetPath);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'authority paths must be descendants of deploymentRoot',
|
||||
);
|
||||
}
|
||||
const directories: string[] = [];
|
||||
let current = deploymentRoot;
|
||||
for (const part of relative.split(path.sep).slice(0, -1)) {
|
||||
current = path.join(current, part);
|
||||
directories.push(current);
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
function assertMissing(targetPath: string): void {
|
||||
try {
|
||||
fs.lstatSync(targetPath);
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'authorization destination cannot be inspected',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'authorization destination must not exist',
|
||||
);
|
||||
}
|
||||
|
||||
function optionalRuntimeOptions(options: Record<string, unknown>): string[] {
|
||||
return [
|
||||
...(options.busyTimeoutMs === undefined ? [] : ['busyTimeoutMs']),
|
||||
...(options.legacyTimezone === undefined ? [] : ['legacyTimezone']),
|
||||
];
|
||||
}
|
||||
|
||||
function validRuntimeOptions(options: Record<string, unknown>): boolean {
|
||||
return !(
|
||||
(options.legacyTimezone !== undefined &&
|
||||
(typeof options.legacyTimezone !== 'string' ||
|
||||
options.legacyTimezone.length < 1 ||
|
||||
options.legacyTimezone.length > 128)) ||
|
||||
(options.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.busyTimeoutMs) ||
|
||||
(options.busyTimeoutMs as number) < 1 ||
|
||||
(options.busyTimeoutMs as number) > 60_000))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LegacyCrontabAdoptionCommand> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['schemaVersion', 'operation', 'options'])
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'command shape is invalid',
|
||||
);
|
||||
}
|
||||
const command = value as Record<string, unknown>;
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
(command.operation !== 'legacy-crontab.decision.issue' &&
|
||||
command.operation !== 'legacy-crontab.adoption.commit') ||
|
||||
!command.options ||
|
||||
typeof command.options !== 'object' ||
|
||||
Array.isArray(command.options)
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'command value is invalid',
|
||||
);
|
||||
}
|
||||
const options = command.options as Record<string, unknown>;
|
||||
const issue = command.operation === 'legacy-crontab.decision.issue';
|
||||
const expected = issue
|
||||
? [
|
||||
'authorizationPath',
|
||||
...optionalRuntimeOptions(options),
|
||||
'credentialFilePath',
|
||||
'databasePath',
|
||||
'decisionId',
|
||||
'deploymentRoot',
|
||||
'expectedPlanDigest',
|
||||
'issuerKeyringPath',
|
||||
...(options.lifetimeMs === undefined ? [] : ['lifetimeMs']),
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
'reviewFilePath',
|
||||
'sourcePath',
|
||||
]
|
||||
: [
|
||||
'authorizationPath',
|
||||
...optionalRuntimeOptions(options),
|
||||
'credentialFilePath',
|
||||
'deploymentRoot',
|
||||
'expectedDecisionId',
|
||||
'expectedPlanDigest',
|
||||
'issuerKeyringPath',
|
||||
'mutationId',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'sourcePath',
|
||||
'targetPath',
|
||||
];
|
||||
if (
|
||||
!exactKeys(options, expected) ||
|
||||
(options.profile !== 'edge' && options.profile !== 'standalone') ||
|
||||
typeof options.expectedPlanDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(options.expectedPlanDigest) ||
|
||||
!validRuntimeOptions(options) ||
|
||||
(issue &&
|
||||
(typeof options.decisionId !== 'string' ||
|
||||
!UUID_V7_PATTERN.test(options.decisionId))) ||
|
||||
(issue &&
|
||||
options.lifetimeMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.lifetimeMs) ||
|
||||
(options.lifetimeMs as number) < 1_000 ||
|
||||
(options.lifetimeMs as number) > 30 * 60 * 1_000)) ||
|
||||
(!issue &&
|
||||
(typeof options.expectedDecisionId !== 'string' ||
|
||||
!UUID_V7_PATTERN.test(options.expectedDecisionId) ||
|
||||
typeof options.mutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(options.mutationId) ||
|
||||
typeof options.projectId !== 'string' ||
|
||||
!LOCAL_ID_PATTERN.test(options.projectId) ||
|
||||
typeof options.requestId !== 'string' ||
|
||||
!LOCAL_ID_PATTERN.test(options.requestId)))
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'command options are invalid',
|
||||
);
|
||||
}
|
||||
const pathKeys = [
|
||||
'authorizationPath',
|
||||
'credentialFilePath',
|
||||
'deploymentRoot',
|
||||
'issuerKeyringPath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'sourcePath',
|
||||
...(issue ? ['databasePath', 'reviewFilePath'] : ['targetPath']),
|
||||
];
|
||||
for (const key of pathKeys) {
|
||||
boundedPath(options[key], key);
|
||||
}
|
||||
return Object.freeze(value as LegacyCrontabAdoptionCommand);
|
||||
}
|
||||
|
||||
function readCommandFile(
|
||||
commandFilePath: string,
|
||||
): Readonly<LegacyCrontabAdoptionCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(commandFilePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LegacyCrontabAdoptionCliConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function deploymentProof(options: DeploymentProofOptions): {
|
||||
verify(): void;
|
||||
} {
|
||||
const uid = currentUid();
|
||||
const rootPath = boundedPath(options.deploymentRoot, 'deploymentRoot');
|
||||
const root = identity(rootPath, 'directory', uid);
|
||||
const filePaths = options.filePaths.map((candidate) =>
|
||||
boundedPath(candidate, 'authority file'),
|
||||
);
|
||||
const directoryPaths = options.directoryPaths.map((candidate) =>
|
||||
boundedPath(candidate, 'authority directory'),
|
||||
);
|
||||
const missingPaths = (options.missingPaths ?? []).map((candidate) =>
|
||||
boundedPath(candidate, 'missing authority path'),
|
||||
);
|
||||
const mutableFilePaths = new Set(
|
||||
(options.mutableFilePaths ?? []).map((candidate) =>
|
||||
boundedPath(candidate, 'mutable authority file'),
|
||||
),
|
||||
);
|
||||
if (
|
||||
[...mutableFilePaths].some((candidate) => !filePaths.includes(candidate))
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'mutable authority files must be declared files',
|
||||
);
|
||||
}
|
||||
const nestedDirectories = new Set<string>();
|
||||
for (const target of [...filePaths, ...directoryPaths]) {
|
||||
for (const directory of descendants(rootPath, target)) {
|
||||
nestedDirectories.add(directory);
|
||||
}
|
||||
}
|
||||
const directories = [
|
||||
root,
|
||||
...[...nestedDirectories]
|
||||
.filter((candidate) => candidate !== rootPath)
|
||||
.map((candidate) => identity(candidate, 'directory', uid)),
|
||||
...directoryPaths
|
||||
.filter(
|
||||
(candidate, index) =>
|
||||
directoryPaths.indexOf(candidate) === index &&
|
||||
candidate !== rootPath &&
|
||||
!nestedDirectories.has(candidate),
|
||||
)
|
||||
.map((candidate) => identity(candidate, 'directory', uid)),
|
||||
];
|
||||
const files = filePaths.map((candidate) => identity(candidate, 'file', uid));
|
||||
const uniqueFiles = new Set(
|
||||
files.map((entry) => `${entry.device}:${entry.inode}`),
|
||||
);
|
||||
if (uniqueFiles.size !== files.length) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'authority files must not share an inode',
|
||||
);
|
||||
}
|
||||
for (const missingPath of missingPaths) assertMissing(missingPath);
|
||||
if (!files.some((entry) => entry.path === options.credentialFilePath)) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'credential file must be part of the deployment proof',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
verify() {
|
||||
if (currentUid() !== uid) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'POSIX user changed during command execution',
|
||||
);
|
||||
}
|
||||
for (const expected of [...directories, ...files]) {
|
||||
sameIdentity(expected, mutableFilePaths.has(expected.path));
|
||||
}
|
||||
for (const missingPath of missingPaths) assertMissing(missingPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type LocalBootstrapDatabase = Awaited<
|
||||
ReturnType<typeof openLocalSqliteBootstrapDatabase>
|
||||
>;
|
||||
type CommitReviewer = Parameters<
|
||||
NonNullable<
|
||||
CommitReviewedLegacyCrontabAdoptionOptions['confirmReviewerAuthority']
|
||||
>
|
||||
>[0];
|
||||
|
||||
async function createReviewerAuthority(
|
||||
database: LocalBootstrapDatabase,
|
||||
deploymentRoot: string,
|
||||
databasePath: string,
|
||||
ownerPepperKeyringDirectory: string,
|
||||
credentialFilePath: string,
|
||||
proof: ReturnType<typeof deploymentProof>,
|
||||
): Promise<{
|
||||
readonly reviewer: Readonly<CommitReviewer>;
|
||||
readonly confirm: () => Promise<void>;
|
||||
}> {
|
||||
proof.verify();
|
||||
let authenticated: Awaited<
|
||||
ReturnType<typeof establishAuthenticatedLocalCommand>
|
||||
>;
|
||||
try {
|
||||
authenticated = await establishAuthenticatedLocalCommand(database, {
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
authenticationNamespace: 'local_adoption',
|
||||
});
|
||||
} catch {
|
||||
throw new LegacyCrontabAdoptionCliAuthenticationError(
|
||||
'credential authority is unavailable',
|
||||
);
|
||||
}
|
||||
const reviewer: Readonly<CommitReviewer> = authenticated.principal;
|
||||
return Object.freeze({
|
||||
reviewer,
|
||||
async confirm() {
|
||||
proof.verify();
|
||||
try {
|
||||
await authenticated.confirm();
|
||||
} catch {
|
||||
throw new LegacyCrontabAdoptionCliAuthenticationError(
|
||||
'credential authority changed during adoption',
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function issueDecision(
|
||||
command: Readonly<IssueLegacyCrontabAdoptionDecisionCommand>,
|
||||
): Promise<Readonly<IssueLegacyCrontabAdoptionDecisionCommandResult>> {
|
||||
const options = command.options;
|
||||
const proof = deploymentProof({
|
||||
deploymentRoot: options.deploymentRoot,
|
||||
credentialFilePath: options.credentialFilePath,
|
||||
filePaths: [
|
||||
options.databasePath,
|
||||
options.issuerKeyringPath,
|
||||
options.credentialFilePath,
|
||||
options.sourcePath,
|
||||
options.reviewFilePath,
|
||||
],
|
||||
directoryPaths: [
|
||||
options.ownerPepperKeyringDirectory,
|
||||
path.dirname(options.authorizationPath),
|
||||
],
|
||||
missingPaths: [options.authorizationPath],
|
||||
});
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
});
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'source no longer matches expectedPlanDigest',
|
||||
);
|
||||
}
|
||||
return withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
{
|
||||
filePath: options.reviewFilePath,
|
||||
expectedDecisionId: options.decisionId,
|
||||
expectedProfile: options.profile,
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
expectedInventoryDigest: plan.tasks.inventoryDigest,
|
||||
},
|
||||
async (review) => {
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.databasePath,
|
||||
profile: options.profile,
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||
});
|
||||
try {
|
||||
const authority = await createReviewerAuthority(
|
||||
database,
|
||||
options.deploymentRoot,
|
||||
options.databasePath,
|
||||
options.ownerPepperKeyringDirectory,
|
||||
options.credentialFilePath,
|
||||
proof,
|
||||
);
|
||||
const issued =
|
||||
await issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
decisionId: options.decisionId,
|
||||
authorizationPath: options.authorizationPath,
|
||||
issuerKeyringPath: options.issuerKeyringPath,
|
||||
decisions: review.decisions,
|
||||
authenticateReviewer: () => authority.reviewer,
|
||||
confirmIssuerAuthority: authority.confirm,
|
||||
confirmDecisionStreamAuthority: review.confirmIdentity,
|
||||
...(options.lifetimeMs === undefined
|
||||
? {}
|
||||
: { lifetimeMs: options.lifetimeMs }),
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'legacy-crontab.decision.issue',
|
||||
review: Object.freeze({
|
||||
decisionCount: review.evidence.decisionCount,
|
||||
fileBytes: review.evidence.fileBytes,
|
||||
fileDigest: review.evidence.fileDigest,
|
||||
}),
|
||||
authorization: Object.freeze({
|
||||
decisionId: issued.file.decisionId,
|
||||
decisionCount: issued.file.decisionCount,
|
||||
fileBytes: issued.file.fileBytes,
|
||||
fileDigest: issued.file.fileDigest,
|
||||
keyId: issued.file.keyId,
|
||||
}),
|
||||
receipt: Object.freeze({
|
||||
reviewerSubjectId: issued.receipt.reviewer.subject.id,
|
||||
issuedAtMs: issued.receipt.issuedAtMs,
|
||||
expiresAtMs: issued.receipt.expiresAtMs,
|
||||
decisionDigest: issued.receipt.decisions.decisionDigest,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function commitAdoption(
|
||||
command: Readonly<CommitLegacyCrontabAdoptionCommand>,
|
||||
): Promise<Readonly<CommitLegacyCrontabAdoptionCommandResult>> {
|
||||
const options = command.options;
|
||||
const proof = deploymentProof({
|
||||
deploymentRoot: options.deploymentRoot,
|
||||
credentialFilePath: options.credentialFilePath,
|
||||
filePaths: [
|
||||
options.targetPath,
|
||||
options.issuerKeyringPath,
|
||||
options.credentialFilePath,
|
||||
options.sourcePath,
|
||||
options.authorizationPath,
|
||||
],
|
||||
mutableFilePaths: [options.targetPath],
|
||||
directoryPaths: [options.ownerPepperKeyringDirectory],
|
||||
});
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: options.sourcePath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
});
|
||||
if (plan.planDigest !== options.expectedPlanDigest) {
|
||||
throw new LegacyCrontabAdoptionCliConfigurationError(
|
||||
'source no longer matches expectedPlanDigest',
|
||||
);
|
||||
}
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: options.targetPath,
|
||||
profile: options.profile,
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||
});
|
||||
try {
|
||||
const authority = await createReviewerAuthority(
|
||||
database,
|
||||
options.deploymentRoot,
|
||||
options.targetPath,
|
||||
options.ownerPepperKeyringDirectory,
|
||||
options.credentialFilePath,
|
||||
proof,
|
||||
);
|
||||
const confirmReviewerAuthority = async (
|
||||
reviewer: Readonly<CommitReviewer>,
|
||||
): Promise<void> => {
|
||||
await authority.confirm();
|
||||
if (
|
||||
reviewer.subject.type !== authority.reviewer.subject.type ||
|
||||
reviewer.subject.id !== authority.reviewer.subject.id ||
|
||||
reviewer.authenticationId !== authority.reviewer.authenticationId ||
|
||||
reviewer.assurance !== authority.reviewer.assurance
|
||||
) {
|
||||
throw new LegacyCrontabAdoptionCliAuthenticationError(
|
||||
'current operator does not match the signed reviewer',
|
||||
);
|
||||
}
|
||||
};
|
||||
const published = await publishReviewedLegacyCrontabAdoption({
|
||||
sourcePath: options.sourcePath,
|
||||
targetPath: options.targetPath,
|
||||
authorizationPath: options.authorizationPath,
|
||||
profile: options.profile,
|
||||
...(options.legacyTimezone === undefined
|
||||
? {}
|
||||
: { legacyTimezone: options.legacyTimezone }),
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
expectedDecisionId: options.expectedDecisionId,
|
||||
projectId: options.projectId,
|
||||
mutationId: options.mutationId,
|
||||
requestId: options.requestId,
|
||||
keyProvider: new LegacyCrontabDecisionIssuerKeyringFileProvider(
|
||||
options.issuerKeyringPath,
|
||||
),
|
||||
observedAtMs: Date.now(),
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||
confirmReviewerAuthority,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'legacy-crontab.adoption.commit',
|
||||
status: published.status,
|
||||
adoption: Object.freeze({
|
||||
mutationId: published.adoption.mutationId,
|
||||
decisionId: published.adoption.decisionId,
|
||||
projectId: published.adoption.projectId,
|
||||
publicationDigest: published.adoption.publicationDigest,
|
||||
adoptedTaskCount: published.adoption.adoptedTaskCount,
|
||||
adoptedTriggerCount: published.adoption.adoptedTriggerCount,
|
||||
skippedCount: published.adoption.skippedCount,
|
||||
auditEventId: published.adoption.auditEventId,
|
||||
createdAtMs: published.adoption.createdAtMs,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLegacyCrontabAdoptionCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LegacyCrontabAdoptionCommandResult>> {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
return command.operation === 'legacy-crontab.decision.issue'
|
||||
? issueDecision(command)
|
||||
: commitAdoption(command);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Keep the one-shot adoption binary beside its lifecycle command.
|
||||
import { runLegacyCrontabAdoptionCommandFile } from './adoption';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-adoption run --command-file /absolute/private-command.json';
|
||||
|
||||
function publicErrorCode(error: unknown): string {
|
||||
let current = error;
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
if (!current || typeof current !== 'object') break;
|
||||
const candidate = current as { readonly code?: unknown; cause?: unknown };
|
||||
if (
|
||||
candidate.code === 'LEGACY_CRONTAB_ADOPTION_CLI_AUTHENTICATION_FAILED'
|
||||
) {
|
||||
return candidate.code;
|
||||
}
|
||||
current = candidate.cause;
|
||||
}
|
||||
const candidate = error as { readonly code?: unknown };
|
||||
return typeof candidate?.code === 'string'
|
||||
? candidate.code
|
||||
: 'LEGACY_CRONTAB_ADOPTION_CLI_FAILED';
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 3 || argv[0] !== 'run' || argv[1] !== '--command-file') {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LEGACY_CRONTAB_ADOPTION_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLegacyCrontabAdoptionCommandFile(argv[2]!);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: publicErrorCode(error),
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Legacy Crontab adoption command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,213 @@
|
||||
// Local lifecycle owns short-lived readiness inspection.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
inspectLocalSqliteReadinessPath,
|
||||
type LocalSqliteDatabaseOptions,
|
||||
type LocalSqliteReadinessEvidence,
|
||||
} from '@qinglong/local-sqlite/readiness-inspection';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface LocalReadinessResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.readiness.inspect';
|
||||
readonly status: 'ready';
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly storage: Readonly<{
|
||||
contractName: string;
|
||||
contractVersion: number;
|
||||
migrationCount: number;
|
||||
tableCount: number;
|
||||
sqliteVersion: string;
|
||||
journalMode: 'delete' | 'wal';
|
||||
}>;
|
||||
}
|
||||
|
||||
export class LocalReadinessConfigurationError extends TypeError {
|
||||
readonly code = 'QL3_LOCAL_READINESS_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Local readiness configuration is invalid: ${message}`, options);
|
||||
this.name = 'LocalReadinessConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalReadinessIncompatibleError extends Error {
|
||||
readonly code = 'QL3_LOCAL_READINESS_INCOMPATIBLE';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Local readiness is incompatible: ${message}`, options);
|
||||
this.name = 'LocalReadinessIncompatibleError';
|
||||
}
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function normalizedDatabasePath(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'databasePath must be a normalized bounded absolute non-root path',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function inspectPrivateDatabaseFile(databasePath: string): void {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(databasePath);
|
||||
} catch (error) {
|
||||
throw new LocalReadinessConfigurationError('database is unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== currentUid() ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
fs.realpathSync(databasePath) !== databasePath
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'database must be a canonical current-UID mode-0600 regular file',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLocalReadinessOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalSqliteDatabaseOptions> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalReadinessConfigurationError('options must be an object');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const expectedKeys = [
|
||||
'databasePath',
|
||||
'profile',
|
||||
...(Object.hasOwn(candidate, 'busyTimeoutMs') ? ['busyTimeoutMs'] : []),
|
||||
].sort();
|
||||
const actualKeys = Object.keys(candidate).sort();
|
||||
if (
|
||||
actualKeys.length !== expectedKeys.length ||
|
||||
actualKeys.some((key, index) => key !== expectedKeys[index])
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError('options shape is invalid');
|
||||
}
|
||||
if (candidate.profile !== 'edge' && candidate.profile !== 'standalone') {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
const busyTimeoutMs = candidate.busyTimeoutMs;
|
||||
if (
|
||||
busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(busyTimeoutMs) ||
|
||||
(busyTimeoutMs as number) < 100 ||
|
||||
(busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'busyTimeoutMs must be between 100 and 30000',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
databasePath: normalizedDatabasePath(candidate.databasePath),
|
||||
profile: candidate.profile,
|
||||
...(busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: busyTimeoutMs as number }),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseLocalReadinessArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<LocalSqliteDatabaseOptions> {
|
||||
const values = new Map<string, string>();
|
||||
for (const argument of argv) {
|
||||
if (argument === '--') continue;
|
||||
const match = /^--(database|profile|busy-timeout-ms)=(.+)$/.exec(argument);
|
||||
if (!match || values.has(match[1]!)) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'arguments must contain one --database and one --profile, with optional --busy-timeout-ms',
|
||||
);
|
||||
}
|
||||
values.set(match[1]!, match[2]!);
|
||||
}
|
||||
if (
|
||||
values.size < 2 ||
|
||||
values.size > 3 ||
|
||||
!values.has('database') ||
|
||||
!values.has('profile')
|
||||
) {
|
||||
throw new LocalReadinessConfigurationError(
|
||||
'arguments must contain one --database and one --profile, with optional --busy-timeout-ms',
|
||||
);
|
||||
}
|
||||
const rawTimeout = values.get('busy-timeout-ms');
|
||||
return normalizeLocalReadinessOptions({
|
||||
databasePath: values.get('database'),
|
||||
profile: values.get('profile'),
|
||||
...(rawTimeout === undefined ? {} : { busyTimeoutMs: Number(rawTimeout) }),
|
||||
});
|
||||
}
|
||||
|
||||
function resultFromEvidence(
|
||||
profile: 'edge' | 'standalone',
|
||||
evidence: LocalSqliteReadinessEvidence,
|
||||
): Readonly<LocalReadinessResult> {
|
||||
const expectedJournalMode = profile === 'edge' ? 'delete' : 'wal';
|
||||
if (evidence.journalMode !== expectedJournalMode) {
|
||||
throw new LocalReadinessIncompatibleError(
|
||||
`${profile} requires ${expectedJournalMode} journal mode`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.readiness.inspect' as const,
|
||||
status: 'ready' as const,
|
||||
profile,
|
||||
storage: Object.freeze({
|
||||
contractName: evidence.contractName,
|
||||
contractVersion: evidence.contractVersion,
|
||||
migrationCount: evidence.migrationIds.length,
|
||||
tableCount: evidence.tableCount,
|
||||
sqliteVersion: evidence.sqliteVersion,
|
||||
journalMode: expectedJournalMode,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function inspectLocalReadiness(
|
||||
value: unknown,
|
||||
): Promise<Readonly<LocalReadinessResult>> {
|
||||
const options = normalizeLocalReadinessOptions(value);
|
||||
inspectPrivateDatabaseFile(options.databasePath);
|
||||
let evidence: LocalSqliteReadinessEvidence;
|
||||
try {
|
||||
evidence = await inspectLocalSqliteReadinessPath(options);
|
||||
} catch (error) {
|
||||
throw new LocalReadinessIncompatibleError('storage audit failed', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return resultFromEvidence(options.profile, evidence);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Keep the readiness binary beside its lifecycle inspection.
|
||||
import {
|
||||
inspectLocalReadiness,
|
||||
parseLocalReadinessArguments,
|
||||
} from './localReadiness';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-readiness --database=/absolute/qinglong3.sqlite --profile=<edge|standalone> [--busy-timeout-ms=5000]';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
await inspectLocalReadiness(parseLocalReadinessArguments(argv)),
|
||||
)}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'QL3_LOCAL_READINESS_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,491 @@
|
||||
// Local lifecycle owns first-run storage and key-material setup.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
import {
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
backupLocalOwnerPepperKey,
|
||||
provisionLocalOwnerPepperKey,
|
||||
} from '@qinglong/local-owner-console/pepper-custody';
|
||||
import {
|
||||
LocalSecretKeyringFileProvider,
|
||||
provisionLocalSecretKeyring,
|
||||
} from '@qinglong/local-secret';
|
||||
import { openLocalSqliteBootstrapDatabase } from '@qinglong/local-sqlite/bootstrap';
|
||||
import { migrateLocalSqlitePath } from '@qinglong/local-sqlite/migration';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface LocalSetupCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.setup.prepare';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
databasePath: string;
|
||||
profile: 'edge' | 'standalone';
|
||||
ownerPepperKeyringDirectory: string;
|
||||
ownerPepperBackupDirectory: string;
|
||||
ownerPepperKeyId: string;
|
||||
localSecretKeyringPath: string;
|
||||
busyTimeoutMs?: number;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
registerMutationId: string;
|
||||
activateMutationId: string;
|
||||
registeredAtMs: number;
|
||||
activatedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalSetupResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly storage: Readonly<{
|
||||
contractName: string;
|
||||
contractVersion: number;
|
||||
migrationCount: number;
|
||||
}>;
|
||||
readonly ownerPepper: Readonly<{
|
||||
registerStatus: 'inserted' | 'existing';
|
||||
activateStatus: 'inserted' | 'existing';
|
||||
generation: number;
|
||||
}>;
|
||||
readonly envelopeKeyring: Readonly<{
|
||||
version: 1;
|
||||
keyCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class LocalSetupConfigurationError extends TypeError {
|
||||
readonly code = 'QL3_LOCAL_SETUP_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Local setup configuration is invalid: ${message}`, options);
|
||||
this.name = 'LocalSetupConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function absolute(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function privateDirectory(value: unknown, label: string): string {
|
||||
const directory = absolute(value, label);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory);
|
||||
} catch (error) {
|
||||
throw new LocalSetupConfigurationError(`${label} is unavailable`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== currentUid() ||
|
||||
(stat.mode & 0o777) !== 0o700 ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
`${label} must be a canonical current-UID 0700 directory`,
|
||||
);
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
function child(root: string, value: unknown, label: string): string {
|
||||
const candidate = absolute(value, label);
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative === '' ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
`${label} must be a distinct child of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function safeInteger(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function normalizeLocalSetupCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalSetupCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(command, ['operation', 'options', 'request', 'schemaVersion'], 'command');
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !== 'local.setup.prepare'
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'schemaVersion or operation is invalid',
|
||||
);
|
||||
}
|
||||
const rawOptions = object(command.options, 'options');
|
||||
const optionalKeys = Object.hasOwn(rawOptions, 'busyTimeoutMs')
|
||||
? ['busyTimeoutMs']
|
||||
: [];
|
||||
exact(
|
||||
rawOptions,
|
||||
[
|
||||
'databasePath',
|
||||
'deploymentRoot',
|
||||
'localSecretKeyringPath',
|
||||
'ownerPepperBackupDirectory',
|
||||
'ownerPepperKeyId',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
...optionalKeys,
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = privateDirectory(
|
||||
rawOptions.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
);
|
||||
const databasePath = child(
|
||||
deploymentRoot,
|
||||
rawOptions.databasePath,
|
||||
'databasePath',
|
||||
);
|
||||
const localSecretKeyringPath = child(
|
||||
deploymentRoot,
|
||||
rawOptions.localSecretKeyringPath,
|
||||
'localSecretKeyringPath',
|
||||
);
|
||||
const ownerPepperKeyringDirectory = privateDirectory(
|
||||
child(
|
||||
deploymentRoot,
|
||||
rawOptions.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
),
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const ownerPepperBackupDirectory = privateDirectory(
|
||||
child(
|
||||
deploymentRoot,
|
||||
rawOptions.ownerPepperBackupDirectory,
|
||||
'ownerPepperBackupDirectory',
|
||||
),
|
||||
'ownerPepperBackupDirectory',
|
||||
);
|
||||
if (
|
||||
new Set([
|
||||
databasePath,
|
||||
localSecretKeyringPath,
|
||||
ownerPepperKeyringDirectory,
|
||||
ownerPepperBackupDirectory,
|
||||
]).size !== 4
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'setup authority paths must be distinct',
|
||||
);
|
||||
}
|
||||
if (
|
||||
rawOptions.profile !== 'edge' &&
|
||||
rawOptions.profile !== 'standalone'
|
||||
) {
|
||||
throw new LocalSetupConfigurationError('profile is invalid');
|
||||
}
|
||||
if (
|
||||
typeof rawOptions.ownerPepperKeyId !== 'string' ||
|
||||
rawOptions.ownerPepperKeyId.length < 1 ||
|
||||
rawOptions.ownerPepperKeyId.length > 128
|
||||
) {
|
||||
throw new LocalSetupConfigurationError('ownerPepperKeyId is invalid');
|
||||
}
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
[
|
||||
'activateMutationId',
|
||||
'activatedAtMs',
|
||||
'registerMutationId',
|
||||
'registeredAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
typeof request.registerMutationId !== 'string' ||
|
||||
typeof request.activateMutationId !== 'string'
|
||||
) {
|
||||
throw new LocalSetupConfigurationError('mutation identity is invalid');
|
||||
}
|
||||
const registeredAtMs = safeInteger(
|
||||
request.registeredAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'registeredAtMs',
|
||||
);
|
||||
const activatedAtMs = safeInteger(
|
||||
request.activatedAtMs,
|
||||
registeredAtMs,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'activatedAtMs',
|
||||
);
|
||||
const busyTimeoutMs =
|
||||
rawOptions.busyTimeoutMs === undefined
|
||||
? undefined
|
||||
: safeInteger(rawOptions.busyTimeoutMs, 100, 30_000, 'busyTimeoutMs');
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.setup.prepare' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
profile: rawOptions.profile,
|
||||
ownerPepperKeyringDirectory,
|
||||
ownerPepperBackupDirectory,
|
||||
ownerPepperKeyId: rawOptions.ownerPepperKeyId,
|
||||
localSecretKeyringPath,
|
||||
...(busyTimeoutMs === undefined ? {} : { busyTimeoutMs }),
|
||||
}),
|
||||
request: Object.freeze({
|
||||
registerMutationId: request.registerMutationId,
|
||||
activateMutationId: request.activateMutationId,
|
||||
registeredAtMs,
|
||||
activatedAtMs,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function existingOrProvisionPepper(
|
||||
command: Readonly<LocalSetupCommand>,
|
||||
): Readonly<{ digest: string; created: boolean }> {
|
||||
const provider = new LocalOwnerPepperKeyringFileProvider(
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
);
|
||||
const inspected = provider.inspect();
|
||||
if (inspected.keyIds.length === 0) {
|
||||
const created = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: command.options.ownerPepperKeyringDirectory,
|
||||
pepperKeyId: command.options.ownerPepperKeyId,
|
||||
});
|
||||
return Object.freeze({ digest: created.digest, created: true });
|
||||
}
|
||||
if (
|
||||
inspected.keyIds.length !== 1 ||
|
||||
inspected.keyIds[0] !== command.options.ownerPepperKeyId
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper keyring is not an exact setup replay',
|
||||
);
|
||||
}
|
||||
const material = provider.resolve(command.options.ownerPepperKeyId);
|
||||
if (!material) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper material is unavailable',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
digest: material.summary.digest,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
function existingOrBackupPepper(
|
||||
command: Readonly<LocalSetupCommand>,
|
||||
expectedDigest: string,
|
||||
): Readonly<{ digest: string; created: boolean }> {
|
||||
const provider = new LocalOwnerPepperKeyringFileProvider(
|
||||
command.options.ownerPepperBackupDirectory,
|
||||
);
|
||||
const inspected = provider.inspect();
|
||||
if (inspected.keyIds.length === 0) {
|
||||
const created = backupLocalOwnerPepperKey({
|
||||
keyringDirectory: command.options.ownerPepperKeyringDirectory,
|
||||
backupDirectory: command.options.ownerPepperBackupDirectory,
|
||||
pepperKeyId: command.options.ownerPepperKeyId,
|
||||
});
|
||||
if (created.digest !== expectedDigest) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper backup digest changed',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ digest: created.digest, created: true });
|
||||
}
|
||||
if (
|
||||
inspected.keyIds.length !== 1 ||
|
||||
inspected.keyIds[0] !== command.options.ownerPepperKeyId
|
||||
) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper backup is not an exact setup replay',
|
||||
);
|
||||
}
|
||||
const material = provider.resolve(command.options.ownerPepperKeyId);
|
||||
if (!material || material.summary.digest !== expectedDigest) {
|
||||
throw new LocalSetupConfigurationError(
|
||||
'Owner pepper backup does not match primary material',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
digest: material.summary.digest,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function existingOrProvisionEnvelopeKeyring(
|
||||
filePath: string,
|
||||
): Promise<Readonly<{ version: 1; keyCount: number; created: boolean }>> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const created = await provisionLocalSecretKeyring(filePath);
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
keyCount: created.keyIds.length,
|
||||
created: true,
|
||||
});
|
||||
}
|
||||
const existing = await new LocalSecretKeyringFileProvider(filePath).inspect();
|
||||
return Object.freeze({
|
||||
version: 1,
|
||||
keyCount: existing.keyIds.length,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeLocalSetup(
|
||||
input: unknown,
|
||||
): Promise<Readonly<LocalSetupResult>> {
|
||||
const command = normalizeLocalSetupCommand(input);
|
||||
const migration = await migrateLocalSqlitePath({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
const pepper = existingOrProvisionPepper(command);
|
||||
const backup = existingOrBackupPepper(command, pepper.digest);
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let registered;
|
||||
let activated;
|
||||
try {
|
||||
registered = await database.ownerPepper.register({
|
||||
mutationId: command.request.registerMutationId,
|
||||
pepperKeyId: command.options.ownerPepperKeyId,
|
||||
materialDigest: pepper.digest,
|
||||
backupDigest: backup.digest,
|
||||
registeredAtMs: command.request.registeredAtMs,
|
||||
});
|
||||
activated = await database.ownerPepper.activate({
|
||||
mutationId: command.request.activateMutationId,
|
||||
pepperKeyId: command.options.ownerPepperKeyId,
|
||||
expectedGeneration: 0,
|
||||
activatedAtMs: command.request.activatedAtMs,
|
||||
});
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
const envelopeKeyring = await existingOrProvisionEnvelopeKeyring(
|
||||
command.options.localSecretKeyringPath,
|
||||
);
|
||||
const created =
|
||||
pepper.created ||
|
||||
backup.created ||
|
||||
registered.status === 'inserted' ||
|
||||
activated.status === 'inserted' ||
|
||||
envelopeKeyring.created;
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
status: created ? ('prepared' as const) : ('existing' as const),
|
||||
profile: command.options.profile,
|
||||
storage: Object.freeze({
|
||||
contractName: migration.readiness.contractName,
|
||||
contractVersion: migration.readiness.contractVersion,
|
||||
migrationCount: migration.readiness.migrationIds.length,
|
||||
}),
|
||||
ownerPepper: Object.freeze({
|
||||
registerStatus: registered.status,
|
||||
activateStatus: activated.status,
|
||||
generation: activated.activation.generation,
|
||||
}),
|
||||
envelopeKeyring: Object.freeze({
|
||||
version: 1 as const,
|
||||
keyCount: envelopeKeyring.keyCount,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function executeLocalSetupCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalSetupResult>> {
|
||||
return executeLocalSetup(readPrivateLocalCommandFile(filePath));
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Keep the one-shot setup binary beside its lifecycle command.
|
||||
import { executeLocalSetupCommandFile } from './localSetup';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-setup run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
if (argv.length !== 3 || argv[0] !== 'run' || argv[1] !== '--command-file') {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_LOCAL_SETUP_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(await executeLocalSetupCommandFile(argv[2]!))}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'QL3_LOCAL_SETUP_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
type LocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
type ProjectPermission,
|
||||
ProjectPolicyEngine,
|
||||
ProjectPolicyUnavailableError,
|
||||
type SecurityAuditRecord,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
normalizeSecurityAuditRecord,
|
||||
normalizeSecurityPrincipal,
|
||||
} from './authorizationAuthority';
|
||||
import {
|
||||
LocalPluginPackagePromptAuthenticationError,
|
||||
LocalPluginPackagePromptAuthorizationError,
|
||||
type LocalPluginPackagePromptCommand,
|
||||
LocalPluginPackagePromptUnavailableError,
|
||||
} from './contracts';
|
||||
|
||||
const STRONG_USER_ASSURANCES = new Set([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
export const PROMPT_PERMISSIONS = Object.freeze([
|
||||
'run.start',
|
||||
'model.invoke',
|
||||
'secret.use',
|
||||
] as const satisfies readonly ProjectPermission[]);
|
||||
|
||||
function strongUser(
|
||||
value: Readonly<SecurityPrincipal>,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
try {
|
||||
const principal = normalizeSecurityPrincipal(value, nowMs);
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_USER_ASSURANCES.has(principal.assurance)
|
||||
) {
|
||||
throw new LocalPluginPackagePromptAuthenticationError();
|
||||
}
|
||||
return principal;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalPluginPackagePromptAuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalPluginPackagePromptAuthenticationError();
|
||||
}
|
||||
}
|
||||
|
||||
export function sameFence(
|
||||
left: NonNullable<SecurityPolicyDecision['fence']>,
|
||||
right: NonNullable<SecurityPolicyDecision['fence']>,
|
||||
): boolean {
|
||||
return (
|
||||
left.projectVersion === right.projectVersion &&
|
||||
left.bindingVersion === right.bindingVersion
|
||||
);
|
||||
}
|
||||
|
||||
export async function authorize(
|
||||
database: LocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
principalValue: Readonly<SecurityPrincipal>,
|
||||
projectId: string,
|
||||
nowMs: number,
|
||||
permissions: readonly ProjectPermission[] = PROMPT_PERMISSIONS,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
principal: Readonly<SecurityPrincipal>;
|
||||
decision: Readonly<SecurityPolicyDecision> & {
|
||||
readonly fence: NonNullable<SecurityPolicyDecision['fence']>;
|
||||
};
|
||||
}>
|
||||
> {
|
||||
const principal = strongUser(principalValue, nowMs);
|
||||
const policy = new ProjectPolicyEngine(database.projectPolicy);
|
||||
let selected: Readonly<SecurityPolicyDecision> | undefined;
|
||||
try {
|
||||
for (const permission of permissions) {
|
||||
const decision = await policy.authorize(principal, projectId, permission);
|
||||
if (
|
||||
decision.effect !== 'allow' ||
|
||||
!decision.fence ||
|
||||
decision.fence.bindingVersion === null
|
||||
) {
|
||||
throw new LocalPluginPackagePromptAuthorizationError();
|
||||
}
|
||||
if (selected?.fence && !sameFence(selected.fence, decision.fence)) {
|
||||
throw new LocalPluginPackagePromptUnavailableError();
|
||||
}
|
||||
selected = decision;
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalPluginPackagePromptAuthorizationError ||
|
||||
error instanceof LocalPluginPackagePromptUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof ProjectPolicyUnavailableError) {
|
||||
throw new LocalPluginPackagePromptUnavailableError({ cause: error });
|
||||
}
|
||||
throw new LocalPluginPackagePromptUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (!selected?.fence || selected.fence.bindingVersion === null) {
|
||||
throw new LocalPluginPackagePromptUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
principal,
|
||||
decision: Object.freeze({
|
||||
...selected,
|
||||
fence: Object.freeze({
|
||||
projectVersion: selected.fence.projectVersion,
|
||||
bindingVersion: selected.fence.bindingVersion,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function allowedAudit(
|
||||
command: Readonly<LocalPluginPackagePromptCommand>,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
decision: Readonly<SecurityPolicyDecision> & {
|
||||
readonly fence: NonNullable<SecurityPolicyDecision['fence']>;
|
||||
},
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId:
|
||||
command.operation === 'prompt.execution.inspect'
|
||||
? 'prompt.execution.read'
|
||||
: command.operation === 'prompt.execution.output.read'
|
||||
? 'prompt.execution.output.read'
|
||||
: command.operation,
|
||||
projectId: command.request.projectId,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: decision.reasons,
|
||||
fence: decision.fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
ProjectPolicyEngine,
|
||||
ProjectPolicyUnavailableError,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
export type { ProjectPermission } from '@qinglong/runtime-core/project-policy';
|
||||
export { normalizeSecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
export type {
|
||||
SecurityPolicyDecision,
|
||||
SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
export { normalizeSecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
export type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
export type { LocalSqliteOptionalFeatureRuntimeDatabase } from '@qinglong/local-sqlite/optional-feature-runtime';
|
||||
+531
@@ -0,0 +1,531 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from './codecAuthority';
|
||||
import {
|
||||
LocalPluginPackagePromptCommandConfigurationError,
|
||||
type LocalPluginPackagePromptCommand,
|
||||
type LocalPluginPackagePromptCommandOptions,
|
||||
type LocalPluginPackagePromptInspectCommandOptions,
|
||||
type LocalPluginPackagePromptOutputCommandOptions,
|
||||
type LocalPluginPackagePromptOutputIntent,
|
||||
} from './contracts';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const MAX_TIMEOUT_MS = 10 * 60_000;
|
||||
const MAX_PARAMETER_COUNT = 128;
|
||||
const MAX_PARAMETER_VALUE_BYTES = 64 * 1_024;
|
||||
const MIN_OUTPUT_RETENTION_MS = 60 * 60_000;
|
||||
const MAX_OUTPUT_RETENTION_MS = 365 * 24 * 60 * 60_000;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const RESOURCE_ID_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
required.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
operation: 'prompt.inspect' | 'prompt.execution.inspect',
|
||||
): Readonly<LocalPluginPackagePromptInspectCommandOptions>;
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
operation: 'prompt.execution.output.read',
|
||||
): Readonly<LocalPluginPackagePromptOutputCommandOptions>;
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
operation: 'prompt.execute',
|
||||
): Readonly<LocalPluginPackagePromptCommandOptions>;
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
operation:
|
||||
| 'prompt.inspect'
|
||||
| 'prompt.execution.inspect'
|
||||
| 'prompt.execution.output.read'
|
||||
| 'prompt.execute',
|
||||
): Readonly<
|
||||
| LocalPluginPackagePromptCommandOptions
|
||||
| LocalPluginPackagePromptInspectCommandOptions
|
||||
| LocalPluginPackagePromptOutputCommandOptions
|
||||
> {
|
||||
const execution = operation === 'prompt.execute';
|
||||
const outputRead = operation === 'prompt.execution.output.read';
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'credentialFilePath',
|
||||
'databasePath',
|
||||
'deploymentRoot',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
...(execution ? ['providerAuthorityFilePath', 'secretKeyringPath'] : []),
|
||||
...(outputRead ? ['promptOutputKeyringPath'] : []),
|
||||
],
|
||||
['busyTimeoutMs', ...(execution ? ['promptOutputKeyringPath'] : [])],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
const basePaths = {
|
||||
databasePath: boundedPath(value.databasePath, 'databasePath'),
|
||||
ownerPepperKeyringDirectory: boundedPath(
|
||||
value.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
),
|
||||
credentialFilePath: boundedPath(
|
||||
value.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
),
|
||||
};
|
||||
const executionPaths = execution
|
||||
? {
|
||||
secretKeyringPath: boundedPath(
|
||||
value.secretKeyringPath,
|
||||
'secretKeyringPath',
|
||||
),
|
||||
providerAuthorityFilePath: boundedPath(
|
||||
value.providerAuthorityFilePath,
|
||||
'providerAuthorityFilePath',
|
||||
),
|
||||
}
|
||||
: {};
|
||||
const outputPaths =
|
||||
value.promptOutputKeyringPath === undefined
|
||||
? {}
|
||||
: {
|
||||
promptOutputKeyringPath: boundedPath(
|
||||
value.promptOutputKeyringPath,
|
||||
'promptOutputKeyringPath',
|
||||
),
|
||||
};
|
||||
const paths = { ...basePaths, ...executionPaths, ...outputPaths };
|
||||
for (const [label, candidate] of Object.entries(paths)) {
|
||||
descendant(deploymentRoot, candidate, label);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot,
|
||||
...paths,
|
||||
profile: value.profile,
|
||||
...(value.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: value.busyTimeoutMs as number }),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeParameters(value: unknown): Readonly<Record<string, string>> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'parameters must be a plain object',
|
||||
);
|
||||
}
|
||||
const entries = Object.entries(value);
|
||||
if (
|
||||
entries.length > MAX_PARAMETER_COUNT ||
|
||||
entries.some(
|
||||
([key, candidate]) =>
|
||||
!RESOURCE_ID_PATTERN.test(key) ||
|
||||
typeof candidate !== 'string' ||
|
||||
Buffer.byteLength(candidate, 'utf8') > MAX_PARAMETER_VALUE_BYTES,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'parameters are invalid or exceed the bounded input budget',
|
||||
);
|
||||
}
|
||||
return Object.freeze(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
function normalizeOutputIntent(
|
||||
value: unknown,
|
||||
): Readonly<LocalPluginPackagePromptOutputIntent> {
|
||||
exactObject(value, ['mode'], ['retentionPolicy'], 'output');
|
||||
if (value.mode === 'live_only') {
|
||||
exactObject(value, ['mode'], [], 'output');
|
||||
return Object.freeze({ mode: 'live_only' as const });
|
||||
}
|
||||
if (value.mode !== 'durable_artifact') {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'output mode is invalid',
|
||||
);
|
||||
}
|
||||
exactObject(value, ['mode', 'retentionPolicy'], [], 'output');
|
||||
exactObject(
|
||||
value.retentionPolicy,
|
||||
['retentionMs', 'revision'],
|
||||
[],
|
||||
'output.retentionPolicy',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(value.retentionPolicy.retentionMs) ||
|
||||
(value.retentionPolicy.retentionMs as number) < MIN_OUTPUT_RETENTION_MS ||
|
||||
(value.retentionPolicy.retentionMs as number) > MAX_OUTPUT_RETENTION_MS
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'output retention is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: 'durable_artifact' as const,
|
||||
retentionPolicy: Object.freeze({
|
||||
revision: identity(
|
||||
value.retentionPolicy.revision,
|
||||
'output.retentionPolicy.revision',
|
||||
),
|
||||
retentionMs: value.retentionPolicy.retentionMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalPluginPackagePromptCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
[],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
(value.operation !== 'prompt.inspect' &&
|
||||
value.operation !== 'prompt.execution.inspect' &&
|
||||
value.operation !== 'prompt.execution.output.read' &&
|
||||
value.operation !== 'prompt.execute')
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'command version or operation is invalid',
|
||||
);
|
||||
}
|
||||
if (value.operation === 'prompt.inspect') {
|
||||
const options = normalizeOptions(value.options, 'prompt.inspect');
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
typeof value.request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(value.request.packageName) ||
|
||||
typeof value.request.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.auditEventId) ||
|
||||
typeof value.request.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.failureAuditEventId) ||
|
||||
value.request.auditEventId === value.request.failureAuditEventId
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'request value is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'prompt.inspect',
|
||||
options,
|
||||
request: Object.freeze({
|
||||
projectId: identity(value.request.projectId, 'projectId'),
|
||||
packageName: value.request.packageName,
|
||||
requestId: identity(value.request.requestId, 'requestId'),
|
||||
auditEventId: value.request.auditEventId,
|
||||
failureAuditEventId: value.request.failureAuditEventId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (value.operation === 'prompt.execution.inspect') {
|
||||
const options = normalizeOptions(value.options, 'prompt.execution.inspect');
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'executionRequestId',
|
||||
'failureAuditEventId',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'promptId',
|
||||
'requestId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
typeof value.request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(value.request.packageName) ||
|
||||
typeof value.request.promptId !== 'string' ||
|
||||
!RESOURCE_ID_PATTERN.test(value.request.promptId) ||
|
||||
typeof value.request.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.auditEventId) ||
|
||||
typeof value.request.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.failureAuditEventId) ||
|
||||
value.request.auditEventId === value.request.failureAuditEventId
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'request value is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'prompt.execution.inspect',
|
||||
options,
|
||||
request: Object.freeze({
|
||||
projectId: identity(value.request.projectId, 'projectId'),
|
||||
packageName: value.request.packageName,
|
||||
promptId: value.request.promptId,
|
||||
executionRequestId: identity(
|
||||
value.request.executionRequestId,
|
||||
'executionRequestId',
|
||||
),
|
||||
requestId: identity(value.request.requestId, 'requestId'),
|
||||
auditEventId: value.request.auditEventId,
|
||||
failureAuditEventId: value.request.failureAuditEventId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (value.operation === 'prompt.execution.output.read') {
|
||||
const options = normalizeOptions(
|
||||
value.options,
|
||||
'prompt.execution.output.read',
|
||||
);
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'executionRequestId',
|
||||
'failureAuditEventId',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'promptId',
|
||||
'requestId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
typeof value.request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(value.request.packageName) ||
|
||||
typeof value.request.promptId !== 'string' ||
|
||||
!RESOURCE_ID_PATTERN.test(value.request.promptId) ||
|
||||
typeof value.request.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.auditEventId) ||
|
||||
typeof value.request.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.failureAuditEventId) ||
|
||||
value.request.auditEventId === value.request.failureAuditEventId
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'request value is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'prompt.execution.output.read',
|
||||
options,
|
||||
request: Object.freeze({
|
||||
projectId: identity(value.request.projectId, 'projectId'),
|
||||
packageName: value.request.packageName,
|
||||
promptId: value.request.promptId,
|
||||
executionRequestId: identity(
|
||||
value.request.executionRequestId,
|
||||
'executionRequestId',
|
||||
),
|
||||
requestId: identity(value.request.requestId, 'requestId'),
|
||||
auditEventId: value.request.auditEventId,
|
||||
failureAuditEventId: value.request.failureAuditEventId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const options = normalizeOptions(value.options, 'prompt.execute');
|
||||
const hasTemperature =
|
||||
!!value.request &&
|
||||
typeof value.request === 'object' &&
|
||||
!Array.isArray(value.request) &&
|
||||
Object.hasOwn(value.request, 'temperature');
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'maxOutputTokens',
|
||||
'model',
|
||||
'output',
|
||||
'packageName',
|
||||
'parameters',
|
||||
'projectId',
|
||||
'promptId',
|
||||
'provider',
|
||||
'requestId',
|
||||
'timeoutMs',
|
||||
'traceId',
|
||||
],
|
||||
hasTemperature ? ['temperature'] : [],
|
||||
'request',
|
||||
);
|
||||
const request = value.request;
|
||||
if (
|
||||
typeof request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(request.packageName) ||
|
||||
typeof request.promptId !== 'string' ||
|
||||
!RESOURCE_ID_PATTERN.test(request.promptId) ||
|
||||
typeof request.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(request.auditEventId) ||
|
||||
typeof request.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(request.failureAuditEventId) ||
|
||||
request.auditEventId === request.failureAuditEventId ||
|
||||
!Number.isSafeInteger(request.maxOutputTokens) ||
|
||||
(request.maxOutputTokens as number) < 1 ||
|
||||
(request.maxOutputTokens as number) > 1_000_000 ||
|
||||
!Number.isSafeInteger(request.timeoutMs) ||
|
||||
(request.timeoutMs as number) < 100 ||
|
||||
(request.timeoutMs as number) > MAX_TIMEOUT_MS ||
|
||||
(request.temperature !== undefined &&
|
||||
(typeof request.temperature !== 'number' ||
|
||||
!Number.isFinite(request.temperature) ||
|
||||
request.temperature < 0 ||
|
||||
request.temperature > 2))
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'request value is invalid',
|
||||
);
|
||||
}
|
||||
const output = normalizeOutputIntent(request.output);
|
||||
if (
|
||||
output.mode === 'durable_artifact' &&
|
||||
options.promptOutputKeyringPath === undefined
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'durable output requires promptOutputKeyringPath',
|
||||
);
|
||||
}
|
||||
if (
|
||||
output.mode === 'live_only' &&
|
||||
options.promptOutputKeyringPath !== undefined
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'live-only output must not configure promptOutputKeyringPath',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'prompt.execute',
|
||||
options,
|
||||
request: Object.freeze({
|
||||
projectId: identity(request.projectId, 'projectId'),
|
||||
packageName: request.packageName,
|
||||
promptId: request.promptId,
|
||||
requestId: identity(request.requestId, 'requestId'),
|
||||
traceId: identity(request.traceId, 'traceId'),
|
||||
auditEventId: request.auditEventId,
|
||||
failureAuditEventId: request.failureAuditEventId,
|
||||
parameters: normalizeParameters(request.parameters),
|
||||
provider: identity(request.provider, 'provider'),
|
||||
model: identity(request.model, 'model'),
|
||||
maxOutputTokens: request.maxOutputTokens as number,
|
||||
...(request.temperature === undefined
|
||||
? {}
|
||||
: { temperature: request.temperature as number }),
|
||||
timeoutMs: request.timeoutMs as number,
|
||||
output,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function readCommandFile(
|
||||
candidatePath: string,
|
||||
): Readonly<LocalPluginPackagePromptCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalPluginPackagePromptCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error instanceof PrivateLocalCommandFileError ? error : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export type { PluginPackagePromptCatalogItem } from '@qinglong/ai/plugin-package-prompt-catalog';
|
||||
export type { PluginPackagePromptExecutionInspection } from '@qinglong/ai/plugin-package-prompt-execution-inspection';
|
||||
export type { PluginPackagePromptExecutionOutputReadResult } from '@qinglong/ai/plugin-package-prompt-execution-output-read';
|
||||
export type { PluginPackagePromptOutputArtifactReference } from '@qinglong/ai/plugin-package-prompt-output-artifact';
|
||||
export type { ModelGatewayProviderAuthority } from '@qinglong/ai/profile';
|
||||
export type { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export type {
|
||||
LocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
openLocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
} from '@qinglong/local-sqlite/optional-feature-runtime';
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import type {
|
||||
establishAuthenticatedLocalCommand,
|
||||
LocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
ModelGatewayProviderAuthority,
|
||||
openLocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
PluginPackagePromptCatalogItem,
|
||||
PluginPackagePromptExecutionInspection,
|
||||
PluginPackagePromptExecutionOutputReadResult,
|
||||
PluginPackagePromptOutputArtifactReference,
|
||||
} from './contractAuthority';
|
||||
|
||||
export interface LocalPluginPackagePromptInspectCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackagePromptCommandOptions
|
||||
extends LocalPluginPackagePromptInspectCommandOptions {
|
||||
readonly secretKeyringPath: string;
|
||||
readonly providerAuthorityFilePath: string;
|
||||
readonly promptOutputKeyringPath?: string;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackagePromptOutputCommandOptions
|
||||
extends LocalPluginPackagePromptInspectCommandOptions {
|
||||
readonly promptOutputKeyringPath: string;
|
||||
}
|
||||
|
||||
export type LocalPluginPackagePromptOutputIntent =
|
||||
| Readonly<{ mode: 'live_only' }>
|
||||
| Readonly<{
|
||||
mode: 'durable_artifact';
|
||||
retentionPolicy: Readonly<{
|
||||
revision: string;
|
||||
retentionMs: number;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export interface ExecuteLocalPluginPackagePromptCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'prompt.execute';
|
||||
readonly options: LocalPluginPackagePromptCommandOptions;
|
||||
readonly request: Readonly<{
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
auditEventId: string;
|
||||
failureAuditEventId: string;
|
||||
parameters: Readonly<Record<string, string>>;
|
||||
provider: string;
|
||||
model: string;
|
||||
maxOutputTokens: number;
|
||||
temperature?: number;
|
||||
timeoutMs: number;
|
||||
output: Readonly<LocalPluginPackagePromptOutputIntent>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InspectLocalPluginPackagePromptCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'prompt.inspect';
|
||||
readonly options: LocalPluginPackagePromptInspectCommandOptions;
|
||||
readonly request: Readonly<{
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
failureAuditEventId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InspectLocalPluginPackagePromptExecutionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'prompt.execution.inspect';
|
||||
readonly options: LocalPluginPackagePromptInspectCommandOptions;
|
||||
readonly request: Readonly<{
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
executionRequestId: string;
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
failureAuditEventId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ReadLocalPluginPackagePromptExecutionOutputCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'prompt.execution.output.read';
|
||||
readonly options: LocalPluginPackagePromptOutputCommandOptions;
|
||||
readonly request: Readonly<{
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
executionRequestId: string;
|
||||
requestId: string;
|
||||
auditEventId: string;
|
||||
failureAuditEventId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type LocalPluginPackagePromptCommand =
|
||||
| ExecuteLocalPluginPackagePromptCommand
|
||||
| InspectLocalPluginPackagePromptCommand
|
||||
| InspectLocalPluginPackagePromptExecutionCommand
|
||||
| ReadLocalPluginPackagePromptExecutionOutputCommand;
|
||||
|
||||
export type LocalPluginPackagePromptCommandResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'prompt.inspect';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
found: boolean;
|
||||
publicationState: 'active' | 'withdrawn' | 'absent' | null;
|
||||
prompts: readonly Readonly<PluginPackagePromptCatalogItem>[];
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'prompt.execution.inspect';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
executionRequestId: string;
|
||||
found: boolean;
|
||||
execution: Readonly<PluginPackagePromptExecutionInspection> | null;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'prompt.execution.output.read';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
executionRequestId: string;
|
||||
status: 'not_found';
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'prompt.execution.output.read';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
executionRequestId: string;
|
||||
status: 'available';
|
||||
reference: Readonly<PluginPackagePromptOutputArtifactReference>;
|
||||
result: Extract<
|
||||
PluginPackagePromptExecutionOutputReadResult,
|
||||
{
|
||||
status: 'available';
|
||||
}
|
||||
>['result'];
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'prompt.execute';
|
||||
status: 'executed' | 'resumed' | 'existing';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
promptId: string;
|
||||
requestId: string;
|
||||
invocationId: string;
|
||||
runId: string;
|
||||
stepRunId: string;
|
||||
planDigest: string;
|
||||
receiptDigest: string;
|
||||
finalizationDigest: string;
|
||||
runStatus: 'succeeded' | 'failed' | 'cancelled' | 'timed_out';
|
||||
result: Readonly<{
|
||||
provider: string;
|
||||
model: string;
|
||||
text: string;
|
||||
finishReason: string;
|
||||
usage: Readonly<{
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
costMicros?: number;
|
||||
}>;
|
||||
}> | null;
|
||||
outputArtifact?: Readonly<PluginPackagePromptOutputArtifactReference>;
|
||||
}>;
|
||||
|
||||
export interface LocalPluginPackagePromptCommandRunner {
|
||||
run(commandFilePath: string): Promise<LocalPluginPackagePromptCommandResult>;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackagePromptCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteOptionalFeatureRuntimeDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly loadProviders: (
|
||||
options: Readonly<{
|
||||
database: LocalSqliteOptionalFeatureRuntimeDatabase;
|
||||
command: Readonly<ExecuteLocalPluginPackagePromptCommand>;
|
||||
now: () => number;
|
||||
}>,
|
||||
) => Promise<ModelGatewayProviderAuthority>;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalPluginPackagePromptCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_PROMPT_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(
|
||||
`Local Plugin Package Prompt command configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'LocalPluginPackagePromptCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalPluginPackagePromptAuthenticationError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_PROMPT_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Local Plugin Package Prompt requires a current strong User');
|
||||
this.name = 'LocalPluginPackagePromptAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalPluginPackagePromptAuthorizationError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_PROMPT_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Local Plugin Package Prompt is not authorized');
|
||||
this.name = 'LocalPluginPackagePromptAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalPluginPackagePromptNotFoundError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_PROMPT_NOT_FOUND';
|
||||
|
||||
constructor() {
|
||||
super('Active Plugin Package Prompt is not available');
|
||||
this.name = 'LocalPluginPackagePromptNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalPluginPackagePromptUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_PROMPT_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Local Plugin Package Prompt is unavailable', options);
|
||||
this.name = 'LocalPluginPackagePromptUnavailableError';
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
type ActiveModelGatewayCapability,
|
||||
BoundModelProviderCredentialProvider,
|
||||
EncryptedLocalSecretService,
|
||||
LocalModelProviderCredentialRepository,
|
||||
LocalSecretKeyringFileProvider,
|
||||
type LocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
type ModelGatewayProviderAuthority,
|
||||
type PluginPackagePromptExecutionPlan,
|
||||
loadProjectedModelGatewayProviderAuthority,
|
||||
} from './supportAuthority';
|
||||
import {
|
||||
type ExecuteLocalPluginPackagePromptCommand,
|
||||
LocalPluginPackagePromptCommandConfigurationError,
|
||||
type LocalPluginPackagePromptCommandRunnerDependencies,
|
||||
LocalPluginPackagePromptUnavailableError,
|
||||
} from './contracts';
|
||||
|
||||
export async function defaultLoadProviders(
|
||||
input: Readonly<{
|
||||
database: LocalSqliteOptionalFeatureRuntimeDatabase;
|
||||
command: Readonly<ExecuteLocalPluginPackagePromptCommand>;
|
||||
now: () => number;
|
||||
}>,
|
||||
): Promise<ModelGatewayProviderAuthority> {
|
||||
const credentials = new LocalModelProviderCredentialRepository(
|
||||
input.database.authority,
|
||||
{ now: input.now },
|
||||
);
|
||||
const secrets = new EncryptedLocalSecretService(
|
||||
input.database.localSecrets,
|
||||
new LocalSecretKeyringFileProvider(input.command.options.secretKeyringPath),
|
||||
);
|
||||
return loadProjectedModelGatewayProviderAuthority({
|
||||
configFile: input.command.options.providerAuthorityFilePath,
|
||||
credentials: new BoundModelProviderCredentialProvider({
|
||||
bindings: credentials,
|
||||
audit: credentials,
|
||||
secrets,
|
||||
now: input.now,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function dependencies(
|
||||
value: LocalPluginPackagePromptCommandRunnerDependencies,
|
||||
): Readonly<LocalPluginPackagePromptCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
['authenticate', 'loadProviders', 'now', 'openDatabase']
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.loadProviders !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export async function stopGateway(
|
||||
capability: ActiveModelGatewayCapability | undefined,
|
||||
): Promise<void> {
|
||||
if (!capability) return;
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
if ((await capability.stop()) === 'stopped') return;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
throw new LocalPluginPackagePromptUnavailableError();
|
||||
}
|
||||
|
||||
export function assertReplayRequest(
|
||||
plan: Readonly<PluginPackagePromptExecutionPlan>,
|
||||
command: Readonly<ExecuteLocalPluginPackagePromptCommand>,
|
||||
): void {
|
||||
const request = command.request;
|
||||
if (
|
||||
plan.target.projectId !== request.projectId ||
|
||||
plan.target.packageName !== request.packageName ||
|
||||
plan.target.promptId !== request.promptId ||
|
||||
plan.traceId !== request.traceId ||
|
||||
plan.provider !== request.provider ||
|
||||
plan.model !== request.model ||
|
||||
plan.maxOutputTokens !== request.maxOutputTokens ||
|
||||
plan.temperature !== (request.temperature ?? null) ||
|
||||
plan.output?.mode !== request.output.mode ||
|
||||
(request.output.mode === 'durable_artifact' &&
|
||||
(plan.output?.mode !== 'durable_artifact' ||
|
||||
JSON.stringify(plan.output.retentionPolicy) !==
|
||||
JSON.stringify(request.output.retentionPolicy)))
|
||||
) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'request conflicts with the durable Prompt plan',
|
||||
);
|
||||
}
|
||||
}
|
||||
+535
@@ -0,0 +1,535 @@
|
||||
import {
|
||||
type ActiveModelGatewayCapability,
|
||||
type AuthenticatedLocalCommand,
|
||||
type AuthorizedPluginPackagePromptExecutionInspection,
|
||||
LocalModelInvocationFeatureActivationRepository,
|
||||
LocalModelInvocationRepository,
|
||||
LocalModelPriceCatalogRepository,
|
||||
LocalPluginPackagePromptAdmissionRepository,
|
||||
LocalPluginPackagePromptExecutionInspectionRepository,
|
||||
LocalPluginPackagePromptExecutionOutputReferenceRepository,
|
||||
LocalPluginPackagePromptOutputArtifactRepository,
|
||||
LocalPluginPackagePromptOutputRetentionRepository,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
PluginPackagePromptExecutionInspectionAuthorizationFenceConflictError,
|
||||
type PluginPackagePromptExecutionPlan,
|
||||
PluginPackagePromptExecutionOutputReadService,
|
||||
PluginPackagePromptExecutor,
|
||||
type PluginPackagePromptOutputArtifactReadAuthorizer,
|
||||
type PluginPackagePromptOutputCompletionCapability,
|
||||
PluginPackagePromptOutputCompletionCoordinator,
|
||||
PluginPackagePromptOutputFileKeyring,
|
||||
PluginPackagePromptOutputReadService,
|
||||
ProjectPolicyEngine,
|
||||
assertLocalModelInvocationFeatureActive,
|
||||
bootstrapModelGatewayProfile,
|
||||
commitLocalSqliteSecurityAuditInTransaction,
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence,
|
||||
confirmLocalSqliteProjectPolicyFence,
|
||||
createPluginPackagePromptCatalogResult,
|
||||
establishAuthenticatedLocalCommand,
|
||||
normalizeSecurityAuditRecord,
|
||||
openLocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
} from './runnerAuthority';
|
||||
import {
|
||||
LocalPluginPackagePromptAuthenticationError,
|
||||
LocalPluginPackagePromptAuthorizationError,
|
||||
type LocalPluginPackagePromptCommandResult,
|
||||
LocalPluginPackagePromptCommandConfigurationError,
|
||||
type LocalPluginPackagePromptCommandRunner,
|
||||
type LocalPluginPackagePromptCommandRunnerDependencies,
|
||||
LocalPluginPackagePromptNotFoundError,
|
||||
LocalPluginPackagePromptUnavailableError,
|
||||
} from './contracts';
|
||||
import { readCommandFile } from './codec';
|
||||
import {
|
||||
PROMPT_PERMISSIONS,
|
||||
allowedAudit,
|
||||
authorize,
|
||||
sameFence,
|
||||
} from './authorization';
|
||||
import {
|
||||
assertReplayRequest,
|
||||
defaultLoadProviders,
|
||||
dependencies,
|
||||
stopGateway,
|
||||
} from './executionSupport';
|
||||
|
||||
export function createLocalPluginPackagePromptCommandRunner(
|
||||
candidateDependencies: LocalPluginPackagePromptCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
loadProviders: defaultLoadProviders,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalPluginPackagePromptCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let capability: ActiveModelGatewayCapability | undefined;
|
||||
let featureReady = false;
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
let authorization: Awaited<ReturnType<typeof authorize>> | undefined;
|
||||
try {
|
||||
const activation =
|
||||
command.operation === 'prompt.execute'
|
||||
? assertLocalModelInvocationFeatureActive(database.authority.client)
|
||||
: null;
|
||||
featureReady = true;
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_plugin_package_prompt',
|
||||
});
|
||||
await authenticated.confirm();
|
||||
const nowMs = adapters.now();
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new LocalPluginPackagePromptCommandConfigurationError(
|
||||
'clock is invalid',
|
||||
);
|
||||
}
|
||||
authorization = await authorize(
|
||||
database,
|
||||
authenticated.principal,
|
||||
command.request.projectId,
|
||||
nowMs,
|
||||
command.operation === 'prompt.inspect'
|
||||
? Object.freeze(['model.invoke'] as const)
|
||||
: command.operation === 'prompt.execution.inspect'
|
||||
? Object.freeze(['run.read'] as const)
|
||||
: command.operation === 'prompt.execution.output.read'
|
||||
? Object.freeze(['artifact.read'] as const)
|
||||
: PROMPT_PERMISSIONS,
|
||||
);
|
||||
const authorized = authorization;
|
||||
const credentialFence =
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>;
|
||||
const audit = allowedAudit(
|
||||
command,
|
||||
authorized.principal,
|
||||
authorized.decision,
|
||||
nowMs,
|
||||
);
|
||||
if (command.operation === 'prompt.inspect') {
|
||||
await database.securityAudit.record(audit);
|
||||
const publication = await database.automationPublications.findCurrent(
|
||||
command.request.projectId,
|
||||
command.request.packageName,
|
||||
);
|
||||
const catalog = createPluginPackagePromptCatalogResult(
|
||||
command.request.projectId,
|
||||
command.request.packageName,
|
||||
publication,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: catalog.projectId,
|
||||
packageName: catalog.packageName,
|
||||
found: catalog.found,
|
||||
publicationState: catalog.publicationState,
|
||||
prompts: catalog.prompts,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'prompt.execution.inspect') {
|
||||
const inspections =
|
||||
new LocalPluginPackagePromptExecutionInspectionRepository(
|
||||
database.authority,
|
||||
Object.freeze({
|
||||
confirm(
|
||||
inspection: Readonly<AuthorizedPluginPackagePromptExecutionInspection>,
|
||||
auditReplay: boolean,
|
||||
) {
|
||||
try {
|
||||
if (
|
||||
inspection.actor.type !==
|
||||
authorized.principal.subject.type ||
|
||||
inspection.actor.id !== authorized.principal.subject.id ||
|
||||
inspection.projectId !== command.request.projectId ||
|
||||
!sameFence(inspection.fence, authorized.decision.fence)
|
||||
) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(
|
||||
database.authority,
|
||||
credentialFence,
|
||||
);
|
||||
confirmLocalSqliteProjectPolicyFence(
|
||||
database.authority,
|
||||
inspection.projectId,
|
||||
inspection.actor,
|
||||
inspection.fence,
|
||||
);
|
||||
commitLocalSqliteSecurityAuditInTransaction(
|
||||
database.authority,
|
||||
inspection.audit,
|
||||
auditReplay,
|
||||
);
|
||||
} catch {
|
||||
throw new PluginPackagePromptExecutionInspectionAuthorizationFenceConflictError();
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await inspections.inspectAuthorized({
|
||||
projectId: command.request.projectId,
|
||||
packageName: command.request.packageName,
|
||||
promptId: command.request.promptId,
|
||||
executionRequestId: command.request.executionRequestId,
|
||||
actor: authorized.principal.subject,
|
||||
fence: authorized.decision.fence,
|
||||
audit,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: result.projectId,
|
||||
packageName: result.packageName,
|
||||
promptId: result.promptId,
|
||||
executionRequestId: result.executionRequestId,
|
||||
found: result.found,
|
||||
execution: result.execution,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'prompt.execution.output.read') {
|
||||
await database.securityAudit.record(audit);
|
||||
const policy = new ProjectPolicyEngine(database.projectPolicy);
|
||||
const outputReader = new PluginPackagePromptOutputReadService({
|
||||
artifacts: new LocalPluginPackagePromptOutputArtifactRepository(
|
||||
database.authority,
|
||||
),
|
||||
authorizer: Object.freeze({
|
||||
async authorize(
|
||||
request: Parameters<
|
||||
PluginPackagePromptOutputArtifactReadAuthorizer['authorize']
|
||||
>[0],
|
||||
) {
|
||||
const decision = await policy.authorize(
|
||||
request.principal,
|
||||
request.projectId,
|
||||
'artifact.read',
|
||||
);
|
||||
return decision.effect === 'allow'
|
||||
? Object.freeze({ effect: 'allow' as const })
|
||||
: Object.freeze({
|
||||
effect: decision.effect,
|
||||
reasonCode: 'artifact_read_denied',
|
||||
});
|
||||
},
|
||||
}),
|
||||
retention: new LocalPluginPackagePromptOutputRetentionRepository(
|
||||
database.authority,
|
||||
),
|
||||
keys: new PluginPackagePromptOutputFileKeyring(
|
||||
command.options.promptOutputKeyringPath,
|
||||
),
|
||||
now: adapters.now,
|
||||
});
|
||||
const result =
|
||||
await new PluginPackagePromptExecutionOutputReadService({
|
||||
references:
|
||||
new LocalPluginPackagePromptExecutionOutputReferenceRepository(
|
||||
database.authority,
|
||||
),
|
||||
outputs: outputReader,
|
||||
}).read({
|
||||
principal: authorized.principal,
|
||||
projectId: command.request.projectId,
|
||||
packageName: command.request.packageName,
|
||||
promptId: command.request.promptId,
|
||||
executionRequestId: command.request.executionRequestId,
|
||||
});
|
||||
const target = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: result.projectId,
|
||||
packageName: result.packageName,
|
||||
promptId: result.promptId,
|
||||
executionRequestId: result.executionRequestId,
|
||||
});
|
||||
return result.status === 'not_found'
|
||||
? Object.freeze({ ...target, status: 'not_found' as const })
|
||||
: Object.freeze({
|
||||
...target,
|
||||
status: 'available' as const,
|
||||
reference: result.reference,
|
||||
result: result.result,
|
||||
});
|
||||
}
|
||||
const admissions = new LocalPluginPackagePromptAdmissionRepository(
|
||||
database.authority,
|
||||
Object.freeze({
|
||||
confirm(
|
||||
plan: Readonly<PluginPackagePromptExecutionPlan>,
|
||||
replay: boolean,
|
||||
) {
|
||||
if (
|
||||
plan.requestedBySubject.type !==
|
||||
authorized.principal.subject.type ||
|
||||
plan.requestedBySubject.id !==
|
||||
authorized.principal.subject.id ||
|
||||
plan.target.projectId !== command.request.projectId ||
|
||||
!sameFence(plan.policyFence, authorized.decision.fence)
|
||||
) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(
|
||||
database.authority,
|
||||
credentialFence,
|
||||
);
|
||||
confirmLocalSqliteProjectPolicyFence(
|
||||
database.authority,
|
||||
plan.target.projectId,
|
||||
plan.requestedBySubject,
|
||||
plan.policyFence,
|
||||
);
|
||||
commitLocalSqliteSecurityAuditInTransaction(
|
||||
database.authority,
|
||||
audit,
|
||||
replay,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const existing = await admissions.findPlanByRequestId(
|
||||
command.request.requestId,
|
||||
);
|
||||
if (existing) assertReplayRequest(existing, command);
|
||||
const publication = existing
|
||||
? await database.automationPublications.findByDigest(
|
||||
existing.target.publicationDigest,
|
||||
)
|
||||
: await database.automationPublications.findCurrent(
|
||||
command.request.projectId,
|
||||
command.request.packageName,
|
||||
);
|
||||
if (
|
||||
!publication ||
|
||||
publication.state !== 'active' ||
|
||||
publication.target.projectId !== command.request.projectId ||
|
||||
publication.target.packageName !== command.request.packageName ||
|
||||
!publication.definitions.prompts.some(
|
||||
({ id }) => id === command.request.promptId,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePromptNotFoundError();
|
||||
}
|
||||
if (
|
||||
existing &&
|
||||
(!sameFence(existing.policyFence, authorization.decision.fence) ||
|
||||
existing.requestedBySubject.type !==
|
||||
authorization.principal.subject.type ||
|
||||
existing.requestedBySubject.id !==
|
||||
authorization.principal.subject.id)
|
||||
) {
|
||||
throw new LocalPluginPackagePromptAuthorizationError();
|
||||
}
|
||||
const repository = new LocalModelInvocationRepository(
|
||||
database.authority,
|
||||
);
|
||||
const pricing = new LocalModelPriceCatalogRepository(
|
||||
database.authority,
|
||||
);
|
||||
let durableOutput:
|
||||
| PluginPackagePromptOutputCompletionCapability
|
||||
| undefined;
|
||||
const outputKeys =
|
||||
command.request.output.mode === 'durable_artifact'
|
||||
? new PluginPackagePromptOutputFileKeyring(
|
||||
command.options.promptOutputKeyringPath!,
|
||||
)
|
||||
: undefined;
|
||||
const gateway = await bootstrapModelGatewayProfile({
|
||||
enabled: true,
|
||||
profile: command.options.profile,
|
||||
loadStorage: async () =>
|
||||
Object.freeze({
|
||||
repository,
|
||||
pricing,
|
||||
close: async () => undefined,
|
||||
}),
|
||||
loadProviders: () =>
|
||||
adapters.loadProviders({
|
||||
database,
|
||||
command,
|
||||
now: adapters.now,
|
||||
}),
|
||||
...(outputKeys === undefined
|
||||
? {}
|
||||
: {
|
||||
createSuccessfulCompletion: (coordinator) => {
|
||||
durableOutput =
|
||||
new PluginPackagePromptOutputCompletionCoordinator({
|
||||
coordinator,
|
||||
keys: outputKeys,
|
||||
now: adapters.now,
|
||||
});
|
||||
return durableOutput;
|
||||
},
|
||||
}),
|
||||
confirmActive: async () => {
|
||||
await database.authority.enqueue(
|
||||
async () => {
|
||||
const current =
|
||||
new LocalModelInvocationFeatureActivationRepository(
|
||||
database.authority.client,
|
||||
).findCurrent();
|
||||
if (
|
||||
activation === null ||
|
||||
current?.state !== 'active' ||
|
||||
current.generation !== activation.generation ||
|
||||
current.transitionDigest !== activation.transitionDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePromptUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalPluginPackagePromptUnavailableError(),
|
||||
);
|
||||
},
|
||||
audit: async () => undefined,
|
||||
maxConcurrent: 1,
|
||||
recoveryLimit: command.options.profile === 'edge' ? 4 : 16,
|
||||
now: adapters.now,
|
||||
});
|
||||
if (gateway.status !== 'active') {
|
||||
throw new LocalPluginPackagePromptUnavailableError();
|
||||
}
|
||||
capability = gateway.capability;
|
||||
const executor = new PluginPackagePromptExecutor({
|
||||
admissions,
|
||||
invocations: repository,
|
||||
gateway: capability,
|
||||
...(durableOutput === undefined ? {} : { durableOutput }),
|
||||
});
|
||||
const plannedAtMs = existing?.plannedAtMs ?? nowMs;
|
||||
const deadlineAtMs =
|
||||
existing?.deadlineAtMs ?? plannedAtMs + command.request.timeoutMs;
|
||||
const executed = await executor.execute({
|
||||
publication,
|
||||
expectedPublicationDigest: publication.publicationDigest,
|
||||
promptId: command.request.promptId,
|
||||
requestId: command.request.requestId,
|
||||
traceId: command.request.traceId,
|
||||
requestedBySubject: authorization.principal.subject,
|
||||
policyFence: authorization.decision.fence as Readonly<{
|
||||
projectVersion: number;
|
||||
bindingVersion: number;
|
||||
}>,
|
||||
parameters: command.request.parameters,
|
||||
provider: command.request.provider,
|
||||
model: command.request.model,
|
||||
maxOutputTokens: command.request.maxOutputTokens,
|
||||
...(command.request.temperature === undefined
|
||||
? {}
|
||||
: { temperature: command.request.temperature }),
|
||||
plannedAtMs,
|
||||
deadlineAtMs,
|
||||
output: command.request.output,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: executed.status,
|
||||
projectId: command.request.projectId,
|
||||
packageName: command.request.packageName,
|
||||
promptId: command.request.promptId,
|
||||
requestId: executed.admission.requestId,
|
||||
invocationId: executed.admission.invocationId,
|
||||
runId: executed.admission.runId,
|
||||
stepRunId: executed.admission.stepRunId,
|
||||
planDigest: executed.admission.planDigest,
|
||||
receiptDigest: executed.admission.receiptDigest,
|
||||
finalizationDigest: executed.finalization.receiptDigest,
|
||||
runStatus: executed.finalization.runStatus,
|
||||
result: executed.result,
|
||||
...(executed.outputArtifact === undefined
|
||||
? {}
|
||||
: { outputArtifact: executed.outputArtifact }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (featureReady) {
|
||||
const occurredAtMs = adapters.now();
|
||||
if (!Number.isSafeInteger(occurredAtMs) || occurredAtMs < 0) {
|
||||
throw new LocalPluginPackagePromptUnavailableError();
|
||||
}
|
||||
const outcome = !authenticated
|
||||
? ('authentication_rejected' as const)
|
||||
: error instanceof LocalPluginPackagePromptAuthorizationError
|
||||
? ('denied' as const)
|
||||
: ('authorization_unavailable' as const);
|
||||
try {
|
||||
await database.securityAudit.record(
|
||||
normalizeSecurityAuditRecord({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: command.operation,
|
||||
projectId: command.request.projectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId:
|
||||
authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: [
|
||||
!authenticated
|
||||
? 'strong_authentication_required'
|
||||
: outcome === 'denied'
|
||||
? 'permission_missing'
|
||||
: command.operation === 'prompt.inspect'
|
||||
? 'prompt_catalog_unavailable'
|
||||
: command.operation === 'prompt.execution.inspect'
|
||||
? 'prompt_execution_inspection_unavailable'
|
||||
: command.operation === 'prompt.execution.output.read'
|
||||
? 'prompt_execution_output_read_unavailable'
|
||||
: 'prompt_execution_unavailable',
|
||||
],
|
||||
fence: authorization?.decision.fence ?? null,
|
||||
occurredAtMs,
|
||||
}),
|
||||
);
|
||||
} catch (auditError) {
|
||||
throw new LocalPluginPackagePromptUnavailableError({
|
||||
cause: auditError instanceof Error ? auditError : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
error instanceof LocalPluginPackagePromptCommandConfigurationError ||
|
||||
error instanceof LocalPluginPackagePromptAuthenticationError ||
|
||||
error instanceof LocalPluginPackagePromptAuthorizationError ||
|
||||
error instanceof LocalPluginPackagePromptNotFoundError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalPluginPackagePromptUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
} finally {
|
||||
let stopError: unknown;
|
||||
try {
|
||||
await stopGateway(capability);
|
||||
} catch (error) {
|
||||
stopError = error;
|
||||
}
|
||||
await database.close();
|
||||
if (stopError) throw stopError;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalPluginPackagePromptCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<LocalPluginPackagePromptCommandResult> {
|
||||
return createLocalPluginPackagePromptCommandRunner().run(commandFilePath);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
export {
|
||||
LocalModelInvocationFeatureActivationRepository,
|
||||
assertLocalModelInvocationFeatureActive,
|
||||
} from '@qinglong/ai/local-feature-activation';
|
||||
export { LocalModelInvocationRepository } from '@qinglong/ai/local-model-invocation-storage';
|
||||
export { LocalModelPriceCatalogRepository } from '@qinglong/ai/local-price-catalog-storage';
|
||||
export { LocalPluginPackagePromptAdmissionRepository } from '@qinglong/ai/local-plugin-package-prompt-admission-storage';
|
||||
export { LocalPluginPackagePromptExecutionInspectionRepository } from '@qinglong/ai/local-plugin-package-prompt-execution-inspection';
|
||||
export { LocalPluginPackagePromptExecutionOutputReferenceRepository } from '@qinglong/ai/local-plugin-package-prompt-execution-output-reference-storage';
|
||||
export { LocalPluginPackagePromptOutputArtifactRepository } from '@qinglong/ai/local-plugin-package-prompt-output-artifact-storage';
|
||||
export { LocalPluginPackagePromptOutputRetentionRepository } from '@qinglong/ai/local-plugin-package-prompt-output-retention-storage';
|
||||
export { PluginPackagePromptExecutor } from '@qinglong/ai/plugin-package-prompt-executor';
|
||||
export { createPluginPackagePromptCatalogResult } from '@qinglong/ai/plugin-package-prompt-catalog';
|
||||
export { PluginPackagePromptExecutionInspectionAuthorizationFenceConflictError } from '@qinglong/ai/plugin-package-prompt-execution-inspection';
|
||||
export type { AuthorizedPluginPackagePromptExecutionInspection } from '@qinglong/ai/plugin-package-prompt-execution-inspection';
|
||||
export { PluginPackagePromptOutputCompletionCoordinator } from '@qinglong/ai/plugin-package-prompt-output-completion';
|
||||
export type { PluginPackagePromptOutputCompletionCapability } from '@qinglong/ai/plugin-package-prompt-output-completion';
|
||||
export { PluginPackagePromptOutputFileKeyring } from '@qinglong/ai/plugin-package-prompt-output-file-keyring';
|
||||
export { PluginPackagePromptOutputReadService } from '@qinglong/ai/plugin-package-prompt-output-read';
|
||||
export { PluginPackagePromptExecutionOutputReadService } from '@qinglong/ai/plugin-package-prompt-execution-output-read';
|
||||
export type { PluginPackagePromptExecutionPlan } from '@qinglong/ai/plugin-package-prompt-execution';
|
||||
export type { PluginPackagePromptOutputArtifactReadAuthorizer } from '@qinglong/ai/plugin-package-prompt-output-artifact';
|
||||
export { bootstrapModelGatewayProfile } from '@qinglong/ai/profile';
|
||||
export type { ActiveModelGatewayCapability } from '@qinglong/ai/profile';
|
||||
export { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export type { AuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export {
|
||||
commitLocalSqliteSecurityAuditInTransaction,
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence,
|
||||
confirmLocalSqliteProjectPolicyFence,
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
openLocalSqliteOptionalFeatureRuntimeDatabase,
|
||||
} from '@qinglong/local-sqlite/optional-feature-runtime';
|
||||
export type { LocalSqliteAuthenticatedUserCredentialFence } from '@qinglong/local-sqlite/optional-feature-runtime';
|
||||
export { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
export { normalizeSecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export { LocalModelProviderCredentialRepository } from '@qinglong/ai/local-model-provider-credential-storage';
|
||||
export type { PluginPackagePromptExecutionPlan } from '@qinglong/ai/plugin-package-prompt-execution';
|
||||
export { BoundModelProviderCredentialProvider } from '@qinglong/ai/provider-credential';
|
||||
export { loadProjectedModelGatewayProviderAuthority } from '@qinglong/ai/projected-model-gateway-authority';
|
||||
export type {
|
||||
ActiveModelGatewayCapability,
|
||||
ModelGatewayProviderAuthority,
|
||||
} from '@qinglong/ai/profile';
|
||||
export {
|
||||
EncryptedLocalSecretService,
|
||||
LocalSecretKeyringFileProvider,
|
||||
} from '@qinglong/local-secret';
|
||||
export type { LocalSqliteOptionalFeatureRuntimeDatabase } from '@qinglong/local-sqlite/optional-feature-runtime';
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from './codecAuthority';
|
||||
import {
|
||||
type CancelLocalPluginPackageWorkflowCommand,
|
||||
type InspectLocalPluginPackageWorkflowRunCommand,
|
||||
type ListLocalPluginPackageWorkflowRunEventsCommand,
|
||||
type ListLocalPluginPackageWorkflowRunsCommand,
|
||||
type ListLocalPluginPackageWorkflowStepRunsCommand,
|
||||
type LocalPluginPackageWorkflowCommand,
|
||||
LocalPluginPackageWorkflowCommandConfigurationError,
|
||||
type LocalPluginPackageWorkflowCommandOptions,
|
||||
type LocalPluginPackageWorkflowCommandRequestBase,
|
||||
type StartLocalPluginPackageWorkflowCommand,
|
||||
} from './contracts';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
required.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalPluginPackageWorkflowCommandOptions> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'credentialFilePath',
|
||||
'databasePath',
|
||||
'deploymentRoot',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
],
|
||||
['busyTimeoutMs'],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
const databasePath = boundedPath(value.databasePath, 'databasePath');
|
||||
const ownerPepperKeyringDirectory = boundedPath(
|
||||
value.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const credentialFilePath = boundedPath(
|
||||
value.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
);
|
||||
descendant(deploymentRoot, databasePath, 'databasePath');
|
||||
descendant(
|
||||
deploymentRoot,
|
||||
ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
descendant(deploymentRoot, credentialFilePath, 'credentialFilePath');
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
profile: value.profile,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
...(value.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: value.busyTimeoutMs as number }),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizedRequestBase(
|
||||
value: Record<string, unknown>,
|
||||
): LocalPluginPackageWorkflowCommandRequestBase {
|
||||
for (const key of ['projectId', 'packageName', 'requestId'] as const) {
|
||||
if (typeof value[key] !== 'string') {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
`${key} is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of ['auditEventId', 'failureAuditEventId'] as const) {
|
||||
if (typeof value[key] !== 'string' || !UUID_V4_PATTERN.test(value[key])) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
`${key} must be a UUID v4`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (value.auditEventId === value.failureAuditEventId) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'audit identities must be distinct',
|
||||
);
|
||||
}
|
||||
return value as unknown as LocalPluginPackageWorkflowCommandRequestBase;
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalPluginPackageWorkflowCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
[],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
(value.operation !== 'workflow.inspect' &&
|
||||
value.operation !== 'workflow.run.inspect' &&
|
||||
value.operation !== 'workflow.run.list' &&
|
||||
value.operation !== 'workflow.step.list' &&
|
||||
value.operation !== 'workflow.event.list' &&
|
||||
value.operation !== 'workflow.start' &&
|
||||
value.operation !== 'workflow.cancel')
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'command version or operation is invalid',
|
||||
);
|
||||
}
|
||||
const options = normalizeOptions(value.options);
|
||||
if (value.operation === 'workflow.inspect') {
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options,
|
||||
request: normalizedRequestBase(value.request),
|
||||
});
|
||||
}
|
||||
if (value.operation === 'workflow.run.inspect') {
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'workflowId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options,
|
||||
request: normalizedRequestBase(
|
||||
value.request,
|
||||
) as InspectLocalPluginPackageWorkflowRunCommand['request'],
|
||||
});
|
||||
}
|
||||
if (value.operation === 'workflow.run.list') {
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'after',
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'limit',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'workflowId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(value.request.limit) ||
|
||||
(value.request.limit as number) < 1 ||
|
||||
(value.request.limit as number) > 64
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'run list limit is invalid',
|
||||
);
|
||||
}
|
||||
if (value.request.after !== null) {
|
||||
exactObject(
|
||||
value.request.after,
|
||||
['admittedAtMs', 'runId'],
|
||||
[],
|
||||
'request.after',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(value.request.after.admittedAtMs) ||
|
||||
(value.request.after.admittedAtMs as number) < 0 ||
|
||||
typeof value.request.after.runId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.request.after.runId)
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'run list cursor is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options,
|
||||
request: normalizedRequestBase(
|
||||
value.request,
|
||||
) as ListLocalPluginPackageWorkflowRunsCommand['request'],
|
||||
});
|
||||
}
|
||||
if (value.operation === 'workflow.step.list') {
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'after',
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'limit',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'workflowId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(value.request.limit) ||
|
||||
(value.request.limit as number) < 1 ||
|
||||
(value.request.limit as number) > 64
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'StepRun list limit is invalid',
|
||||
);
|
||||
}
|
||||
if (value.request.after !== null) {
|
||||
exactObject(value.request.after, ['id', 'stepKey'], [], 'request.after');
|
||||
if (
|
||||
typeof value.request.after.id !== 'string' ||
|
||||
typeof value.request.after.stepKey !== 'string'
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'StepRun list cursor is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options,
|
||||
request: normalizedRequestBase(
|
||||
value.request,
|
||||
) as ListLocalPluginPackageWorkflowStepRunsCommand['request'],
|
||||
});
|
||||
}
|
||||
if (value.operation === 'workflow.event.list') {
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'afterSequence',
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'limit',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'workflowId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(value.request.limit) ||
|
||||
(value.request.limit as number) < 1 ||
|
||||
(value.request.limit as number) > 64 ||
|
||||
!Number.isSafeInteger(value.request.afterSequence) ||
|
||||
(value.request.afterSequence as number) < 0
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'RunEvent list page is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options,
|
||||
request: normalizedRequestBase(
|
||||
value.request,
|
||||
) as ListLocalPluginPackageWorkflowRunEventsCommand['request'],
|
||||
});
|
||||
}
|
||||
if (value.operation === 'workflow.cancel') {
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'mutationId',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runEventId',
|
||||
'runId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options,
|
||||
request: normalizedRequestBase(
|
||||
value.request,
|
||||
) as CancelLocalPluginPackageWorkflowCommand['request'],
|
||||
});
|
||||
}
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'packageName',
|
||||
'planId',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'stepRunIds',
|
||||
'workflowId',
|
||||
],
|
||||
[],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options,
|
||||
request: normalizedRequestBase(
|
||||
value.request,
|
||||
) as StartLocalPluginPackageWorkflowCommand['request'],
|
||||
});
|
||||
}
|
||||
|
||||
export function readCommandFile(
|
||||
candidatePath: string,
|
||||
): Readonly<LocalPluginPackageWorkflowCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalPluginPackageWorkflowCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
PluginPackageWorkflowRunEventListResult,
|
||||
PluginPackageWorkflowRunInspectionResult,
|
||||
PluginPackageWorkflowRunListResult,
|
||||
PluginPackageWorkflowStepRunListResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-administration';
|
||||
export type { createLocalPluginPackageWorkflowAdministrationService } from '@qinglong/local-admin/plugin-package-workflow-administration';
|
||||
export type { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export type { openLocalSqlitePluginPackageWorkflowAdministrationDatabase } from '@qinglong/local-sqlite/plugin-package-workflow-administration';
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import type {
|
||||
createLocalPluginPackageWorkflowAdministrationService,
|
||||
establishAuthenticatedLocalCommand,
|
||||
openLocalSqlitePluginPackageWorkflowAdministrationDatabase,
|
||||
PluginPackageWorkflowRunEventListResult,
|
||||
PluginPackageWorkflowRunInspectionResult,
|
||||
PluginPackageWorkflowRunListResult,
|
||||
PluginPackageWorkflowStepRunListResult,
|
||||
} from './contractAuthority';
|
||||
|
||||
export interface LocalPluginPackageWorkflowCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackageWorkflowCommandRequestBase {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export interface InspectLocalPluginPackageWorkflowCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'workflow.inspect';
|
||||
readonly options: LocalPluginPackageWorkflowCommandOptions;
|
||||
readonly request: LocalPluginPackageWorkflowCommandRequestBase;
|
||||
}
|
||||
|
||||
export interface StartLocalPluginPackageWorkflowCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'workflow.start';
|
||||
readonly options: LocalPluginPackageWorkflowCommandOptions;
|
||||
readonly request: LocalPluginPackageWorkflowCommandRequestBase & {
|
||||
readonly workflowId: string;
|
||||
readonly planId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunIds: Readonly<Record<string, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CancelLocalPluginPackageWorkflowCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'workflow.cancel';
|
||||
readonly options: LocalPluginPackageWorkflowCommandOptions;
|
||||
readonly request: LocalPluginPackageWorkflowCommandRequestBase & {
|
||||
readonly runId: string;
|
||||
readonly mutationId: string;
|
||||
readonly runEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InspectLocalPluginPackageWorkflowRunCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'workflow.run.inspect';
|
||||
readonly options: LocalPluginPackageWorkflowCommandOptions;
|
||||
readonly request: LocalPluginPackageWorkflowCommandRequestBase & {
|
||||
readonly workflowId: string;
|
||||
readonly runId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListLocalPluginPackageWorkflowRunsCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'workflow.run.list';
|
||||
readonly options: LocalPluginPackageWorkflowCommandOptions;
|
||||
readonly request: LocalPluginPackageWorkflowCommandRequestBase & {
|
||||
readonly workflowId: string;
|
||||
readonly limit: number;
|
||||
readonly after: Readonly<{ admittedAtMs: number; runId: string }> | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListLocalPluginPackageWorkflowStepRunsCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'workflow.step.list';
|
||||
readonly options: LocalPluginPackageWorkflowCommandOptions;
|
||||
readonly request: LocalPluginPackageWorkflowCommandRequestBase & {
|
||||
readonly workflowId: string;
|
||||
readonly runId: string;
|
||||
readonly limit: number;
|
||||
readonly after: Readonly<{ stepKey: string; id: string }> | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListLocalPluginPackageWorkflowRunEventsCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'workflow.event.list';
|
||||
readonly options: LocalPluginPackageWorkflowCommandOptions;
|
||||
readonly request: LocalPluginPackageWorkflowCommandRequestBase & {
|
||||
readonly workflowId: string;
|
||||
readonly runId: string;
|
||||
readonly limit: number;
|
||||
readonly afterSequence: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalPluginPackageWorkflowCommand =
|
||||
| InspectLocalPluginPackageWorkflowCommand
|
||||
| InspectLocalPluginPackageWorkflowRunCommand
|
||||
| ListLocalPluginPackageWorkflowRunsCommand
|
||||
| ListLocalPluginPackageWorkflowStepRunsCommand
|
||||
| ListLocalPluginPackageWorkflowRunEventsCommand
|
||||
| StartLocalPluginPackageWorkflowCommand
|
||||
| CancelLocalPluginPackageWorkflowCommand;
|
||||
|
||||
export type LocalPluginPackageWorkflowCommandResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'workflow.inspect';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
found: boolean;
|
||||
publicationState: 'active' | 'withdrawn' | 'absent' | null;
|
||||
workflows: readonly Readonly<{
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
steps: readonly Readonly<{
|
||||
id: string;
|
||||
task: string;
|
||||
needs: readonly string[];
|
||||
}>[];
|
||||
}>[];
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'workflow.run.inspect';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
found: boolean;
|
||||
run: PluginPackageWorkflowRunInspectionResult['run'];
|
||||
stepCount: number | null;
|
||||
stepStatusCounts: PluginPackageWorkflowRunInspectionResult['stepStatusCounts'];
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'workflow.run.list';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
workflowId: string;
|
||||
after: PluginPackageWorkflowRunListResult['after'];
|
||||
runs: PluginPackageWorkflowRunListResult['runs'];
|
||||
truncated: boolean;
|
||||
next: PluginPackageWorkflowRunListResult['next'];
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'workflow.step.list';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
found: boolean;
|
||||
stepRuns: PluginPackageWorkflowStepRunListResult['stepRuns'];
|
||||
truncated: boolean;
|
||||
next: PluginPackageWorkflowStepRunListResult['next'];
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'workflow.event.list';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
found: boolean;
|
||||
afterSequence: number;
|
||||
headSequence: number | null;
|
||||
events: PluginPackageWorkflowRunEventListResult['events'];
|
||||
truncated: boolean;
|
||||
nextAfterSequence: number | null;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'workflow.start';
|
||||
status: 'created' | 'existing';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
stepCount: number;
|
||||
admittedAtMs: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'workflow.cancel';
|
||||
status:
|
||||
| 'accepted'
|
||||
| 'existing'
|
||||
| 'already_requested'
|
||||
| 'already_terminal';
|
||||
projectId: string;
|
||||
packageName: string;
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
runStatus: string;
|
||||
runVersion: number;
|
||||
eventSequence: number;
|
||||
cancelRequestedAtMs?: number;
|
||||
cancelReason?: string;
|
||||
}>;
|
||||
|
||||
export interface LocalPluginPackageWorkflowCommandRunner {
|
||||
run(
|
||||
commandFilePath: string,
|
||||
): Promise<LocalPluginPackageWorkflowCommandResult>;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackageWorkflowCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqlitePluginPackageWorkflowAdministrationDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly createService: typeof createLocalPluginPackageWorkflowAdministrationService;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalPluginPackageWorkflowCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_WORKFLOW_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(
|
||||
`Local Plugin Package Workflow command configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'LocalPluginPackageWorkflowCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
type AuthenticatedLocalCommand,
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
LocalPluginPackageWorkflowAdministrationAuthenticationError,
|
||||
LocalPluginPackageWorkflowAdministrationAuthorizationError,
|
||||
LocalPluginPackageWorkflowAdministrationConfigurationError,
|
||||
LocalPluginPackageWorkflowAdministrationNotFoundError,
|
||||
LocalPluginPackageWorkflowAdministrationUnavailableError,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type LocalSqlitePluginPackageWorkflowAdministrationDatabase,
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError,
|
||||
PluginPackageWorkflowAdministrationMutationConflictError,
|
||||
PluginPackageWorkflowAdmissionConflictError,
|
||||
PluginPackageWorkflowAdmissionNotAllowedError,
|
||||
type SecurityAuditRecord,
|
||||
} from './supportAuthority';
|
||||
import {
|
||||
type LocalPluginPackageWorkflowCommand,
|
||||
LocalPluginPackageWorkflowCommandConfigurationError,
|
||||
type LocalPluginPackageWorkflowCommandRunnerDependencies,
|
||||
} from './contracts';
|
||||
|
||||
export function failureAudit(
|
||||
command: Readonly<LocalPluginPackageWorkflowCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
if (
|
||||
error instanceof
|
||||
LocalPluginPackageWorkflowAdministrationAuthenticationError ||
|
||||
error instanceof
|
||||
LocalPluginPackageWorkflowAdministrationAuthorizationError ||
|
||||
error instanceof LocalPluginPackageWorkflowAdministrationUnavailableError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let outcome: SecurityAuditRecord['outcome'];
|
||||
let reason: string;
|
||||
if (
|
||||
!authenticated ||
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError
|
||||
) {
|
||||
outcome = 'authentication_rejected';
|
||||
reason = 'credential_rejected';
|
||||
} else if (
|
||||
error instanceof LocalSqliteAuthenticatedManagementFenceError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_or_policy_fence_rejected';
|
||||
} else if (
|
||||
error instanceof PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'workflow_admission_conflict';
|
||||
} else if (
|
||||
error instanceof LocalPluginPackageWorkflowAdministrationNotFoundError ||
|
||||
error instanceof
|
||||
LocalPluginPackageWorkflowAdministrationConfigurationError ||
|
||||
error instanceof LocalPluginPackageWorkflowCommandConfigurationError ||
|
||||
error instanceof InvalidPluginPackageWorkflowExecutionPlanError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionNotAllowedError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'workflow_admission_rejected';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId:
|
||||
command.operation === 'workflow.start'
|
||||
? 'workflow.start'
|
||||
: command.operation === 'workflow.cancel'
|
||||
? 'workflow.cancel'
|
||||
: command.operation === 'workflow.step.list'
|
||||
? 'workflow.step.list'
|
||||
: command.operation === 'workflow.event.list'
|
||||
? 'workflow.event.list'
|
||||
: command.operation === 'workflow.run.list'
|
||||
? 'workflow.run.list'
|
||||
: command.operation === 'workflow.run.inspect'
|
||||
? 'workflow.run.read'
|
||||
: 'workflow.read',
|
||||
projectId: command.request.projectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function dependencies(
|
||||
value: LocalPluginPackageWorkflowCommandRunnerDependencies,
|
||||
): Readonly<LocalPluginPackageWorkflowCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
['authenticate', 'createService', 'now', 'openDatabase']
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.createService !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalPluginPackageWorkflowCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export async function activateFence(
|
||||
database: LocalSqlitePluginPackageWorkflowAdministrationDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<void> {
|
||||
await authenticated.confirm();
|
||||
database.activateUserCredentialFence(
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
);
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
type AuthenticatedLocalCommand,
|
||||
type LocalPluginPackageWorkflowAdministrationService,
|
||||
createLocalPluginPackageWorkflowAdministrationService,
|
||||
establishAuthenticatedLocalCommand,
|
||||
openLocalSqlitePluginPackageWorkflowAdministrationDatabase,
|
||||
} from './runnerAuthority';
|
||||
import {
|
||||
type LocalPluginPackageWorkflowCommandResult,
|
||||
type LocalPluginPackageWorkflowCommandRunner,
|
||||
type LocalPluginPackageWorkflowCommandRunnerDependencies,
|
||||
} from './contracts';
|
||||
import { readCommandFile } from './codec';
|
||||
import { activateFence, dependencies, failureAudit } from './executionSupport';
|
||||
|
||||
export function createLocalPluginPackageWorkflowCommandRunner(
|
||||
candidateDependencies: LocalPluginPackageWorkflowCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqlitePluginPackageWorkflowAdministrationDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
createService: createLocalPluginPackageWorkflowAdministrationService,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalPluginPackageWorkflowCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_plugin_package_workflow',
|
||||
});
|
||||
await activateFence(database, authenticated);
|
||||
const service: LocalPluginPackageWorkflowAdministrationService =
|
||||
adapters.createService(
|
||||
database.projectPolicy,
|
||||
database.automationPublications,
|
||||
database.materializedRevisions,
|
||||
database.workflowAdministration,
|
||||
database.securityAudit,
|
||||
{ now: adapters.now },
|
||||
);
|
||||
if (command.operation === 'workflow.inspect') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const result = await service.inspect({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: command.request.projectId,
|
||||
packageName: command.request.packageName,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'workflow.cancel') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const result = await service.cancel({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'workflow.run.inspect') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const result = await service.inspectRun({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: result.projectId,
|
||||
packageName: result.packageName,
|
||||
workflowId: result.workflowId,
|
||||
runId: result.runId,
|
||||
found: result.found,
|
||||
run: result.run,
|
||||
stepCount: result.stepCount,
|
||||
stepStatusCounts: result.stepStatusCounts,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'workflow.run.list') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const result = await service.listRuns({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: result.projectId,
|
||||
packageName: result.packageName,
|
||||
workflowId: result.workflowId,
|
||||
after: result.after,
|
||||
runs: result.runs,
|
||||
truncated: result.truncated,
|
||||
next: result.next,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'workflow.step.list') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const result = await service.listStepRuns({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: result.projectId,
|
||||
packageName: result.packageName,
|
||||
workflowId: result.workflowId,
|
||||
runId: result.runId,
|
||||
found: result.found,
|
||||
stepRuns: result.stepRuns,
|
||||
truncated: result.truncated,
|
||||
next: result.next,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'workflow.event.list') {
|
||||
const { failureAuditEventId: _failure, ...request } =
|
||||
command.request;
|
||||
const result = await service.listRunEvents({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
projectId: result.projectId,
|
||||
packageName: result.packageName,
|
||||
workflowId: result.workflowId,
|
||||
runId: result.runId,
|
||||
found: result.found,
|
||||
afterSequence: result.afterSequence,
|
||||
headSequence: result.headSequence,
|
||||
events: result.events,
|
||||
truncated: result.truncated,
|
||||
nextAfterSequence: result.nextAfterSequence,
|
||||
});
|
||||
}
|
||||
const { failureAuditEventId: _failure, ...request } = command.request;
|
||||
const result = await service.start({
|
||||
...request,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
projectId: result.plan.target.projectId,
|
||||
packageName: result.plan.target.packageName,
|
||||
workflowId: result.plan.target.workflowId,
|
||||
runId: result.plan.runId,
|
||||
stepCount: result.plan.steps.length,
|
||||
admittedAtMs: result.receipt.admittedAtMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
adapters.now(),
|
||||
);
|
||||
if (audit) await database.securityAudit.record(audit);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalPluginPackageWorkflowCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<LocalPluginPackageWorkflowCommandResult> {
|
||||
return createLocalPluginPackageWorkflowCommandRunner().run(commandFilePath);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export { createLocalPluginPackageWorkflowAdministrationService } from '@qinglong/local-admin/plugin-package-workflow-administration';
|
||||
export type { LocalPluginPackageWorkflowAdministrationService } from '@qinglong/local-admin/plugin-package-workflow-administration';
|
||||
export { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export type { AuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export { openLocalSqlitePluginPackageWorkflowAdministrationDatabase } from '@qinglong/local-sqlite/plugin-package-workflow-administration';
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
LocalPluginPackageWorkflowAdministrationAuthenticationError,
|
||||
LocalPluginPackageWorkflowAdministrationAuthorizationError,
|
||||
LocalPluginPackageWorkflowAdministrationConfigurationError,
|
||||
LocalPluginPackageWorkflowAdministrationNotFoundError,
|
||||
LocalPluginPackageWorkflowAdministrationUnavailableError,
|
||||
} from '@qinglong/local-admin/plugin-package-workflow-administration';
|
||||
export { AuthenticatedLocalCommandAuthenticationError } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export type { AuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export { LocalSqliteAuthenticatedManagementFenceError } from '@qinglong/local-sqlite/authenticated-management';
|
||||
export type { LocalSqliteAuthenticatedUserCredentialFence } from '@qinglong/local-sqlite/authenticated-management';
|
||||
export type { LocalSqlitePluginPackageWorkflowAdministrationDatabase } from '@qinglong/local-sqlite/plugin-package-workflow-administration';
|
||||
export {
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError,
|
||||
PluginPackageWorkflowAdministrationMutationConflictError,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-administration';
|
||||
export {
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
PluginPackageWorkflowAdmissionConflictError,
|
||||
PluginPackageWorkflowAdmissionNotAllowedError,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
export type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalPluginPackageCatalogCommandFile } from './pluginPackageCatalogCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-package-catalog run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_PLUGIN_PACKAGE_CATALOG_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
await runLocalPluginPackageCatalogCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_PLUGIN_PACKAGE_CATALOG_CLI_FAILED',
|
||||
name:
|
||||
typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,865 @@
|
||||
// Plugin Package owns recovery catalog publication and inspection commands.
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
collectLocalPluginPackageRecoveryCatalog,
|
||||
createLocalPluginPackagePublisherTrustRegistry,
|
||||
inspectLocalPluginPackageRecoveryCatalog,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
publishLocalPluginPackageRecoveryCatalogEntry,
|
||||
type CollectLocalPluginPackageRecoveryCatalogOptions,
|
||||
type PublishLocalPluginPackageRecoveryCatalogOptions,
|
||||
} from '@qinglong/local-admin/package-recovery-catalog';
|
||||
import { assertLocalPluginPackagePublisherKeyPublicationAllowed } from '@qinglong/local-admin/package-publisher-trust';
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
readPrivateLocalJsonFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
openLocalSqliteAuthenticatedManagementDatabase,
|
||||
type LocalSqliteAuthenticatedManagementDatabase,
|
||||
type LocalSqliteProfile,
|
||||
} from '@qinglong/local-sqlite/authenticated-management';
|
||||
import { LocalSqlitePluginPackageInstallRepository } from '@qinglong/local-sqlite/plugin-package-install';
|
||||
|
||||
export const LOCAL_PLUGIN_PACKAGE_RECOVERY_PUBLICATION_SCHEMA =
|
||||
'qinglong/local-plugin-package-recovery-publication@v1' as const;
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const MAX_DESCRIPTOR_BYTES = 256 * 1024;
|
||||
const MAX_TRUST_BYTES = 256 * 1024;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME_PATTERN =
|
||||
/^(?:@[a-z0-9][a-z0-9._-]{0,63}\/)?[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const COLLECTION_LIMITS = Object.freeze({
|
||||
edge: 4,
|
||||
standalone: 16,
|
||||
} as const);
|
||||
|
||||
export interface LocalPluginPackageCatalogCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly catalogRoot: string;
|
||||
readonly bundleRoot: string;
|
||||
readonly trustRoot: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface MutationIdentity {
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export interface PublishLocalPluginPackageCatalogCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.catalog.publish';
|
||||
readonly options: LocalPluginPackageCatalogCommandOptions;
|
||||
readonly request: MutationIdentity & {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly descriptorFilePath: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CollectLocalPluginPackageCatalogCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.catalog.collect';
|
||||
readonly options: LocalPluginPackageCatalogCommandOptions;
|
||||
readonly request: MutationIdentity & {
|
||||
readonly limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InspectLocalPluginPackageCatalogCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.catalog.inspect';
|
||||
readonly options: LocalPluginPackageCatalogCommandOptions;
|
||||
readonly request: Readonly<Record<never, never>>;
|
||||
}
|
||||
|
||||
export type LocalPluginPackageCatalogCommand =
|
||||
| PublishLocalPluginPackageCatalogCommand
|
||||
| CollectLocalPluginPackageCatalogCommand
|
||||
| InspectLocalPluginPackageCatalogCommand;
|
||||
|
||||
export type LocalPluginPackageCatalogCommandResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.catalog.publish';
|
||||
status: 'published' | 'existing';
|
||||
lockDigest: string;
|
||||
artifactDigest: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.catalog.collect';
|
||||
removedEntries: number;
|
||||
removedBundles: number;
|
||||
removedTransactions: number;
|
||||
remaining: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.catalog.inspect';
|
||||
entryCount: number;
|
||||
bundleCount: number;
|
||||
unresolvedTransactions: number;
|
||||
currentEntries: number;
|
||||
staleEntries: number;
|
||||
}>;
|
||||
|
||||
export interface LocalPluginPackageCatalogCommandRunner {
|
||||
run(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalPluginPackageCatalogCommandResult>>;
|
||||
}
|
||||
|
||||
interface PublicationDescriptor {
|
||||
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_RECOVERY_PUBLICATION_SCHEMA;
|
||||
readonly bundlePath: string;
|
||||
readonly manifest: PublishLocalPluginPackageRecoveryCatalogOptions['manifest'];
|
||||
readonly signature: PublishLocalPluginPackageRecoveryCatalogOptions['signature'];
|
||||
}
|
||||
|
||||
interface LocalPluginPackageCatalogCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteAuthenticatedManagementDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly publish: typeof publishLocalPluginPackageRecoveryCatalogEntry;
|
||||
readonly inspect: typeof inspectLocalPluginPackageRecoveryCatalog;
|
||||
readonly collect: typeof collectLocalPluginPackageRecoveryCatalog;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalPluginPackageCatalogCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_CATALOG_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(
|
||||
`Local Plugin Package catalog command configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'LocalPluginPackageCatalogCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalPluginPackageCatalogCommandConflictError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_CATALOG_COMMAND_CONFLICT';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Local Plugin Package catalog command conflicts with durable state: ${message}`,
|
||||
);
|
||||
this.name = 'LocalPluginPackageCatalogCommandConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
) ||
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(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 LocalPluginPackageCatalogCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function options(value: unknown): LocalPluginPackageCatalogCommandOptions {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'bundleRoot',
|
||||
'catalogRoot',
|
||||
'credentialFilePath',
|
||||
'databasePath',
|
||||
'deploymentRoot',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
'trustRoot',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
const result = {
|
||||
deploymentRoot,
|
||||
databasePath: boundedPath(value.databasePath, 'databasePath'),
|
||||
profile: value.profile,
|
||||
ownerPepperKeyringDirectory: boundedPath(
|
||||
value.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
),
|
||||
credentialFilePath: boundedPath(
|
||||
value.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
),
|
||||
catalogRoot: boundedPath(value.catalogRoot, 'catalogRoot'),
|
||||
bundleRoot: boundedPath(value.bundleRoot, 'bundleRoot'),
|
||||
trustRoot: boundedPath(value.trustRoot, 'trustRoot'),
|
||||
...(hasBusyTimeout ? { busyTimeoutMs: value.busyTimeoutMs as number } : {}),
|
||||
};
|
||||
if (result.profile !== 'edge' && result.profile !== 'standalone') {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
hasBusyTimeout &&
|
||||
(!Number.isSafeInteger(result.busyTimeoutMs) ||
|
||||
(result.busyTimeoutMs as number) < 100 ||
|
||||
(result.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
const authorityPaths = [
|
||||
result.databasePath,
|
||||
result.ownerPepperKeyringDirectory,
|
||||
result.credentialFilePath,
|
||||
result.catalogRoot,
|
||||
result.bundleRoot,
|
||||
result.trustRoot,
|
||||
];
|
||||
for (const [index, authorityPath] of authorityPaths.entries()) {
|
||||
descendant(deploymentRoot, authorityPath, `authority path ${index}`);
|
||||
}
|
||||
if (new Set(authorityPaths).size !== authorityPaths.length) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'authority paths must be distinct',
|
||||
);
|
||||
}
|
||||
return Object.freeze(result as LocalPluginPackageCatalogCommandOptions);
|
||||
}
|
||||
|
||||
function mutationIdentity(
|
||||
value: Record<string, unknown>,
|
||||
extraKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> & MutationIdentity {
|
||||
exactObject(
|
||||
value,
|
||||
['auditEventId', 'failureAuditEventId', 'requestId', ...extraKeys],
|
||||
label,
|
||||
);
|
||||
if (
|
||||
typeof value.requestId !== 'string' ||
|
||||
value.requestId.length < 1 ||
|
||||
value.requestId.length > 128 ||
|
||||
typeof value.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.auditEventId) ||
|
||||
typeof value.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.failureAuditEventId) ||
|
||||
value.auditEventId === value.failureAuditEventId
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
`${label} identity is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalPluginPackageCatalogCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (value.schemaVersion !== 1) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'schemaVersion is invalid',
|
||||
);
|
||||
}
|
||||
const commandOptions = options(value.options);
|
||||
if (value.operation === 'plugin-package.catalog.publish') {
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'descriptorFilePath',
|
||||
'failureAuditEventId',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
],
|
||||
'publication request',
|
||||
);
|
||||
mutationIdentity(
|
||||
value.request,
|
||||
['descriptorFilePath', 'packageName', 'projectId'],
|
||||
'publication request',
|
||||
);
|
||||
if (
|
||||
typeof value.request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(value.request.projectId) ||
|
||||
typeof value.request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(value.request.packageName)
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'publication package identity is invalid',
|
||||
);
|
||||
}
|
||||
const descriptorFilePath = boundedPath(
|
||||
value.request.descriptorFilePath,
|
||||
'descriptorFilePath',
|
||||
);
|
||||
descendant(
|
||||
commandOptions.deploymentRoot,
|
||||
descriptorFilePath,
|
||||
'descriptorFilePath',
|
||||
);
|
||||
if (
|
||||
new Set([
|
||||
commandOptions.databasePath,
|
||||
commandOptions.credentialFilePath,
|
||||
commandOptions.catalogRoot,
|
||||
commandOptions.bundleRoot,
|
||||
commandOptions.trustRoot,
|
||||
descriptorFilePath,
|
||||
]).size !== 6
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'publication authority paths must be distinct',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options: commandOptions,
|
||||
request: Object.freeze({
|
||||
...value.request,
|
||||
descriptorFilePath,
|
||||
}),
|
||||
} as PublishLocalPluginPackageCatalogCommand);
|
||||
}
|
||||
if (value.operation === 'plugin-package.catalog.collect') {
|
||||
const hasLimit =
|
||||
!!value.request &&
|
||||
typeof value.request === 'object' &&
|
||||
!Array.isArray(value.request) &&
|
||||
Object.hasOwn(value.request, 'limit');
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
'requestId',
|
||||
...(hasLimit ? ['limit'] : []),
|
||||
],
|
||||
'collection request',
|
||||
);
|
||||
mutationIdentity(
|
||||
value.request,
|
||||
hasLimit ? ['limit'] : [],
|
||||
'collection request',
|
||||
);
|
||||
const limit = value.request.limit;
|
||||
if (
|
||||
limit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
(limit as number) < 1 ||
|
||||
(limit as number) > COLLECTION_LIMITS[commandOptions.profile])
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'collection limit is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options: commandOptions,
|
||||
request: Object.freeze({
|
||||
requestId: value.request.requestId,
|
||||
auditEventId: value.request.auditEventId,
|
||||
failureAuditEventId: value.request.failureAuditEventId,
|
||||
...(limit === undefined ? {} : { limit: limit as number }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (value.operation === 'plugin-package.catalog.inspect') {
|
||||
exactObject(value.request, [], 'inspection request');
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: value.operation,
|
||||
options: commandOptions,
|
||||
request: Object.freeze({}),
|
||||
});
|
||||
}
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'operation is invalid',
|
||||
);
|
||||
}
|
||||
|
||||
function readCommandFile(
|
||||
commandFilePath: string,
|
||||
): Readonly<LocalPluginPackageCatalogCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(commandFilePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalPluginPackageCatalogCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function publicationDescriptor(
|
||||
filePath: string,
|
||||
deploymentRoot: string,
|
||||
): Readonly<PublicationDescriptor> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_DESCRIPTOR_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'publication descriptor cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
exactObject(
|
||||
parsed,
|
||||
['bundlePath', 'manifest', 'schema', 'signature'],
|
||||
'publication descriptor',
|
||||
);
|
||||
if (parsed.schema !== LOCAL_PLUGIN_PACKAGE_RECOVERY_PUBLICATION_SCHEMA) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'publication descriptor schema is invalid',
|
||||
);
|
||||
}
|
||||
const bundlePath = boundedPath(parsed.bundlePath, 'bundlePath');
|
||||
descendant(deploymentRoot, bundlePath, 'bundlePath');
|
||||
return Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_RECOVERY_PUBLICATION_SCHEMA,
|
||||
bundlePath,
|
||||
manifest:
|
||||
parsed.manifest as PublishLocalPluginPackageRecoveryCatalogOptions['manifest'],
|
||||
signature:
|
||||
parsed.signature as PublishLocalPluginPackageRecoveryCatalogOptions['signature'],
|
||||
});
|
||||
}
|
||||
|
||||
function trustFile(
|
||||
filePath: string,
|
||||
): ReturnType<typeof createLocalPluginPackagePublisherTrustRegistry> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_TRUST_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'publisher trust file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
(parsed as { schema?: unknown }).schema !==
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'publisher trust file schema is invalid',
|
||||
);
|
||||
}
|
||||
return createLocalPluginPackagePublisherTrustRegistry(parsed);
|
||||
}
|
||||
|
||||
async function confirmOwner(
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<void> {
|
||||
await authenticated.confirm();
|
||||
database.confirmUserCredentialFence(authenticated.databaseFence);
|
||||
database.confirmDefaultProjectOwnerFence(authenticated.databaseFence);
|
||||
}
|
||||
|
||||
type SecurityAudit = Parameters<
|
||||
LocalSqliteAuthenticatedManagementDatabase['securityAudit']['record']
|
||||
>[0];
|
||||
|
||||
function databaseTime(
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
eventId: string,
|
||||
now: () => number,
|
||||
): number {
|
||||
const existing = database.authority.client
|
||||
.prepare(
|
||||
`SELECT "occurred_at_ms" AS "occurredAtMs"
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ?`,
|
||||
)
|
||||
.get(eventId) as { readonly occurredAtMs?: unknown } | undefined;
|
||||
const value = existing?.occurredAtMs ?? now();
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'audit clock is invalid',
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
async function recordAuthorized(
|
||||
command:
|
||||
| Readonly<PublishLocalPluginPackageCatalogCommand>
|
||||
| Readonly<CollectLocalPluginPackageCatalogCommand>,
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
projectId: string | null,
|
||||
now: () => number,
|
||||
): Promise<void> {
|
||||
const audit: SecurityAudit = Object.freeze({
|
||||
eventId: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId:
|
||||
command.operation === 'plugin-package.catalog.publish'
|
||||
? 'plugin_package_catalog_publish'
|
||||
: 'plugin_package_catalog_collect',
|
||||
projectId,
|
||||
subject: authenticated.principal.subject,
|
||||
authenticationId: authenticated.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze(['catalog_mutation_authorized']),
|
||||
fence: null,
|
||||
occurredAtMs: databaseTime(database, command.request.auditEventId, now),
|
||||
});
|
||||
await database.securityAudit.record(audit);
|
||||
}
|
||||
|
||||
async function staleLockDigests(
|
||||
repository: LocalSqlitePluginPackageInstallRepository,
|
||||
lockDigests: readonly string[],
|
||||
): Promise<readonly string[]> {
|
||||
const stale: string[] = [];
|
||||
for (const lockDigest of lockDigests) {
|
||||
const lock = await repository.findLock(lockDigest);
|
||||
if (!lock) {
|
||||
stale.push(lockDigest);
|
||||
continue;
|
||||
}
|
||||
const head = await repository.find(lock.projectId, lock.packageName);
|
||||
if (!head || head.lockDigest !== lockDigest) stale.push(lockDigest);
|
||||
}
|
||||
return Object.freeze(stale);
|
||||
}
|
||||
|
||||
async function execute(
|
||||
command: Readonly<LocalPluginPackageCatalogCommand>,
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
adapters: Readonly<LocalPluginPackageCatalogCommandRunnerDependencies>,
|
||||
): Promise<Readonly<LocalPluginPackageCatalogCommandResult>> {
|
||||
await confirmOwner(database, authenticated);
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(
|
||||
database.authority,
|
||||
);
|
||||
if (command.operation === 'plugin-package.catalog.publish') {
|
||||
const head = await repository.find(
|
||||
command.request.projectId,
|
||||
command.request.packageName,
|
||||
);
|
||||
if (!head || head.state === 'failed') {
|
||||
throw new LocalPluginPackageCatalogCommandConflictError(
|
||||
'the current install head is unavailable',
|
||||
);
|
||||
}
|
||||
const lock = await repository.findLock(head.lockDigest);
|
||||
if (
|
||||
!lock ||
|
||||
lock.projectId !== command.request.projectId ||
|
||||
lock.packageName !== command.request.packageName
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConflictError(
|
||||
'the current PackageLock is unavailable',
|
||||
);
|
||||
}
|
||||
const descriptor = publicationDescriptor(
|
||||
command.request.descriptorFilePath,
|
||||
command.options.deploymentRoot,
|
||||
);
|
||||
if (
|
||||
descriptor.bundlePath === command.request.descriptorFilePath ||
|
||||
descriptor.bundlePath === command.options.catalogRoot ||
|
||||
descriptor.bundlePath === command.options.bundleRoot ||
|
||||
descriptor.bundlePath === command.options.trustRoot
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'publication descriptor authorities must be distinct',
|
||||
);
|
||||
}
|
||||
const trust = trustFile(
|
||||
path.join(command.options.trustRoot, 'current.json'),
|
||||
);
|
||||
const result = await adapters.publish({
|
||||
catalogRoot: command.options.catalogRoot,
|
||||
bundleRoot: command.options.bundleRoot,
|
||||
sourceBundlePath: descriptor.bundlePath,
|
||||
lock,
|
||||
manifest: descriptor.manifest,
|
||||
signature: descriptor.signature,
|
||||
trust,
|
||||
confirmPublicationAllowed() {
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot: command.options.trustRoot,
|
||||
publisher: descriptor.signature.publisher,
|
||||
keyId: descriptor.signature.keyId,
|
||||
});
|
||||
},
|
||||
async beforePublish() {
|
||||
await confirmOwner(database, authenticated);
|
||||
await recordAuthorized(
|
||||
command,
|
||||
database,
|
||||
authenticated,
|
||||
lock.projectId,
|
||||
adapters.now,
|
||||
);
|
||||
await confirmOwner(database, authenticated);
|
||||
},
|
||||
} satisfies PublishLocalPluginPackageRecoveryCatalogOptions);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
lockDigest: result.lockDigest,
|
||||
artifactDigest: result.artifactDigest,
|
||||
});
|
||||
}
|
||||
|
||||
const inspection = adapters.inspect({
|
||||
catalogRoot: command.options.catalogRoot,
|
||||
bundleRoot: command.options.bundleRoot,
|
||||
});
|
||||
const stale = await staleLockDigests(repository, inspection.lockDigests);
|
||||
if (command.operation === 'plugin-package.catalog.inspect') {
|
||||
await confirmOwner(database, authenticated);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
entryCount: inspection.entryCount,
|
||||
bundleCount: inspection.bundleCount,
|
||||
unresolvedTransactions: inspection.unresolvedTransactions,
|
||||
currentEntries: inspection.entryCount - stale.length,
|
||||
staleEntries: stale.length,
|
||||
});
|
||||
}
|
||||
const result = await adapters.collect({
|
||||
catalogRoot: command.options.catalogRoot,
|
||||
bundleRoot: command.options.bundleRoot,
|
||||
candidateLockDigests: stale,
|
||||
maxDeletes:
|
||||
command.request.limit ?? COLLECTION_LIMITS[command.options.profile],
|
||||
async beforeDelete() {
|
||||
await confirmOwner(database, authenticated);
|
||||
await recordAuthorized(
|
||||
command,
|
||||
database,
|
||||
authenticated,
|
||||
null,
|
||||
adapters.now,
|
||||
);
|
||||
await confirmOwner(database, authenticated);
|
||||
},
|
||||
} satisfies CollectLocalPluginPackageRecoveryCatalogOptions);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
function failureAudit(
|
||||
command: Readonly<LocalPluginPackageCatalogCommand>,
|
||||
database: LocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
now: () => number,
|
||||
): Readonly<SecurityAudit> | null {
|
||||
if (command.operation === 'plugin-package.catalog.inspect') return null;
|
||||
return Object.freeze({
|
||||
eventId: command.request.failureAuditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId:
|
||||
command.operation === 'plugin-package.catalog.publish'
|
||||
? 'plugin_package_catalog_publish'
|
||||
: 'plugin_package_catalog_collect',
|
||||
projectId:
|
||||
command.operation === 'plugin-package.catalog.publish'
|
||||
? command.request.projectId
|
||||
: null,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome: authenticated
|
||||
? 'authorization_unavailable'
|
||||
: 'authentication_rejected',
|
||||
reasons: Object.freeze([
|
||||
authenticated ? 'catalog_mutation_failed' : 'credential_rejected',
|
||||
]),
|
||||
fence: null,
|
||||
occurredAtMs: databaseTime(
|
||||
database,
|
||||
command.request.failureAuditEventId,
|
||||
now,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalPluginPackageCatalogCommandRunnerDependencies,
|
||||
): Readonly<LocalPluginPackageCatalogCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
['authenticate', 'collect', 'inspect', 'now', 'openDatabase', 'publish']
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function' ||
|
||||
typeof value.publish !== 'function' ||
|
||||
typeof value.inspect !== 'function' ||
|
||||
typeof value.collect !== 'function' ||
|
||||
typeof value.now !== 'function'
|
||||
) {
|
||||
throw new LocalPluginPackageCatalogCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function createLocalPluginPackageCatalogCommandRunner(
|
||||
candidateDependencies: LocalPluginPackageCatalogCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteAuthenticatedManagementDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
publish: publishLocalPluginPackageRecoveryCatalogEntry,
|
||||
inspect: inspectLocalPluginPackageRecoveryCatalog,
|
||||
collect: collectLocalPluginPackageRecoveryCatalog,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalPluginPackageCatalogCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_package_catalog',
|
||||
});
|
||||
return await execute(command, database, authenticated, adapters);
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
database,
|
||||
authenticated,
|
||||
adapters.now,
|
||||
);
|
||||
if (audit) await database.securityAudit.record(audit);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalPluginPackageCatalogCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalPluginPackageCatalogCommandResult>> {
|
||||
return createLocalPluginPackageCatalogCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalPluginPackageCommandFile } from './pluginPackageCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-package run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_PLUGIN_PACKAGE_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalPluginPackageCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_PLUGIN_PACKAGE_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Plugin Package command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,982 @@
|
||||
// Plugin Package owns its authenticated lifecycle command surface.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PrivateLocalCommandFileError,
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
openLocalSqlitePluginPackageManagementDatabase,
|
||||
type LocalSqlitePluginPackageManagementDatabase,
|
||||
type LocalSqliteProfile,
|
||||
} from '@qinglong/local-sqlite/package-management';
|
||||
import type {
|
||||
ApprovalRequestRecord,
|
||||
ApprovedActionDispatchRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import type { ApprovedActionDispatchBatchSummary } from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import {
|
||||
normalizePluginPackageLifecycleImpact,
|
||||
type PluginPackageLifecycleAction,
|
||||
type PluginPackageLifecycleImpact,
|
||||
type PluginPackageLifecycleReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import {
|
||||
pluginPackageInstallRecoveryAction,
|
||||
type PluginPackageInstallActionInput,
|
||||
type PluginPackageInstallInventoryItem,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import type { PluginPackageInstallProposal } from '@qinglong/runtime-core/plugin-package-proposal';
|
||||
|
||||
import { createLocalPluginPackageManagementService } from '@qinglong/local-admin/package-management';
|
||||
import { createLocalPluginPackageLifecycleService } from '@qinglong/local-admin/package-lifecycle';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_DISPATCH_LIMIT = 64;
|
||||
const LOCAL_INSTALLATION_INVENTORY_LIMITS = Object.freeze({
|
||||
edge: 16,
|
||||
standalone: 64,
|
||||
});
|
||||
const LOCAL_INSTALLATION_INVENTORY_DEFAULTS = Object.freeze({
|
||||
edge: 8,
|
||||
standalone: 32,
|
||||
});
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const COMMAND_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const REASON_CODE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const LOCAL_PACKAGE_CONSUMER = Object.freeze({
|
||||
subject: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'local_plugin_package_consumer',
|
||||
}),
|
||||
authenticationId: 'local_plugin_package_consumer_v1',
|
||||
});
|
||||
|
||||
export interface LocalPluginPackageCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ProposeLocalPluginPackageCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.propose';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly proposalAuditEventId: string;
|
||||
readonly approvalAuditEventId: string;
|
||||
readonly actionInput: PluginPackageInstallActionInput;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DecideLocalPluginPackageCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.decide';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly expectedVersion: number;
|
||||
readonly decisionId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly decision: 'approved' | 'rejected';
|
||||
readonly reasonCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConsumeLocalPluginPackageCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.consume';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly expectedVersion: number;
|
||||
readonly consumptionId: string;
|
||||
readonly dispatchId: string;
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InspectLocalPluginPackageCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.inspect';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly actionRef: string;
|
||||
readonly approvalRequestId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InspectLocalPluginPackageInstallationCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.installation.inspect';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListLocalPluginPackageInstallationsCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.installation.list';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly projectId: string;
|
||||
readonly limit?: number;
|
||||
readonly after?: Readonly<{ packageName: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DispatchLocalPluginPackageCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.dispatch';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanLocalPluginPackageLifecycleCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.lifecycle.plan';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly action: PluginPackageLifecycleAction;
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecuteLocalPluginPackageLifecycleCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.lifecycle.execute';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly impact: PluginPackageLifecycleImpact;
|
||||
readonly approvalRequestId: string;
|
||||
readonly decisionId: string;
|
||||
readonly consumptionId: string;
|
||||
readonly dispatchId: string;
|
||||
readonly approvalAuditEventId: string;
|
||||
readonly decisionAuditEventId: string;
|
||||
readonly consumptionAuditEventId: string;
|
||||
readonly reasonCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalPluginPackageCommand =
|
||||
| ProposeLocalPluginPackageCommand
|
||||
| DecideLocalPluginPackageCommand
|
||||
| ConsumeLocalPluginPackageCommand
|
||||
| InspectLocalPluginPackageCommand
|
||||
| InspectLocalPluginPackageInstallationCommand
|
||||
| ListLocalPluginPackageInstallationsCommand
|
||||
| DispatchLocalPluginPackageCommand
|
||||
| PlanLocalPluginPackageLifecycleCommand
|
||||
| ExecuteLocalPluginPackageLifecycleCommand;
|
||||
|
||||
export interface LocalPluginPackageCommandRunner {
|
||||
run(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalPluginPackageCommandResult>>;
|
||||
}
|
||||
|
||||
export type LocalPluginPackageCommandResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.propose';
|
||||
proposalStatus: 'created' | 'existing';
|
||||
approvalStatus: 'created' | 'existing';
|
||||
proposal: ReturnType<typeof proposalSummary>;
|
||||
approval: ReturnType<typeof approvalSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.decide';
|
||||
status: 'decided' | 'existing';
|
||||
approval: ReturnType<typeof approvalSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.consume';
|
||||
status: 'consumed' | 'existing';
|
||||
approval: ReturnType<typeof approvalSummary>;
|
||||
dispatch: ReturnType<typeof dispatchSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.inspect';
|
||||
proposal: ReturnType<typeof proposalSummary> | null;
|
||||
approval: ReturnType<typeof approvalSummary> | null;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.installation.inspect';
|
||||
installation: ReturnType<typeof installationSummary> | null;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.installation.list';
|
||||
installations: readonly ReturnType<typeof installationSummary>[];
|
||||
truncated: boolean;
|
||||
next: Readonly<{ packageName: string }> | null;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.dispatch';
|
||||
summary: Readonly<ApprovedActionDispatchBatchSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.lifecycle.plan';
|
||||
impact: Readonly<PluginPackageLifecycleImpact>;
|
||||
summary: ReturnType<typeof lifecycleImpactSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.lifecycle.execute';
|
||||
status: 'created' | 'existing';
|
||||
approval: ReturnType<typeof approvalSummary>;
|
||||
receipt: ReturnType<typeof lifecycleReceiptSummary>;
|
||||
}>;
|
||||
|
||||
export class LocalPluginPackageCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Plugin Package command configuration is invalid: ${message}`);
|
||||
this.name = 'LocalPluginPackageCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
interface LocalPluginPackageCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqlitePluginPackageManagementDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function options(value: unknown): LocalPluginPackageCommandOptions {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'profile',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
for (const key of [
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
] as const) {
|
||||
boundedPath(value[key], key);
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze(value as unknown as LocalPluginPackageCommandOptions);
|
||||
}
|
||||
|
||||
function normalizeCommand(value: unknown): Readonly<LocalPluginPackageCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['schemaVersion', 'operation', 'options', 'request'],
|
||||
'command',
|
||||
);
|
||||
if (value.schemaVersion !== 1) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'schemaVersion is invalid',
|
||||
);
|
||||
}
|
||||
const commandOptions = options(value.options);
|
||||
switch (value.operation) {
|
||||
case 'plugin-package.propose':
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'proposalAuditEventId',
|
||||
'approvalAuditEventId',
|
||||
'actionInput',
|
||||
],
|
||||
'proposal request',
|
||||
);
|
||||
break;
|
||||
case 'plugin-package.decide':
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'expectedVersion',
|
||||
'decisionId',
|
||||
'auditEventId',
|
||||
'decision',
|
||||
'reasonCode',
|
||||
],
|
||||
'decision request',
|
||||
);
|
||||
break;
|
||||
case 'plugin-package.consume':
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'actionRef',
|
||||
'approvalRequestId',
|
||||
'expectedVersion',
|
||||
'consumptionId',
|
||||
'dispatchId',
|
||||
'auditEventId',
|
||||
],
|
||||
'consumption request',
|
||||
);
|
||||
break;
|
||||
case 'plugin-package.inspect':
|
||||
exactObject(
|
||||
value.request,
|
||||
['actionRef', 'approvalRequestId'],
|
||||
'inspection request',
|
||||
);
|
||||
break;
|
||||
case 'plugin-package.installation.inspect':
|
||||
exactObject(
|
||||
value.request,
|
||||
['packageName', 'projectId'],
|
||||
'installation inspection request',
|
||||
);
|
||||
if (
|
||||
typeof value.request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(value.request.projectId) ||
|
||||
typeof value.request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(value.request.packageName)
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'installation inspection identity is invalid',
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'plugin-package.installation.list': {
|
||||
const request =
|
||||
value.request && typeof value.request === 'object'
|
||||
? (value.request as Record<string, unknown>)
|
||||
: {};
|
||||
const hasLimit = Object.hasOwn(request, 'limit');
|
||||
const hasAfter = Object.hasOwn(request, 'after');
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'projectId',
|
||||
...(hasLimit ? ['limit'] : []),
|
||||
...(hasAfter ? ['after'] : []),
|
||||
],
|
||||
'installation list request',
|
||||
);
|
||||
if (
|
||||
typeof request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(request.projectId) ||
|
||||
(request.limit !== undefined &&
|
||||
(!Number.isSafeInteger(request.limit) ||
|
||||
(request.limit as number) < 1 ||
|
||||
(request.limit as number) >
|
||||
LOCAL_INSTALLATION_INVENTORY_LIMITS[commandOptions.profile]))
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'installation list request is invalid',
|
||||
);
|
||||
}
|
||||
if (request.after !== undefined) {
|
||||
exactObject(request.after, ['packageName'], 'installation list cursor');
|
||||
if (
|
||||
typeof request.after.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(request.after.packageName)
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'installation list cursor is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'plugin-package.dispatch': {
|
||||
const hasLimit =
|
||||
!!value.request &&
|
||||
typeof value.request === 'object' &&
|
||||
!Array.isArray(value.request) &&
|
||||
Object.hasOwn(value.request, 'limit');
|
||||
exactObject(value.request, hasLimit ? ['limit'] : [], 'dispatch request');
|
||||
if (
|
||||
(value.request as { limit?: unknown }).limit !== undefined &&
|
||||
(!Number.isSafeInteger((value.request as { limit?: unknown }).limit) ||
|
||||
((value.request as { limit?: number }).limit as number) < 1 ||
|
||||
((value.request as { limit?: number }).limit as number) >
|
||||
MAX_DISPATCH_LIMIT)
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'dispatch limit is invalid',
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'plugin-package.lifecycle.plan':
|
||||
exactObject(
|
||||
value.request,
|
||||
['action', 'packageName', 'projectId'],
|
||||
'lifecycle plan request',
|
||||
);
|
||||
if (
|
||||
(value.request.action !== 'disable' &&
|
||||
value.request.action !== 'enable' &&
|
||||
value.request.action !== 'uninstall') ||
|
||||
typeof value.request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(value.request.projectId) ||
|
||||
typeof value.request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(value.request.packageName)
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'lifecycle plan request is invalid',
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'plugin-package.lifecycle.execute':
|
||||
exactObject(
|
||||
value.request,
|
||||
[
|
||||
'approvalAuditEventId',
|
||||
'approvalRequestId',
|
||||
'consumptionAuditEventId',
|
||||
'consumptionId',
|
||||
'decisionAuditEventId',
|
||||
'decisionId',
|
||||
'dispatchId',
|
||||
'impact',
|
||||
'reasonCode',
|
||||
],
|
||||
'lifecycle execution request',
|
||||
);
|
||||
try {
|
||||
normalizePluginPackageLifecycleImpact(
|
||||
value.request.impact as PluginPackageLifecycleImpact,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'lifecycle impact is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
[
|
||||
value.request.approvalRequestId,
|
||||
value.request.decisionId,
|
||||
value.request.consumptionId,
|
||||
value.request.dispatchId,
|
||||
value.request.approvalAuditEventId,
|
||||
value.request.decisionAuditEventId,
|
||||
value.request.consumptionAuditEventId,
|
||||
].some(
|
||||
(candidate) =>
|
||||
typeof candidate !== 'string' ||
|
||||
!COMMAND_IDENTIFIER_PATTERN.test(candidate),
|
||||
) ||
|
||||
typeof value.request.reasonCode !== 'string' ||
|
||||
!REASON_CODE_PATTERN.test(value.request.reasonCode)
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'lifecycle execution identity is invalid',
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'operation is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...value,
|
||||
options: commandOptions,
|
||||
} as unknown as LocalPluginPackageCommand);
|
||||
}
|
||||
|
||||
function readCommandFile(
|
||||
commandFilePath: string,
|
||||
): Readonly<LocalPluginPackageCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(commandFilePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalPluginPackageCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof PrivateLocalCommandFileError) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function proposalSummary(proposal: Readonly<PluginPackageInstallProposal>) {
|
||||
return Object.freeze({
|
||||
actionRef: proposal.actionRef,
|
||||
projectId: proposal.projectId,
|
||||
packageName: proposal.actionInput.manifest.metadata.name,
|
||||
packageVersion: proposal.actionInput.manifest.metadata.version,
|
||||
operation: proposal.actionInput.plan.operation,
|
||||
sourceKind: proposal.actionInput.source.kind,
|
||||
architecture: proposal.actionInput.architecture,
|
||||
deploymentProfile: proposal.actionInput.deploymentProfile,
|
||||
targetGeneration: proposal.actionInput.targetGeneration,
|
||||
actionDigest: proposal.actionDigest,
|
||||
previewDigest: proposal.previewDigest,
|
||||
proposalDigest: proposal.proposalDigest,
|
||||
createdAtMs: proposal.createdAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function approvalSummary(approval: Readonly<ApprovalRequestRecord>) {
|
||||
return Object.freeze({
|
||||
id: approval.id,
|
||||
projectId: approval.projectId,
|
||||
version: approval.version,
|
||||
state: approval.state,
|
||||
risk: approval.risk,
|
||||
decisionMode: approval.decisionMode,
|
||||
requestedAtMs: approval.requestedAtMs,
|
||||
expiresAtMs: approval.expiresAtMs,
|
||||
decision: approval.decision,
|
||||
decisionReasonCode: approval.decisionReasonCode,
|
||||
decidedAtMs: approval.decidedAtMs,
|
||||
dispatchId: approval.dispatchId,
|
||||
consumedAtMs: approval.consumedAtMs,
|
||||
actionDigest: approval.action.actionDigest,
|
||||
previewDigest: approval.action.previewDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchSummary(dispatch: Readonly<ApprovedActionDispatchRecord>) {
|
||||
return Object.freeze({
|
||||
id: dispatch.id,
|
||||
approvalRequestId: dispatch.approvalRequestId,
|
||||
projectId: dispatch.projectId,
|
||||
actionRef: dispatch.action.actionRef,
|
||||
actionDigest: dispatch.action.actionDigest,
|
||||
createdAtMs: dispatch.createdAtMs,
|
||||
expiresAtMs: dispatch.expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function installationSummary(
|
||||
item: Readonly<PluginPackageInstallInventoryItem>,
|
||||
) {
|
||||
const { record, quarantine } = item;
|
||||
return Object.freeze({
|
||||
installationId: record.installationId,
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
packageVersion: record.packageVersion,
|
||||
operation: record.operation,
|
||||
state: record.state,
|
||||
targetGeneration: record.targetGeneration,
|
||||
activeLockDigest: record.activeLockDigest,
|
||||
previousActiveLockDigest: record.previousActiveLockDigest,
|
||||
recoveryAction: pluginPackageInstallRecoveryAction(record),
|
||||
availability: quarantine
|
||||
? ('quarantined' as const)
|
||||
: record.state === 'active'
|
||||
? ('active' as const)
|
||||
: ('not_active' as const),
|
||||
quarantineReason: quarantine?.reasonCode ?? null,
|
||||
quarantineAuthorizationMode: quarantine?.authorizationMode ?? null,
|
||||
quarantineEventDigest: quarantine?.eventDigest ?? null,
|
||||
quarantinedAtMs: quarantine?.occurredAtMs ?? null,
|
||||
withdrawalStatus: quarantine?.capabilityStatus ?? null,
|
||||
withdrawalReceiptDigest: quarantine?.receiptDigest ?? null,
|
||||
withdrawalCommittedAtMs: quarantine?.committedAtMs ?? null,
|
||||
failureReason: record.failure?.reason ?? null,
|
||||
failedFrom: record.failure?.failedFrom ?? null,
|
||||
failedAtMs: record.failure?.failedAtMs ?? null,
|
||||
version: record.version,
|
||||
createdAtMs: record.createdAtMs,
|
||||
updatedAtMs: record.updatedAtMs,
|
||||
recordDigest: record.recordDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function lifecycleImpactSummary(
|
||||
impact: Readonly<PluginPackageLifecycleImpact>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
action: impact.action,
|
||||
projectId: impact.target.projectId,
|
||||
packageName: impact.target.packageName,
|
||||
installationId: impact.target.installationId,
|
||||
lockDigest: impact.target.lockDigest,
|
||||
installVersion: impact.target.installVersion,
|
||||
installRecordDigest: impact.target.installRecordDigest,
|
||||
expectedVersion: impact.expected.version,
|
||||
expectedDisposition: impact.expected.disposition,
|
||||
expectedEventDigest: impact.expected.eventDigest,
|
||||
generationDigest: impact.generationDigest,
|
||||
materializedRevisionDigest: impact.materializedRevisionDigest,
|
||||
currentToolSnapshotDigest: impact.currentToolSnapshotDigest,
|
||||
taskIds: impact.taskIds,
|
||||
resourceCounts: impact.resourceCounts,
|
||||
referenceGraphDigest: impact.referenceGraphDigest,
|
||||
blockingReferences: impact.blockingReferences,
|
||||
impactDigest: impact.impactDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function lifecycleReceiptSummary(
|
||||
receipt: Readonly<PluginPackageLifecycleReceipt>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
eventDigest: receipt.eventDigest,
|
||||
action: receipt.action,
|
||||
projectId: receipt.target.projectId,
|
||||
packageName: receipt.target.packageName,
|
||||
installationId: receipt.target.installationId,
|
||||
lockDigest: receipt.target.lockDigest,
|
||||
lifecycleVersion: receipt.lifecycle.version,
|
||||
disposition: receipt.lifecycle.disposition,
|
||||
capabilityStatus: receipt.capability.status,
|
||||
taskTransitions: receipt.capability.taskTransitions.length,
|
||||
previousActiveVectorDigest:
|
||||
receipt.capability.previousActiveVectorDigest,
|
||||
currentActiveVectorDigest: receipt.capability.currentActiveVectorDigest,
|
||||
currentToolSnapshotDigest: receipt.capability.currentToolSnapshotDigest,
|
||||
retainedSourceCount: receipt.capability.retainedSourceCount,
|
||||
committedAtMs: receipt.committedAtMs,
|
||||
receiptDigest: receipt.receiptDigest,
|
||||
});
|
||||
}
|
||||
|
||||
async function execute(
|
||||
command: Readonly<LocalPluginPackageCommand>,
|
||||
database: LocalSqlitePluginPackageManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<Readonly<LocalPluginPackageCommandResult>> {
|
||||
await authenticated.confirm();
|
||||
if (command.operation === 'plugin-package.lifecycle.plan') {
|
||||
const service = createLocalPluginPackageLifecycleService({
|
||||
authority: database.authority,
|
||||
});
|
||||
const impact = await service.plan(
|
||||
command.request.action,
|
||||
command.request.projectId,
|
||||
command.request.packageName,
|
||||
authenticated.principal,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
impact,
|
||||
summary: lifecycleImpactSummary(impact),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'plugin-package.lifecycle.execute') {
|
||||
const service = createLocalPluginPackageLifecycleService({
|
||||
authority: database.authority,
|
||||
});
|
||||
const result = await service.execute({
|
||||
...command.request,
|
||||
principal: authenticated.principal,
|
||||
confirmAuthorization: authenticated.confirm,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
approval: approvalSummary(result.approval),
|
||||
receipt: lifecycleReceiptSummary(result.receipt),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'plugin-package.installation.inspect') {
|
||||
const { LocalSqlitePluginPackageInstallRepository } = await import(
|
||||
'@qinglong/local-sqlite/plugin-package-install'
|
||||
);
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(
|
||||
database.authority,
|
||||
);
|
||||
const item = await repository.findCurrent(
|
||||
command.request.projectId,
|
||||
command.request.packageName,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
installation: item ? installationSummary(item) : null,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'plugin-package.installation.list') {
|
||||
const { LocalSqlitePluginPackageInstallRepository } = await import(
|
||||
'@qinglong/local-sqlite/plugin-package-install'
|
||||
);
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(
|
||||
database.authority,
|
||||
);
|
||||
const page = await repository.listCurrentPage({
|
||||
projectId: command.request.projectId,
|
||||
limit:
|
||||
command.request.limit ??
|
||||
LOCAL_INSTALLATION_INVENTORY_DEFAULTS[command.options.profile],
|
||||
...(command.request.after === undefined
|
||||
? {}
|
||||
: { after: command.request.after }),
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
installations: Object.freeze(page.items.map(installationSummary)),
|
||||
truncated: page.truncated,
|
||||
next: page.next ?? null,
|
||||
});
|
||||
}
|
||||
const service = createLocalPluginPackageManagementService({
|
||||
authority: database.authority,
|
||||
profile: command.options.profile,
|
||||
consumer: LOCAL_PACKAGE_CONSUMER,
|
||||
dispatcher: {
|
||||
owner: 'local_plugin_package_dispatcher',
|
||||
defaultBatchSize: 4,
|
||||
createId: randomUUID,
|
||||
},
|
||||
});
|
||||
switch (command.operation) {
|
||||
case 'plugin-package.propose': {
|
||||
const current = await service.inspect(
|
||||
command.request.actionRef,
|
||||
command.request.approvalRequestId,
|
||||
);
|
||||
const result = await service.propose({
|
||||
...command.request,
|
||||
requestedAtMs:
|
||||
current.approvalRequest?.requestedAtMs ??
|
||||
current.proposal?.createdAtMs ??
|
||||
Date.now(),
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
proposalStatus: result.proposalStatus,
|
||||
approvalStatus: result.approvalStatus,
|
||||
proposal: proposalSummary(result.proposal),
|
||||
approval: approvalSummary(result.approvalRequest),
|
||||
});
|
||||
}
|
||||
case 'plugin-package.decide': {
|
||||
const { actionRef, ...decisionRequest } = command.request;
|
||||
const current = await service.inspect(
|
||||
actionRef,
|
||||
command.request.approvalRequestId,
|
||||
);
|
||||
if (
|
||||
current.approvalRequest?.action.actionRef === actionRef &&
|
||||
current.approvalRequest.decisionId === command.request.decisionId &&
|
||||
current.approvalRequest.decision === command.request.decision &&
|
||||
current.approvalRequest.decisionReasonCode ===
|
||||
command.request.reasonCode &&
|
||||
current.approvalRequest.decidedBy?.type ===
|
||||
authenticated.principal.subject.type &&
|
||||
current.approvalRequest.decidedBy.id ===
|
||||
authenticated.principal.subject.id
|
||||
) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
status: 'existing',
|
||||
approval: approvalSummary(current.approvalRequest),
|
||||
});
|
||||
}
|
||||
const result = await service.decide({
|
||||
...decisionRequest,
|
||||
decidedAtMs: Date.now(),
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
approval: approvalSummary(result.request),
|
||||
});
|
||||
}
|
||||
case 'plugin-package.consume': {
|
||||
const { actionRef, ...consumptionRequest } = command.request;
|
||||
const current = await service.inspect(
|
||||
actionRef,
|
||||
command.request.approvalRequestId,
|
||||
);
|
||||
const result = await service.consume({
|
||||
...consumptionRequest,
|
||||
consumedAtMs:
|
||||
current.approvalRequest?.consumptionId ===
|
||||
command.request.consumptionId &&
|
||||
current.approvalRequest.dispatchId === command.request.dispatchId &&
|
||||
current.approvalRequest.consumedAtMs !== null
|
||||
? current.approvalRequest.consumedAtMs
|
||||
: Date.now(),
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
approval: approvalSummary(result.request),
|
||||
dispatch: dispatchSummary(result.dispatch),
|
||||
});
|
||||
}
|
||||
case 'plugin-package.inspect': {
|
||||
const result = await service.inspect(
|
||||
command.request.actionRef,
|
||||
command.request.approvalRequestId,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
proposal: result.proposal ? proposalSummary(result.proposal) : null,
|
||||
approval: result.approvalRequest
|
||||
? approvalSummary(result.approvalRequest)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
case 'plugin-package.dispatch': {
|
||||
const result = await service.dispatch(command.request.limit);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
summary: result,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
value: LocalPluginPackageCommandRunnerDependencies,
|
||||
): Readonly<LocalPluginPackageCommandRunnerDependencies> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).length !== 2 ||
|
||||
typeof value.openDatabase !== 'function' ||
|
||||
typeof value.authenticate !== 'function'
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function createLocalPluginPackageCommandRunner(
|
||||
candidateDependencies: LocalPluginPackageCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqlitePluginPackageManagementDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
},
|
||||
): LocalPluginPackageCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
try {
|
||||
const authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_package',
|
||||
});
|
||||
return await execute(command, database, authenticated);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalPluginPackageCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalPluginPackageCommandResult>> {
|
||||
return createLocalPluginPackageCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalPluginPackagePromptCommandFile } from './pluginPackagePromptCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-prompt run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_PLUGIN_PACKAGE_PROMPT_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalPluginPackagePromptCommandFile(
|
||||
commandFilePath,
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_PLUGIN_PACKAGE_PROMPT_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,26 @@
|
||||
// Plugin Package owns server-derived Prompt execution commands.
|
||||
export {
|
||||
LocalPluginPackagePromptAuthenticationError,
|
||||
LocalPluginPackagePromptAuthorizationError,
|
||||
LocalPluginPackagePromptCommandConfigurationError,
|
||||
LocalPluginPackagePromptNotFoundError,
|
||||
LocalPluginPackagePromptUnavailableError,
|
||||
} from './plugin-package-prompt-command/contracts';
|
||||
export type {
|
||||
ExecuteLocalPluginPackagePromptCommand,
|
||||
InspectLocalPluginPackagePromptCommand,
|
||||
InspectLocalPluginPackagePromptExecutionCommand,
|
||||
LocalPluginPackagePromptCommand,
|
||||
LocalPluginPackagePromptCommandOptions,
|
||||
LocalPluginPackagePromptCommandResult,
|
||||
LocalPluginPackagePromptCommandRunner,
|
||||
LocalPluginPackagePromptCommandRunnerDependencies,
|
||||
LocalPluginPackagePromptInspectCommandOptions,
|
||||
LocalPluginPackagePromptOutputCommandOptions,
|
||||
LocalPluginPackagePromptOutputIntent,
|
||||
ReadLocalPluginPackagePromptExecutionOutputCommand,
|
||||
} from './plugin-package-prompt-command/contracts';
|
||||
export {
|
||||
createLocalPluginPackagePromptCommandRunner,
|
||||
runLocalPluginPackagePromptCommandFile,
|
||||
} from './plugin-package-prompt-command/runner';
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalPluginPackagePublisherTrustCommandFile } from './pluginPackagePublisherTrustCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-package-trust run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
await runLocalPluginPackagePublisherTrustCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_CLI_FAILED',
|
||||
name:
|
||||
typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
+1041
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalPluginPackageWorkflowCommandFile } from './pluginPackageWorkflowCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-workflow run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_PLUGIN_PACKAGE_WORKFLOW_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalPluginPackageWorkflowCommandFile(
|
||||
commandFilePath,
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_PLUGIN_PACKAGE_WORKFLOW_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,20 @@
|
||||
// Plugin Package owns generation-bound Workflow administration commands.
|
||||
export { LocalPluginPackageWorkflowCommandConfigurationError } from './plugin-package-workflow-command/contracts';
|
||||
export type {
|
||||
CancelLocalPluginPackageWorkflowCommand,
|
||||
InspectLocalPluginPackageWorkflowCommand,
|
||||
InspectLocalPluginPackageWorkflowRunCommand,
|
||||
ListLocalPluginPackageWorkflowRunEventsCommand,
|
||||
ListLocalPluginPackageWorkflowRunsCommand,
|
||||
ListLocalPluginPackageWorkflowStepRunsCommand,
|
||||
LocalPluginPackageWorkflowCommand,
|
||||
LocalPluginPackageWorkflowCommandOptions,
|
||||
LocalPluginPackageWorkflowCommandResult,
|
||||
LocalPluginPackageWorkflowCommandRunner,
|
||||
LocalPluginPackageWorkflowCommandRunnerDependencies,
|
||||
StartLocalPluginPackageWorkflowCommand,
|
||||
} from './plugin-package-workflow-command/contracts';
|
||||
export {
|
||||
createLocalPluginPackageWorkflowCommandRunner,
|
||||
runLocalPluginPackageWorkflowCommandFile,
|
||||
} from './plugin-package-workflow-command/runner';
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { constants } from 'node:os';
|
||||
|
||||
import { resolveQingLong3ProductCommand } from './productCommand';
|
||||
|
||||
const FORWARDED_SIGNALS = Object.freeze([
|
||||
'SIGINT',
|
||||
'SIGTERM',
|
||||
'SIGHUP',
|
||||
] as const);
|
||||
|
||||
export interface QingLong3ProductSignalChild {
|
||||
readonly exitCode: number | null;
|
||||
readonly signalCode: NodeJS.Signals | null;
|
||||
kill(signal: NodeJS.Signals): boolean;
|
||||
}
|
||||
|
||||
export interface QingLong3ProductSignalHost {
|
||||
on(signal: NodeJS.Signals, handler: () => void): unknown;
|
||||
off(signal: NodeJS.Signals, handler: () => void): unknown;
|
||||
}
|
||||
|
||||
export function signalExitCode(signal: NodeJS.Signals | null): number {
|
||||
if (signal === null) return 1;
|
||||
const number = constants.signals[signal];
|
||||
return typeof number === 'number' ? 128 + number : 1;
|
||||
}
|
||||
|
||||
function lowSensitivityFailure(
|
||||
code: string,
|
||||
message: string,
|
||||
): Readonly<Record<string, string | number>> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-product-cli',
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
export function forwardSignals(
|
||||
child: QingLong3ProductSignalChild,
|
||||
signalHost: QingLong3ProductSignalHost = process,
|
||||
): () => void {
|
||||
const handlers = FORWARDED_SIGNALS.map((signal) => {
|
||||
const handler = (): void => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill(signal);
|
||||
}
|
||||
};
|
||||
signalHost.on(signal, handler);
|
||||
return Object.freeze({ signal, handler });
|
||||
});
|
||||
return () => {
|
||||
for (const { signal, handler } of handlers) {
|
||||
signalHost.off(signal, handler);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function invoke(targetFilePath: string, argv: readonly string[]): void {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(process.execPath, [targetFilePath, ...argv], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_PRODUCT_COMMAND_START_FAILED',
|
||||
'QingLong 3.0 product command could not start',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const removeSignalHandlers = forwardSignals(child);
|
||||
let settled = false;
|
||||
const settle = (exitCode: number): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
process.exitCode = exitCode;
|
||||
};
|
||||
child.once('error', () => {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_PRODUCT_COMMAND_START_FAILED',
|
||||
'QingLong 3.0 product command could not start',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
settle(1);
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
settle(code ?? signalExitCode(signal));
|
||||
});
|
||||
}
|
||||
|
||||
function main(argv: readonly string[]): void {
|
||||
try {
|
||||
const resolution = resolveQingLong3ProductCommand(argv, __dirname);
|
||||
if (resolution.kind === 'help' || resolution.kind === 'version') {
|
||||
process.stdout.write(`${resolution.output}\n`);
|
||||
return;
|
||||
}
|
||||
if (resolution.kind === 'invalid') {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(resolution.code, resolution.message),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
invoke(resolution.targetFilePath, resolution.argv);
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_PRODUCT_CLI_INSTALLATION_INVALID',
|
||||
'QingLong 3.0 product command installation is invalid',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export interface QingLong3ProductCommandDefinition {
|
||||
readonly name: string;
|
||||
readonly binary: string;
|
||||
readonly target: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export type QingLong3ProductCommandResolution =
|
||||
| Readonly<{ kind: 'help'; output: string }>
|
||||
| Readonly<{ kind: 'version'; output: string }>
|
||||
| Readonly<{
|
||||
kind: 'invoke';
|
||||
command: QingLong3ProductCommandDefinition;
|
||||
targetFilePath: string;
|
||||
argv: readonly string[];
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: 'invalid';
|
||||
code: 'QL3_PRODUCT_CLI_USAGE_INVALID';
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
const PACKAGE_NAME = '@qinglong/local-owner-cli';
|
||||
const MAXIMUM_PACKAGE_MANIFEST_BYTES = 64 * 1024;
|
||||
const SEMVER_PATTERN =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
export const QINGLONG3_PRODUCT_COMMANDS: readonly QingLong3ProductCommandDefinition[] =
|
||||
Object.freeze([
|
||||
Object.freeze({
|
||||
name: 'setup',
|
||||
binary: 'ql3-local-setup',
|
||||
target: 'lifecycle/localSetupCli.js',
|
||||
description: 'prepare Local storage and owner material',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'readiness',
|
||||
binary: 'ql3-local-readiness',
|
||||
target: 'lifecycle/localReadinessCli.js',
|
||||
description: 'inspect Local schema and runtime readiness',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'deploy',
|
||||
binary: 'ql3-local-deploy',
|
||||
target: 'deployment/localDeploymentCli.js',
|
||||
description: 'run the bounded Local deployment ceremony',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'owner',
|
||||
binary: 'ql3-owner',
|
||||
target: 'cli.js',
|
||||
description: 'manage the Local Owner ceremony',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'identity',
|
||||
binary: 'ql3-identity',
|
||||
target: 'security-management/identityCredentialCli.js',
|
||||
description: 'manage identities and API credentials',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'policy',
|
||||
binary: 'ql3-policy',
|
||||
target: 'security-management/projectPolicyCli.js',
|
||||
description: 'manage Project policy and lifecycle',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'audit',
|
||||
binary: 'ql3-audit',
|
||||
target: 'security-management/securityAuditQueryCli.js',
|
||||
description: 'query bounded security audit facts',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'secret',
|
||||
binary: 'ql3-secret',
|
||||
target: 'security-management/secretCli.js',
|
||||
description: 'manage Local Secret references',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'task',
|
||||
binary: 'ql3-task',
|
||||
target: 'automation-management/taskDefinitionCli.js',
|
||||
description: 'manage Task definitions',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'trigger',
|
||||
binary: 'ql3-trigger',
|
||||
target: 'automation-management/triggerCli.js',
|
||||
description: 'manage Trigger definitions',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'workflow',
|
||||
binary: 'ql3-workflow',
|
||||
target: 'plugin-package/pluginPackageWorkflowCli.js',
|
||||
description: 'inspect, start, and control Package workflows',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'approval',
|
||||
binary: 'ql3-approval',
|
||||
target: 'approval-management/approvalCli.js',
|
||||
description: 'inspect and decide Approved Actions',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package',
|
||||
binary: 'ql3-package',
|
||||
target: 'plugin-package/pluginPackageCli.js',
|
||||
description: 'manage Plugin Package lifecycle',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package-catalog',
|
||||
binary: 'ql3-package-catalog',
|
||||
target: 'plugin-package/pluginPackageCatalogCli.js',
|
||||
description: 'inspect the installed Package catalog',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package-trust',
|
||||
binary: 'ql3-package-trust',
|
||||
target: 'plugin-package/pluginPackagePublisherTrustCli.js',
|
||||
description: 'manage trusted Package publishers',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'ai-feature',
|
||||
binary: 'ql3-ai-feature',
|
||||
target: 'ai-management/aiFeatureCli.js',
|
||||
description: 'activate or deactivate optional Local AI',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'model-price',
|
||||
binary: 'ql3-model-price',
|
||||
target: 'ai-management/modelPriceCatalogCli.js',
|
||||
description: 'manage the model price catalog',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'model-credential',
|
||||
binary: 'ql3-model-credential',
|
||||
target: 'ai-management/modelProviderCredentialCli.js',
|
||||
description: 'manage model provider credentials',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'prompt',
|
||||
binary: 'ql3-prompt',
|
||||
target: 'plugin-package/pluginPackagePromptCli.js',
|
||||
description: 'inspect and execute Package prompts',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'adoption',
|
||||
binary: 'ql3-adoption',
|
||||
target: 'lifecycle/adoptionCli.js',
|
||||
description: 'review and commit legacy task adoption',
|
||||
}),
|
||||
]);
|
||||
|
||||
function installationPaths(moduleDirectory: string): Readonly<{
|
||||
distRoot: string;
|
||||
packageManifestPath: string;
|
||||
}> {
|
||||
const distRoot = resolve(moduleDirectory, '..');
|
||||
return Object.freeze({
|
||||
distRoot,
|
||||
packageManifestPath: resolve(distRoot, '..', 'package.json'),
|
||||
});
|
||||
}
|
||||
|
||||
function isInside(parent: string, candidate: string): boolean {
|
||||
const pathFromParent = relative(parent, candidate);
|
||||
return (
|
||||
pathFromParent !== '' &&
|
||||
pathFromParent !== '..' &&
|
||||
!pathFromParent.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(pathFromParent)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInstalledTarget(
|
||||
distRoot: string,
|
||||
definition: QingLong3ProductCommandDefinition,
|
||||
): string {
|
||||
const lexicalTarget = resolve(distRoot, definition.target);
|
||||
if (!isInside(distRoot, lexicalTarget)) {
|
||||
throw new Error('product command target escapes package dist root');
|
||||
}
|
||||
const targetStatus = statSync(lexicalTarget, { throwIfNoEntry: false });
|
||||
if (targetStatus === undefined || !targetStatus.isFile()) {
|
||||
throw new Error('product command target is unavailable');
|
||||
}
|
||||
const canonicalDistRoot = realpathSync(distRoot);
|
||||
const canonicalTarget = realpathSync(lexicalTarget);
|
||||
if (!isInside(canonicalDistRoot, canonicalTarget)) {
|
||||
throw new Error('product command target escapes canonical package root');
|
||||
}
|
||||
return lexicalTarget;
|
||||
}
|
||||
|
||||
export function loadQingLong3ProductVersion(moduleDirectory: string): string {
|
||||
const { packageManifestPath } = installationPaths(moduleDirectory);
|
||||
const status = statSync(packageManifestPath, { throwIfNoEntry: false });
|
||||
if (
|
||||
status === undefined ||
|
||||
!status.isFile() ||
|
||||
status.size <= 0 ||
|
||||
status.size > MAXIMUM_PACKAGE_MANIFEST_BYTES
|
||||
) {
|
||||
throw new Error('product package manifest is unavailable');
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(packageManifestPath, 'utf8')) as {
|
||||
readonly name?: unknown;
|
||||
readonly version?: unknown;
|
||||
};
|
||||
if (
|
||||
manifest.name !== PACKAGE_NAME ||
|
||||
typeof manifest.version !== 'string' ||
|
||||
!SEMVER_PATTERN.test(manifest.version)
|
||||
) {
|
||||
throw new Error('product package identity is invalid');
|
||||
}
|
||||
return manifest.version;
|
||||
}
|
||||
|
||||
export function qingLong3ProductHelp(): string {
|
||||
const longestName = Math.max(
|
||||
...QINGLONG3_PRODUCT_COMMANDS.map(({ name }) => name.length),
|
||||
);
|
||||
const commands = QINGLONG3_PRODUCT_COMMANDS.map(
|
||||
({ name, description }) => ` ${name.padEnd(longestName)} ${description}`,
|
||||
).join('\n');
|
||||
return [
|
||||
'Usage: ql3 <command> [arguments]',
|
||||
'',
|
||||
'Commands:',
|
||||
commands,
|
||||
'',
|
||||
'Use `ql3 <command> --help` for command-specific usage.',
|
||||
'Root service mutation remains isolated in `ql3-service-bridge`.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function resolveQingLong3ProductCommand(
|
||||
argv: readonly string[],
|
||||
moduleDirectory: string,
|
||||
): QingLong3ProductCommandResolution {
|
||||
if (
|
||||
argv.length === 0 ||
|
||||
(argv.length === 1 &&
|
||||
(argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help'))
|
||||
) {
|
||||
return Object.freeze({ kind: 'help', output: qingLong3ProductHelp() });
|
||||
}
|
||||
if (
|
||||
argv.length === 1 &&
|
||||
(argv[0] === '--version' || argv[0] === '-V' || argv[0] === 'version')
|
||||
) {
|
||||
return Object.freeze({
|
||||
kind: 'version',
|
||||
output: loadQingLong3ProductVersion(moduleDirectory),
|
||||
});
|
||||
}
|
||||
const commandName = argv[0];
|
||||
const command = QINGLONG3_PRODUCT_COMMANDS.find(
|
||||
(candidate) => candidate.name === commandName,
|
||||
);
|
||||
if (command === undefined) {
|
||||
return Object.freeze({
|
||||
kind: 'invalid',
|
||||
code: 'QL3_PRODUCT_CLI_USAGE_INVALID',
|
||||
message: 'unknown QingLong 3.0 product command',
|
||||
});
|
||||
}
|
||||
const { distRoot } = installationPaths(moduleDirectory);
|
||||
return Object.freeze({
|
||||
kind: 'invoke',
|
||||
command,
|
||||
targetFilePath: resolveInstalledTarget(distRoot, command),
|
||||
argv: Object.freeze(argv.slice(1)),
|
||||
});
|
||||
}
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
assertApiCredentialId,
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySubject,
|
||||
readPrivateLocalCommandFile,
|
||||
type SecuritySubject,
|
||||
} from './codecAuthority';
|
||||
import {
|
||||
LocalIdentityCredentialCommandConfigurationError,
|
||||
type BaseInspectionRequest,
|
||||
type BaseMutationRequest,
|
||||
type BaseTargetMutationRequest,
|
||||
type LocalIdentityCredentialCommand,
|
||||
type LocalIdentityCredentialCommandOptions,
|
||||
} from './contracts';
|
||||
|
||||
export const MAX_PATH_BYTES = 4096;
|
||||
export const MAX_VERSION = 2_147_483_647;
|
||||
export const MIN_CREDENTIAL_LIFETIME_MS = 60_000;
|
||||
export const MAX_CREDENTIAL_LIFETIME_MS = 2 * 365 * 24 * 60 * 60 * 1000;
|
||||
export const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
export const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
export const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function descendant(
|
||||
root: string,
|
||||
candidate: string,
|
||||
label: string,
|
||||
): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function requiresDelivery(
|
||||
operation: LocalIdentityCredentialCommand['operation'],
|
||||
) {
|
||||
return (
|
||||
operation === 'credential.issue' ||
|
||||
operation === 'credential.rotate' ||
|
||||
operation === 'credential.delivery.acknowledge'
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeOptions(
|
||||
value: unknown,
|
||||
operation: LocalIdentityCredentialCommand['operation'],
|
||||
): Readonly<LocalIdentityCredentialCommandOptions> {
|
||||
const hasBusyTimeout =
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.hasOwn(value, 'busyTimeoutMs');
|
||||
const deliveryRequired = requiresDelivery(operation);
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'deploymentRoot',
|
||||
'databasePath',
|
||||
'profile',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
...(deliveryRequired ? ['credentialDeliveryDirectory'] : []),
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
||||
const databasePath = boundedPath(value.databasePath, 'databasePath');
|
||||
const ownerPepperKeyringDirectory = boundedPath(
|
||||
value.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const credentialFilePath = boundedPath(
|
||||
value.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
);
|
||||
for (const [label, candidate] of [
|
||||
['databasePath', databasePath],
|
||||
['ownerPepperKeyringDirectory', ownerPepperKeyringDirectory],
|
||||
['credentialFilePath', credentialFilePath],
|
||||
] as const) {
|
||||
descendant(deploymentRoot, candidate, label);
|
||||
}
|
||||
let credentialDeliveryDirectory: string | undefined;
|
||||
if (deliveryRequired) {
|
||||
credentialDeliveryDirectory = boundedPath(
|
||||
value.credentialDeliveryDirectory,
|
||||
'credentialDeliveryDirectory',
|
||||
);
|
||||
descendant(
|
||||
deploymentRoot,
|
||||
credentialDeliveryDirectory,
|
||||
'credentialDeliveryDirectory',
|
||||
);
|
||||
if (
|
||||
credentialDeliveryDirectory === path.dirname(databasePath) ||
|
||||
credentialDeliveryDirectory === ownerPepperKeyringDirectory
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'credentialDeliveryDirectory must not share database or keyring storage',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
||||
(value.busyTimeoutMs as number) < 100 ||
|
||||
(value.busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'busyTimeoutMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
profile: value.profile,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
...(credentialDeliveryDirectory === undefined
|
||||
? {}
|
||||
: { credentialDeliveryDirectory }),
|
||||
...(value.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: value.busyTimeoutMs as number }),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCommonRequest(
|
||||
value: Record<string, unknown>,
|
||||
): Readonly<BaseMutationRequest> {
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.projectId as string);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'projectId is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.mutationId) ||
|
||||
typeof value.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.failureAuditEventId) ||
|
||||
value.failureAuditEventId === value.mutationId ||
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'mutation or request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: value.projectId as string,
|
||||
mutationId: value.mutationId,
|
||||
requestId: value.requestId,
|
||||
failureAuditEventId: value.failureAuditEventId,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeInspectionCommonRequest(
|
||||
value: Record<string, unknown>,
|
||||
): Readonly<BaseInspectionRequest> {
|
||||
try {
|
||||
assertProjectPolicyProjectId(value.projectId as string);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'projectId is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.auditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.auditEventId) ||
|
||||
typeof value.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(value.requestId)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'audit or request identity is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: value.projectId as string,
|
||||
requestId: value.requestId,
|
||||
auditEventId: value.auditEventId,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeInspectionTarget(
|
||||
value: unknown,
|
||||
): Readonly<SecuritySubject> {
|
||||
let target: Readonly<SecuritySubject>;
|
||||
try {
|
||||
target = normalizeProjectPolicySubject(value as SecuritySubject);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'target is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!['user', 'api_app', 'mcp_client', 'agent'].includes(target.type)) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'target is invalid',
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function normalizeTargetRequest(
|
||||
value: Record<string, unknown>,
|
||||
): Readonly<BaseTargetMutationRequest> {
|
||||
const common = normalizeCommonRequest(value);
|
||||
let target: Readonly<SecuritySubject>;
|
||||
try {
|
||||
target = normalizeProjectPolicySubject(value.target as SecuritySubject);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'target is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!['user', 'api_app', 'mcp_client', 'agent'].includes(target.type) ||
|
||||
!Number.isSafeInteger(value.expectedCurrentVersion) ||
|
||||
(value.expectedCurrentVersion as number) < 0 ||
|
||||
(value.expectedCurrentVersion as number) >= MAX_VERSION
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'target or expectedCurrentVersion is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...common,
|
||||
target,
|
||||
expectedCurrentVersion: value.expectedCurrentVersion as number,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRequest(
|
||||
value: unknown,
|
||||
operation: LocalIdentityCredentialCommand['operation'],
|
||||
): LocalIdentityCredentialCommand['request'] {
|
||||
const inspection =
|
||||
operation === 'identity.inspect' || operation === 'credential.inspect';
|
||||
if (inspection) {
|
||||
const identityInspection = operation === 'identity.inspect';
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'projectId',
|
||||
...(identityInspection ? ['target'] : ['credentialId']),
|
||||
'requestId',
|
||||
'auditEventId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const common = normalizeInspectionCommonRequest(value);
|
||||
if (identityInspection) {
|
||||
return Object.freeze({
|
||||
...common,
|
||||
target: normalizeInspectionTarget(value.target),
|
||||
});
|
||||
}
|
||||
try {
|
||||
assertApiCredentialId(value.credentialId as string);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'credentialId is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...common,
|
||||
credentialId: value.credentialId as string,
|
||||
});
|
||||
}
|
||||
const identity = operation.startsWith('identity.');
|
||||
const activeCredential =
|
||||
operation === 'credential.issue' || operation === 'credential.rotate';
|
||||
const revokeCredential = operation === 'credential.revoke';
|
||||
const acknowledge = operation === 'credential.delivery.acknowledge';
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'projectId',
|
||||
...(acknowledge ? [] : ['target', 'expectedCurrentVersion']),
|
||||
...(activeCredential || revokeCredential ? ['credentialId'] : []),
|
||||
...(activeCredential ? ['lifetimeMs'] : []),
|
||||
...(acknowledge
|
||||
? ['credentialMutationId', 'expectedDeliveryDigest']
|
||||
: []),
|
||||
'mutationId',
|
||||
'requestId',
|
||||
'failureAuditEventId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (acknowledge) {
|
||||
const common = normalizeCommonRequest(value);
|
||||
if (
|
||||
typeof value.credentialMutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.credentialMutationId) ||
|
||||
value.credentialMutationId === common.mutationId ||
|
||||
typeof value.expectedDeliveryDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.expectedDeliveryDigest)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'delivery acknowledgement is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...common,
|
||||
credentialMutationId: value.credentialMutationId,
|
||||
expectedDeliveryDigest: value.expectedDeliveryDigest,
|
||||
});
|
||||
}
|
||||
const target = normalizeTargetRequest(value);
|
||||
if (identity) return target;
|
||||
try {
|
||||
assertApiCredentialId(value.credentialId as string);
|
||||
} catch (error) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'credentialId is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
activeCredential &&
|
||||
(!Number.isSafeInteger(value.lifetimeMs) ||
|
||||
(value.lifetimeMs as number) < MIN_CREDENTIAL_LIFETIME_MS ||
|
||||
(value.lifetimeMs as number) > MAX_CREDENTIAL_LIFETIME_MS)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'lifetimeMs is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...target,
|
||||
credentialId: value.credentialId as string,
|
||||
...(activeCredential ? { lifetimeMs: value.lifetimeMs as number } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalIdentityCredentialCommand> {
|
||||
exactObject(
|
||||
value,
|
||||
['schemaVersion', 'operation', 'options', 'request'],
|
||||
'command',
|
||||
);
|
||||
const operations: readonly LocalIdentityCredentialCommand['operation'][] = [
|
||||
'identity.inspect',
|
||||
'identity.register',
|
||||
'identity.enable',
|
||||
'identity.disable',
|
||||
'credential.inspect',
|
||||
'credential.issue',
|
||||
'credential.rotate',
|
||||
'credential.revoke',
|
||||
'credential.delivery.acknowledge',
|
||||
];
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
typeof value.operation !== 'string' ||
|
||||
!operations.includes(
|
||||
value.operation as LocalIdentityCredentialCommand['operation'],
|
||||
)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'command version or operation is invalid',
|
||||
);
|
||||
}
|
||||
const operation =
|
||||
value.operation as LocalIdentityCredentialCommand['operation'];
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
options: normalizeOptions(value.options, operation),
|
||||
request: normalizeRequest(value.request, operation),
|
||||
} as LocalIdentityCredentialCommand);
|
||||
}
|
||||
|
||||
export function readCommandFile(
|
||||
candidatePath: string,
|
||||
): Readonly<LocalIdentityCredentialCommand> {
|
||||
try {
|
||||
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
||||
} catch (error) {
|
||||
if (error instanceof LocalIdentityCredentialCommandConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'command file cannot be read',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
export { assertApiCredentialId } from '@qinglong/runtime-core/api-credential';
|
||||
export {
|
||||
assertProjectPolicyProjectId,
|
||||
normalizeProjectPolicySubject,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
export type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type { createLocalIdentityCredentialAdministrationService } from '@qinglong/local-admin/identity-credential-administration';
|
||||
export type { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export type {
|
||||
LocalCredentialAdministrationDeliveryRecord,
|
||||
LocalCredentialAdministrationDeliverySummary,
|
||||
} from '@qinglong/local-owner-console/credential-administration-delivery';
|
||||
export type { LocalOwnerPepperKeyringFileProvider } from '@qinglong/local-owner-console/pepper-custody';
|
||||
export type { openLocalSqliteIdentityCredentialAdministrationDatabase } from '@qinglong/local-sqlite/identity-credential-administration';
|
||||
export type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import type {
|
||||
createLocalIdentityCredentialAdministrationService,
|
||||
establishAuthenticatedLocalCommand,
|
||||
LocalCredentialAdministrationDeliveryRecord,
|
||||
LocalCredentialAdministrationDeliverySummary,
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
openLocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
SecuritySubject,
|
||||
} from './contractAuthority';
|
||||
|
||||
export type IdentityCommandOperation =
|
||||
| 'identity.register'
|
||||
| 'identity.enable'
|
||||
| 'identity.disable';
|
||||
|
||||
export type CredentialCommandOperation =
|
||||
| 'credential.issue'
|
||||
| 'credential.rotate'
|
||||
| 'credential.revoke';
|
||||
|
||||
export type DeliveryCommandOperation = 'credential.delivery.acknowledge';
|
||||
|
||||
export type InspectionCommandOperation =
|
||||
| 'identity.inspect'
|
||||
| 'credential.inspect';
|
||||
|
||||
export interface LocalIdentityCredentialCommandOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly credentialDeliveryDirectory?: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface BaseMutationRequest {
|
||||
readonly projectId: string;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
export interface BaseTargetMutationRequest extends BaseMutationRequest {
|
||||
readonly target: SecuritySubject;
|
||||
readonly expectedCurrentVersion: number;
|
||||
}
|
||||
|
||||
export interface BaseInspectionRequest {
|
||||
readonly projectId: string;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
}
|
||||
|
||||
export interface LocalIdentityInspectionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'identity.inspect';
|
||||
readonly options: LocalIdentityCredentialCommandOptions;
|
||||
readonly request: BaseInspectionRequest & {
|
||||
readonly target: SecuritySubject;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialInspectionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'credential.inspect';
|
||||
readonly options: LocalIdentityCredentialCommandOptions;
|
||||
readonly request: BaseInspectionRequest & {
|
||||
readonly credentialId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalIdentityAdministrationCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: IdentityCommandOperation;
|
||||
readonly options: LocalIdentityCredentialCommandOptions;
|
||||
readonly request: BaseTargetMutationRequest;
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialIssueCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'credential.issue' | 'credential.rotate';
|
||||
readonly options: LocalIdentityCredentialCommandOptions & {
|
||||
readonly credentialDeliveryDirectory: string;
|
||||
};
|
||||
readonly request: BaseTargetMutationRequest & {
|
||||
readonly credentialId: string;
|
||||
readonly lifetimeMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialRevokeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'credential.revoke';
|
||||
readonly options: LocalIdentityCredentialCommandOptions;
|
||||
readonly request: BaseTargetMutationRequest & {
|
||||
readonly credentialId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalCredentialDeliveryAcknowledgeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: DeliveryCommandOperation;
|
||||
readonly options: LocalIdentityCredentialCommandOptions & {
|
||||
readonly credentialDeliveryDirectory: string;
|
||||
};
|
||||
readonly request: BaseMutationRequest & {
|
||||
readonly credentialMutationId: string;
|
||||
readonly expectedDeliveryDigest: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalIdentityCredentialCommand =
|
||||
| LocalIdentityInspectionCommand
|
||||
| LocalApiCredentialInspectionCommand
|
||||
| LocalIdentityAdministrationCommand
|
||||
| LocalApiCredentialIssueCommand
|
||||
| LocalApiCredentialRevokeCommand
|
||||
| LocalCredentialDeliveryAcknowledgeCommand;
|
||||
|
||||
export type LocalIdentityCredentialCommandResult =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: InspectionCommandOperation;
|
||||
projectId: string;
|
||||
found: false;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'identity.inspect';
|
||||
projectId: string;
|
||||
found: true;
|
||||
target: Readonly<SecuritySubject>;
|
||||
version: number;
|
||||
identityStatus: 'active' | 'disabled';
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'credential.inspect';
|
||||
projectId: string;
|
||||
found: true;
|
||||
credentialId: string;
|
||||
target: Readonly<SecuritySubject>;
|
||||
version: number;
|
||||
state: 'active' | 'revoked';
|
||||
subjectStatus: 'active' | 'disabled';
|
||||
createdAtMs: number;
|
||||
notBeforeAtMs: number;
|
||||
expiresAtMs: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: IdentityCommandOperation;
|
||||
status: 'inserted' | 'existing';
|
||||
projectId: string;
|
||||
target: Readonly<SecuritySubject>;
|
||||
version: number;
|
||||
identityStatus: 'active' | 'disabled';
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: CredentialCommandOperation;
|
||||
status: 'inserted' | 'existing';
|
||||
projectId: string;
|
||||
target: Readonly<SecuritySubject>;
|
||||
credentialId: string;
|
||||
version: number;
|
||||
state: 'active' | 'revoked';
|
||||
expiresAtMs: number;
|
||||
delivery?: Readonly<{
|
||||
fileName: string;
|
||||
digest: string;
|
||||
}>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: DeliveryCommandOperation;
|
||||
status: 'inserted' | 'existing';
|
||||
projectId: string;
|
||||
credentialMutationId: string;
|
||||
acknowledgementMutationId: string;
|
||||
deliveryDigest: string;
|
||||
cleanup: 'removed' | 'absent';
|
||||
}>;
|
||||
|
||||
export interface CredentialDelivery {
|
||||
readonly directory: string;
|
||||
prepare(
|
||||
record: LocalCredentialAdministrationDeliveryRecord,
|
||||
): Readonly<LocalCredentialAdministrationDeliveryRecord>;
|
||||
digest(record: LocalCredentialAdministrationDeliveryRecord): string;
|
||||
publish(
|
||||
record: LocalCredentialAdministrationDeliveryRecord,
|
||||
expectedDeliveryDigest: string,
|
||||
): Readonly<LocalCredentialAdministrationDeliverySummary>;
|
||||
removeAcknowledged(
|
||||
mutationId: string,
|
||||
expectedDeliveryDigest: string,
|
||||
): 'removed' | 'absent';
|
||||
}
|
||||
|
||||
export interface PepperMaterialProvider {
|
||||
resolve(
|
||||
pepperKeyId: string,
|
||||
): ReturnType<LocalOwnerPepperKeyringFileProvider['resolve']>;
|
||||
}
|
||||
|
||||
export interface LocalIdentityCredentialCommandRunner {
|
||||
run(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalIdentityCredentialCommandResult>>;
|
||||
}
|
||||
|
||||
export interface LocalIdentityCredentialCommandRunnerDependencies {
|
||||
readonly openDatabase: typeof openLocalSqliteIdentityCredentialAdministrationDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
readonly createService: typeof createLocalIdentityCredentialAdministrationService;
|
||||
readonly createDelivery: (directory: string) => CredentialDelivery;
|
||||
readonly createPepperProvider: (directory: string) => PepperMaterialProvider;
|
||||
readonly randomBytes: (size: number) => Buffer;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialCommandConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_COMMAND_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Identity credential command is invalid: ${message}`);
|
||||
this.name = 'LocalIdentityCredentialCommandConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialCommandPepperUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_COMMAND_PEPPER_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity credential active pepper material is unavailable');
|
||||
this.name = 'LocalIdentityCredentialCommandPepperUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentityCredentialCommandCurrentCredentialError extends Error {
|
||||
readonly code =
|
||||
'LOCAL_IDENTITY_CREDENTIAL_COMMAND_CURRENT_CREDENTIAL_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Local Identity credential current version is unavailable');
|
||||
this.name = 'LocalIdentityCredentialCommandCurrentCredentialError';
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
export {
|
||||
LocalIdentityCredentialAdministrationAuthenticationError,
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
LocalIdentityCredentialAdministrationServiceUnavailableError,
|
||||
createLocalIdentityCredentialAdministrationService,
|
||||
} from '@qinglong/local-admin/identity-credential-administration';
|
||||
export type { LocalIdentityCredentialAdministrationService } from '@qinglong/local-admin/identity-credential-administration';
|
||||
export {
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
establishAuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
export type { AuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
export { FileLocalCredentialAdministrationDelivery } from '@qinglong/local-owner-console/credential-administration-delivery';
|
||||
export type {
|
||||
LocalCredentialAdministrationDeliveryRecord,
|
||||
LocalCredentialAdministrationDeliverySummary,
|
||||
} from '@qinglong/local-owner-console/credential-administration-delivery';
|
||||
export { LocalOwnerPepperKeyringFileProvider } from '@qinglong/local-owner-console/pepper-custody';
|
||||
export { LocalSqliteAuthenticatedManagementFenceError } from '@qinglong/local-sqlite/authenticated-management';
|
||||
export type { LocalSqliteAuthenticatedUserCredentialFence } from '@qinglong/local-sqlite/authenticated-management';
|
||||
export { openLocalSqliteIdentityCredentialAdministrationDatabase } from '@qinglong/local-sqlite/identity-credential-administration';
|
||||
export type { LocalSqliteIdentityCredentialAdministrationDatabase } from '@qinglong/local-sqlite/identity-credential-administration';
|
||||
export {
|
||||
ApiCredentialAdministrationMutationConflictError,
|
||||
ApiCredentialAdministrationSubjectNotFoundError,
|
||||
ApiCredentialAdministrationVersionConflictError,
|
||||
} from '@qinglong/runtime-core/api-credential-administration';
|
||||
export type { ApiCredentialRecord } from '@qinglong/runtime-core/api-credential';
|
||||
export {
|
||||
API_CREDENTIAL_SECRET_BYTES,
|
||||
apiCredentialSecretDigest,
|
||||
} from '@qinglong/runtime-core/api-credential-token';
|
||||
export {
|
||||
IdentityAdministrationMutationConflictError,
|
||||
IdentityAdministrationVersionConflictError,
|
||||
} from '@qinglong/runtime-core/identity-administration';
|
||||
export {
|
||||
LocalCredentialDeliveryMutationConflictError,
|
||||
LocalCredentialOwnerContinuityError,
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
LocalIdentityOwnerBindingConflictError,
|
||||
} from '@qinglong/runtime-core/local-identity-credential-administration';
|
||||
export type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
export type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
ApiCredentialAdministrationMutationConflictError,
|
||||
ApiCredentialAdministrationSubjectNotFoundError,
|
||||
ApiCredentialAdministrationVersionConflictError,
|
||||
AuthenticatedLocalCommandAuthenticationError,
|
||||
IdentityAdministrationMutationConflictError,
|
||||
IdentityAdministrationVersionConflictError,
|
||||
LocalCredentialDeliveryMutationConflictError,
|
||||
LocalCredentialOwnerContinuityError,
|
||||
LocalIdentityCredentialAdministrationAuthenticationError,
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
LocalIdentityCredentialAdministrationServiceUnavailableError,
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
LocalIdentityCredentialAuthorizationFenceConflictError,
|
||||
LocalIdentityOwnerBindingConflictError,
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type AuthenticatedLocalCommand,
|
||||
type LocalCredentialAdministrationDeliverySummary,
|
||||
type LocalIdentityCredentialAdministrationService,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
type LocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
type SecurityAuditRecord,
|
||||
type SecuritySubject,
|
||||
} from './executionAuthority';
|
||||
import {
|
||||
LocalIdentityCredentialCommandConfigurationError,
|
||||
LocalIdentityCredentialCommandCurrentCredentialError,
|
||||
type LocalApiCredentialIssueCommand,
|
||||
type LocalIdentityCredentialCommand,
|
||||
type LocalIdentityCredentialCommandResult,
|
||||
type LocalIdentityCredentialCommandRunnerDependencies,
|
||||
} from './contracts';
|
||||
|
||||
export function dependencies(
|
||||
value: LocalIdentityCredentialCommandRunnerDependencies,
|
||||
): Readonly<LocalIdentityCredentialCommandRunnerDependencies> {
|
||||
const expected = [
|
||||
'openDatabase',
|
||||
'authenticate',
|
||||
'createService',
|
||||
'createDelivery',
|
||||
'createPepperProvider',
|
||||
'randomBytes',
|
||||
'now',
|
||||
];
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !== expected.sort().join('\0') ||
|
||||
expected.some(
|
||||
(key) =>
|
||||
typeof value[
|
||||
key as keyof LocalIdentityCredentialCommandRunnerDependencies
|
||||
] !== 'function',
|
||||
)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'runner dependencies are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function clock(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'clock is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function failureAudit(
|
||||
command: Readonly<LocalIdentityCredentialCommand>,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
||||
error: unknown,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> | null {
|
||||
if (
|
||||
error instanceof LocalIdentityCredentialAdministrationAuthenticationError ||
|
||||
error instanceof LocalIdentityCredentialAdministrationAuthorizationError ||
|
||||
error instanceof
|
||||
LocalIdentityCredentialAdministrationServiceUnavailableError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let outcome: SecurityAuditRecord['outcome'];
|
||||
let reason: string;
|
||||
if (
|
||||
!authenticated ||
|
||||
error instanceof AuthenticatedLocalCommandAuthenticationError
|
||||
) {
|
||||
outcome = 'authentication_rejected';
|
||||
reason = 'credential_rejected';
|
||||
} else if (
|
||||
error instanceof LocalSqliteAuthenticatedManagementFenceError ||
|
||||
error instanceof LocalIdentityCredentialAuthorizationFenceConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'credential_or_policy_fence_rejected';
|
||||
} else if (
|
||||
error instanceof LocalIdentityOwnerBindingConflictError ||
|
||||
error instanceof LocalCredentialOwnerContinuityError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'owner_continuity_required';
|
||||
} else if (
|
||||
error instanceof IdentityAdministrationVersionConflictError ||
|
||||
error instanceof ApiCredentialAdministrationVersionConflictError ||
|
||||
error instanceof ApiCredentialAdministrationSubjectNotFoundError ||
|
||||
error instanceof LocalIdentityCredentialCommandCurrentCredentialError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'current_version_or_subject_conflict';
|
||||
} else if (
|
||||
error instanceof IdentityAdministrationMutationConflictError ||
|
||||
error instanceof ApiCredentialAdministrationMutationConflictError ||
|
||||
error instanceof LocalCredentialDeliveryMutationConflictError
|
||||
) {
|
||||
outcome = 'denied';
|
||||
reason = 'mutation_conflict';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId:
|
||||
'failureAuditEventId' in command.request
|
||||
? command.request.failureAuditEventId
|
||||
: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
operationId: command.operation,
|
||||
projectId: command.request.projectId,
|
||||
subject: authenticated?.principal.subject ?? null,
|
||||
authenticationId: authenticated?.principal.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons: Object.freeze([reason]),
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateFence(
|
||||
database: LocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<void> {
|
||||
await authenticated.confirm();
|
||||
database.activateUserCredentialFence(
|
||||
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
);
|
||||
}
|
||||
|
||||
export function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
export function activeCredentialResult(
|
||||
command: Readonly<LocalApiCredentialIssueCommand>,
|
||||
result: Awaited<
|
||||
ReturnType<LocalIdentityCredentialAdministrationService['changeCredential']>
|
||||
>,
|
||||
delivery: Readonly<LocalCredentialAdministrationDeliverySummary>,
|
||||
): Readonly<LocalIdentityCredentialCommandResult> {
|
||||
if (
|
||||
result.credential.credentialId !== command.request.credentialId ||
|
||||
result.credential.version !== command.request.expectedCurrentVersion + 1 ||
|
||||
!sameSubject(result.credential.subject, command.request.target) ||
|
||||
result.credential.state !== 'active' ||
|
||||
result.delivery?.digest !== delivery.deliveryDigest ||
|
||||
delivery.mutationId !== command.request.mutationId ||
|
||||
delivery.credentialId !== command.request.credentialId ||
|
||||
delivery.projectId !== command.request.projectId ||
|
||||
!sameSubject(delivery.subject, command.request.target) ||
|
||||
path.dirname(delivery.path) !== command.options.credentialDeliveryDirectory
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
projectId: command.request.projectId,
|
||||
target: result.credential.subject,
|
||||
credentialId: result.credential.credentialId,
|
||||
version: result.credential.version,
|
||||
state: result.credential.state,
|
||||
expiresAtMs: result.credential.expiresAtMs,
|
||||
delivery: Object.freeze({
|
||||
fileName: path.basename(delivery.path),
|
||||
digest: delivery.deliveryDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
import { randomBytes as cryptoRandomBytes } from 'node:crypto';
|
||||
import {
|
||||
API_CREDENTIAL_SECRET_BYTES,
|
||||
FileLocalCredentialAdministrationDelivery,
|
||||
LocalIdentityCredentialAdministrationUnavailableError,
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
apiCredentialSecretDigest,
|
||||
createLocalIdentityCredentialAdministrationService,
|
||||
establishAuthenticatedLocalCommand,
|
||||
openLocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
type ApiCredentialRecord,
|
||||
type AuthenticatedLocalCommand,
|
||||
type LocalCredentialAdministrationDeliveryRecord,
|
||||
} from './executionAuthority';
|
||||
import {
|
||||
LocalIdentityCredentialCommandConfigurationError,
|
||||
LocalIdentityCredentialCommandCurrentCredentialError,
|
||||
LocalIdentityCredentialCommandPepperUnavailableError,
|
||||
type LocalApiCredentialInspectionCommand,
|
||||
type LocalApiCredentialIssueCommand,
|
||||
type LocalApiCredentialRevokeCommand,
|
||||
type LocalCredentialDeliveryAcknowledgeCommand,
|
||||
type LocalIdentityAdministrationCommand,
|
||||
type LocalIdentityCredentialCommandResult,
|
||||
type LocalIdentityCredentialCommandRunner,
|
||||
type LocalIdentityCredentialCommandRunnerDependencies,
|
||||
type LocalIdentityInspectionCommand,
|
||||
} from './contracts';
|
||||
import { readCommandFile } from './codec';
|
||||
import {
|
||||
activateFence,
|
||||
activeCredentialResult,
|
||||
clock,
|
||||
dependencies,
|
||||
failureAudit,
|
||||
sameSubject,
|
||||
} from './executionSupport';
|
||||
|
||||
export function createLocalIdentityCredentialCommandRunner(
|
||||
candidateDependencies: LocalIdentityCredentialCommandRunnerDependencies = {
|
||||
openDatabase: openLocalSqliteIdentityCredentialAdministrationDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
createService: createLocalIdentityCredentialAdministrationService,
|
||||
createDelivery: (directory) =>
|
||||
new FileLocalCredentialAdministrationDelivery(directory),
|
||||
createPepperProvider: (directory) =>
|
||||
new LocalOwnerPepperKeyringFileProvider(directory),
|
||||
randomBytes: cryptoRandomBytes,
|
||||
now: Date.now,
|
||||
},
|
||||
): LocalIdentityCredentialCommandRunner {
|
||||
const adapters = dependencies(candidateDependencies);
|
||||
return Object.freeze({
|
||||
async run(commandFilePath: string) {
|
||||
const command = readCommandFile(commandFilePath);
|
||||
const database = await adapters.openDatabase({
|
||||
databasePath: command.options.databasePath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
||||
try {
|
||||
try {
|
||||
authenticated = await adapters.authenticate(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.databasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_identity_admin',
|
||||
});
|
||||
await activateFence(database, authenticated);
|
||||
const commandNowMs = clock(adapters.now);
|
||||
const service = adapters.createService(
|
||||
database.projectPolicy,
|
||||
database.identityCredentialAdministration,
|
||||
{ now: () => commandNowMs },
|
||||
);
|
||||
|
||||
if (command.operation === 'identity.inspect') {
|
||||
const inspectCommand =
|
||||
command as Readonly<LocalIdentityInspectionCommand>;
|
||||
const result = await service.inspectIdentity({
|
||||
projectId: inspectCommand.request.projectId,
|
||||
target: inspectCommand.request.target,
|
||||
auditEventId: inspectCommand.request.auditEventId,
|
||||
requestId: inspectCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
if (!result.identity) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: inspectCommand.operation,
|
||||
projectId: inspectCommand.request.projectId,
|
||||
found: false as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: inspectCommand.operation,
|
||||
projectId: inspectCommand.request.projectId,
|
||||
found: true as const,
|
||||
target: result.identity.subject,
|
||||
version: result.identity.version,
|
||||
identityStatus: result.identity.status,
|
||||
createdAtMs: result.identity.createdAtMs,
|
||||
updatedAtMs: result.identity.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (command.operation === 'credential.inspect') {
|
||||
const inspectCommand =
|
||||
command as Readonly<LocalApiCredentialInspectionCommand>;
|
||||
const result = await service.inspectCredential({
|
||||
projectId: inspectCommand.request.projectId,
|
||||
credentialId: inspectCommand.request.credentialId,
|
||||
auditEventId: inspectCommand.request.auditEventId,
|
||||
requestId: inspectCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
if (!result.credential) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: inspectCommand.operation,
|
||||
projectId: inspectCommand.request.projectId,
|
||||
found: false as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: inspectCommand.operation,
|
||||
projectId: inspectCommand.request.projectId,
|
||||
found: true as const,
|
||||
credentialId: result.credential.credentialId,
|
||||
target: result.credential.subject,
|
||||
version: result.credential.version,
|
||||
state: result.credential.state,
|
||||
subjectStatus: result.credential.subjectStatus,
|
||||
createdAtMs: result.credential.createdAtMs,
|
||||
notBeforeAtMs: result.credential.notBeforeAtMs,
|
||||
expiresAtMs: result.credential.expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (command.operation.startsWith('identity.')) {
|
||||
const identityCommand =
|
||||
command as Readonly<LocalIdentityAdministrationCommand>;
|
||||
const result = await service.changeIdentity({
|
||||
projectId: identityCommand.request.projectId,
|
||||
operation: identityCommand.operation.slice('identity.'.length) as
|
||||
| 'register'
|
||||
| 'enable'
|
||||
| 'disable',
|
||||
target: identityCommand.request.target,
|
||||
expectedCurrentVersion:
|
||||
identityCommand.request.expectedCurrentVersion,
|
||||
mutationId: identityCommand.request.mutationId,
|
||||
requestId: identityCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: identityCommand.operation,
|
||||
status: result.status,
|
||||
projectId: identityCommand.request.projectId,
|
||||
target: result.identity.subject,
|
||||
version: result.identity.version,
|
||||
identityStatus: result.identity.status,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
command.operation === 'credential.issue' ||
|
||||
command.operation === 'credential.rotate'
|
||||
) {
|
||||
const credentialCommand =
|
||||
command as Readonly<LocalApiCredentialIssueCommand>;
|
||||
const active = await database.ownerPepper.resolveActive();
|
||||
if (!active) {
|
||||
throw new LocalIdentityCredentialCommandPepperUnavailableError();
|
||||
}
|
||||
const key = await database.ownerPepper.resolveKey(
|
||||
active.activePepperKeyId,
|
||||
);
|
||||
const pepper = adapters
|
||||
.createPepperProvider(
|
||||
credentialCommand.options.ownerPepperKeyringDirectory,
|
||||
)
|
||||
.resolve(active.activePepperKeyId);
|
||||
if (
|
||||
!key ||
|
||||
key.state !== 'active' ||
|
||||
key.materialDigest !== active.materialDigest ||
|
||||
!pepper ||
|
||||
pepper.summary.digest !== active.materialDigest
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandPepperUnavailableError();
|
||||
}
|
||||
const material = adapters.randomBytes(API_CREDENTIAL_SECRET_BYTES);
|
||||
if (
|
||||
!Buffer.isBuffer(material) ||
|
||||
material.byteLength !== API_CREDENTIAL_SECRET_BYTES
|
||||
) {
|
||||
material?.fill?.(0);
|
||||
throw new LocalIdentityCredentialCommandConfigurationError(
|
||||
'random source returned invalid credential material',
|
||||
);
|
||||
}
|
||||
let prepared: Readonly<LocalCredentialAdministrationDeliveryRecord>;
|
||||
try {
|
||||
const delivery = adapters.createDelivery(
|
||||
credentialCommand.options.credentialDeliveryDirectory,
|
||||
);
|
||||
prepared = delivery.prepare({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-managed-credential-delivery',
|
||||
mutationId: credentialCommand.request.mutationId,
|
||||
requestId: credentialCommand.request.requestId,
|
||||
projectId: credentialCommand.request.projectId,
|
||||
subject: credentialCommand.request.target,
|
||||
credentialId: credentialCommand.request.credentialId,
|
||||
secret: material.toString('base64url'),
|
||||
notBeforeAtMs: commandNowMs,
|
||||
expiresAtMs:
|
||||
commandNowMs + credentialCommand.request.lifetimeMs,
|
||||
});
|
||||
const deliveryDigest = delivery.digest(prepared);
|
||||
const result = await service.changeCredential({
|
||||
projectId: credentialCommand.request.projectId,
|
||||
operation:
|
||||
credentialCommand.operation === 'credential.issue'
|
||||
? 'issue'
|
||||
: 'rotate',
|
||||
credentialId: credentialCommand.request.credentialId,
|
||||
target: credentialCommand.request.target,
|
||||
expectedCurrentVersion:
|
||||
credentialCommand.request.expectedCurrentVersion,
|
||||
pepperKeyId: active.activePepperKeyId,
|
||||
secretDigest: apiCredentialSecretDigest(
|
||||
pepper.pepper,
|
||||
credentialCommand.request.credentialId,
|
||||
prepared.secret,
|
||||
),
|
||||
deliveryDigest,
|
||||
notBeforeAtMs: prepared.notBeforeAtMs,
|
||||
expiresAtMs: prepared.expiresAtMs,
|
||||
mutationId: credentialCommand.request.mutationId,
|
||||
requestId: credentialCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
const published = delivery.publish(prepared, deliveryDigest);
|
||||
return activeCredentialResult(
|
||||
credentialCommand,
|
||||
result,
|
||||
published,
|
||||
);
|
||||
} finally {
|
||||
material.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (command.operation === 'credential.revoke') {
|
||||
const credentialCommand =
|
||||
command as Readonly<LocalApiCredentialRevokeCommand>;
|
||||
const current: Readonly<ApiCredentialRecord> | null =
|
||||
await database.apiCredentials.resolve(
|
||||
credentialCommand.request.credentialId,
|
||||
);
|
||||
if (
|
||||
!current ||
|
||||
!sameSubject(current.subject, credentialCommand.request.target)
|
||||
) {
|
||||
throw new LocalIdentityCredentialCommandCurrentCredentialError();
|
||||
}
|
||||
const result = await service.changeCredential({
|
||||
projectId: credentialCommand.request.projectId,
|
||||
operation: 'revoke',
|
||||
credentialId: credentialCommand.request.credentialId,
|
||||
target: credentialCommand.request.target,
|
||||
expectedCurrentVersion:
|
||||
credentialCommand.request.expectedCurrentVersion,
|
||||
pepperKeyId: current.pepperKeyId,
|
||||
mutationId: credentialCommand.request.mutationId,
|
||||
requestId: credentialCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: credentialCommand.operation,
|
||||
status: result.status,
|
||||
projectId: credentialCommand.request.projectId,
|
||||
target: result.credential.subject,
|
||||
credentialId: result.credential.credentialId,
|
||||
version: result.credential.version,
|
||||
state: result.credential.state,
|
||||
expiresAtMs: result.credential.expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
const acknowledgementCommand =
|
||||
command as Readonly<LocalCredentialDeliveryAcknowledgeCommand>;
|
||||
const result = await service.acknowledgeCredentialDelivery({
|
||||
projectId: acknowledgementCommand.request.projectId,
|
||||
credentialMutationId:
|
||||
acknowledgementCommand.request.credentialMutationId,
|
||||
expectedDeliveryDigest:
|
||||
acknowledgementCommand.request.expectedDeliveryDigest,
|
||||
mutationId: acknowledgementCommand.request.mutationId,
|
||||
requestId: acknowledgementCommand.request.requestId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
const acknowledgement = result.acknowledgement;
|
||||
if (
|
||||
acknowledgement.credentialMutationId !==
|
||||
acknowledgementCommand.request.credentialMutationId ||
|
||||
acknowledgement.acknowledgementMutationId !==
|
||||
acknowledgementCommand.request.mutationId ||
|
||||
acknowledgement.projectId !==
|
||||
acknowledgementCommand.request.projectId ||
|
||||
acknowledgement.deliveryDigest !==
|
||||
acknowledgementCommand.request.expectedDeliveryDigest
|
||||
) {
|
||||
throw new LocalIdentityCredentialAdministrationUnavailableError();
|
||||
}
|
||||
const cleanup = adapters
|
||||
.createDelivery(
|
||||
acknowledgementCommand.options.credentialDeliveryDirectory,
|
||||
)
|
||||
.removeAcknowledged(
|
||||
acknowledgement.credentialMutationId,
|
||||
acknowledgement.deliveryDigest,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: acknowledgementCommand.operation,
|
||||
status: result.status,
|
||||
projectId: acknowledgement.projectId,
|
||||
credentialMutationId: acknowledgement.credentialMutationId,
|
||||
acknowledgementMutationId:
|
||||
acknowledgement.acknowledgementMutationId,
|
||||
deliveryDigest: acknowledgement.deliveryDigest,
|
||||
cleanup,
|
||||
});
|
||||
} catch (error) {
|
||||
const audit = failureAudit(
|
||||
command,
|
||||
authenticated,
|
||||
error,
|
||||
clock(adapters.now),
|
||||
);
|
||||
if (audit) {
|
||||
await database.identityCredentialAdministration.record(audit);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalIdentityCredentialCommandFile(
|
||||
commandFilePath: string,
|
||||
): Promise<Readonly<LocalIdentityCredentialCommandResult>> {
|
||||
return createLocalIdentityCredentialCommandRunner().run(commandFilePath);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalIdentityCredentialCommandFile } from './identityCredentialCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-identity run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_IDENTITY_CREDENTIAL_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalIdentityCredentialCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_IDENTITY_CREDENTIAL_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Identity credential command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,23 @@
|
||||
// Security management owns identity and API credential administration commands.
|
||||
export {
|
||||
LocalIdentityCredentialCommandConfigurationError,
|
||||
LocalIdentityCredentialCommandCurrentCredentialError,
|
||||
LocalIdentityCredentialCommandPepperUnavailableError,
|
||||
} from './identity-credential-command/contracts';
|
||||
export type {
|
||||
LocalApiCredentialInspectionCommand,
|
||||
LocalApiCredentialIssueCommand,
|
||||
LocalApiCredentialRevokeCommand,
|
||||
LocalCredentialDeliveryAcknowledgeCommand,
|
||||
LocalIdentityAdministrationCommand,
|
||||
LocalIdentityCredentialCommand,
|
||||
LocalIdentityCredentialCommandOptions,
|
||||
LocalIdentityCredentialCommandResult,
|
||||
LocalIdentityCredentialCommandRunner,
|
||||
LocalIdentityCredentialCommandRunnerDependencies,
|
||||
LocalIdentityInspectionCommand,
|
||||
} from './identity-credential-command/contracts';
|
||||
export {
|
||||
createLocalIdentityCredentialCommandRunner,
|
||||
runLocalIdentityCredentialCommandFile,
|
||||
} from './identity-credential-command/runner';
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalProjectPolicyCommandFile } from './projectPolicyCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-policy run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_PROJECT_POLICY_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalProjectPolicyCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_PROJECT_POLICY_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Project policy command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalSecretCommandFile } from './secretCommand';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-secret run --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const commandFilePath = argv[2];
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
argv[0] !== 'run' ||
|
||||
argv[1] !== '--command-file' ||
|
||||
commandFilePath === undefined
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_SECRET_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await runLocalSecretCommandFile(commandFilePath);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
} catch (error) {
|
||||
const candidate = error as {
|
||||
readonly code?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code:
|
||||
typeof candidate.code === 'string'
|
||||
? candidate.code
|
||||
: 'LOCAL_SECRET_CLI_FAILED',
|
||||
name: typeof candidate.name === 'string' ? candidate.name : 'Error',
|
||||
message:
|
||||
typeof candidate.message === 'string'
|
||||
? candidate.message
|
||||
: 'Local Secret command failed',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user