mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
498 lines
15 KiB
TypeScript
498 lines
15 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import path from 'node:path';
|
|
|
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
|
|
|
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;
|
|
completionFence?: Readonly<{
|
|
expectedInstanceHeadDigest: string;
|
|
expectedCompletionDigest: string;
|
|
}>;
|
|
}>;
|
|
|
|
export interface LocalServiceManagerIntent {
|
|
readonly schemaVersion: 1 | 2;
|
|
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,
|
|
schemaVersion: 1 | 2,
|
|
): LocalServiceManagerIntentLineage {
|
|
const lineage = object(value, 'lineage');
|
|
if (lineage.mode === 'fresh') {
|
|
exact(lineage, ['mode'], 'lineage');
|
|
return Object.freeze({ mode: 'fresh' as const });
|
|
}
|
|
exact(
|
|
lineage,
|
|
[
|
|
'cutoverId',
|
|
...(schemaVersion === 2 ? ['completionFence'] : []),
|
|
'expectedActivationDigest',
|
|
'generation',
|
|
'mode',
|
|
'previousRecordDigest',
|
|
],
|
|
'lineage',
|
|
);
|
|
const completionFence =
|
|
schemaVersion === 2
|
|
? object(lineage.completionFence, 'completion fence')
|
|
: undefined;
|
|
if (completionFence !== undefined) {
|
|
exact(
|
|
completionFence,
|
|
['expectedCompletionDigest', 'expectedInstanceHeadDigest'],
|
|
'completion fence',
|
|
);
|
|
}
|
|
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) ||
|
|
(completionFence !== undefined &&
|
|
(typeof completionFence.expectedInstanceHeadDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(completionFence.expectedInstanceHeadDigest) ||
|
|
typeof completionFence.expectedCompletionDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(completionFence.expectedCompletionDigest)))
|
|
) {
|
|
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,
|
|
...(completionFence === undefined
|
|
? {}
|
|
: {
|
|
completionFence: Object.freeze({
|
|
expectedInstanceHeadDigest:
|
|
completionFence.expectedInstanceHeadDigest as string,
|
|
expectedCompletionDigest:
|
|
completionFence.expectedCompletionDigest as string,
|
|
}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
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;
|
|
if (intent.schemaVersion !== 1 && intent.schemaVersion !== 2) {
|
|
configurationError('service manager intent schema version is invalid');
|
|
}
|
|
const schemaVersion = intent.schemaVersion;
|
|
const lineage = normalizedLineage(intent.lineage, schemaVersion);
|
|
if (
|
|
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' &&
|
|
(schemaVersion === 2
|
|
? lineage.generation < 2 || intent.action !== 'restart'
|
|
: (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',
|
|
);
|
|
}
|
|
if (schemaVersion === 2 && lineage.mode !== 'adopted') {
|
|
configurationError(
|
|
'service manager v2 intent requires adopted completion lineage',
|
|
);
|
|
}
|
|
const payload = Object.freeze({
|
|
schemaVersion,
|
|
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,
|
|
}),
|
|
});
|
|
}
|