mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
177 lines
5.6 KiB
TypeScript
177 lines
5.6 KiB
TypeScript
import crypto from 'node:crypto';
|
|
|
|
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
|
|
|
import type { LocalApplicationProcessConfig } from './processConfig';
|
|
|
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
const CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/;
|
|
|
|
interface LegacySilenceCommitmentPayload {
|
|
readonly schemaVersion: 1;
|
|
readonly kind: 'qinglong3-local-legacy-silence-commitment';
|
|
readonly state: 'legacy_stopped';
|
|
readonly cutoverId: string;
|
|
readonly profile: 'edge' | 'standalone';
|
|
readonly instanceId: string;
|
|
readonly activationDigest: string;
|
|
readonly previousRecordDigest: string;
|
|
readonly requestedAtMs: number;
|
|
readonly observedAtMs: number;
|
|
readonly controller: Readonly<{
|
|
kind: 'docker';
|
|
endpointDigest: string;
|
|
legacyContainerId: string;
|
|
legacyContainerIdentityDigest: string;
|
|
legacySourceBindingDigest: string;
|
|
}>;
|
|
}
|
|
|
|
interface LegacySilenceCommitment extends LegacySilenceCommitmentPayload {
|
|
readonly commitmentDigest: string;
|
|
}
|
|
|
|
export class LocalApplicationCutoverCommitmentError extends TypeError {
|
|
readonly code = 'QL3_LOCAL_APPLICATION_CUTOVER_COMMITMENT_INVALID';
|
|
|
|
constructor(message: string) {
|
|
super(`Local application cutover commitment is invalid: ${message}`);
|
|
this.name = 'LocalApplicationCutoverCommitmentError';
|
|
}
|
|
}
|
|
|
|
function invalid(message: string): never {
|
|
throw new LocalApplicationCutoverCommitmentError(message);
|
|
}
|
|
|
|
function object(value: unknown, label: string): Record<string, unknown> {
|
|
if (
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value) ||
|
|
(Object.getPrototypeOf(value) !== Object.prototype &&
|
|
Object.getPrototypeOf(value) !== null)
|
|
) {
|
|
invalid(`${label} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exact(
|
|
value: Record<string, unknown>,
|
|
expected: readonly string[],
|
|
label: string,
|
|
): void {
|
|
const actual = Object.keys(value).sort();
|
|
const keys = [...expected].sort();
|
|
if (
|
|
actual.length !== keys.length ||
|
|
actual.some((key, index) => key !== keys[index])
|
|
) {
|
|
invalid(`${label} shape is invalid`);
|
|
}
|
|
}
|
|
|
|
function digest(value: unknown): string {
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(JSON.stringify(value), 'utf8')
|
|
.digest('hex');
|
|
}
|
|
|
|
function parseLegacySilenceCommitment(
|
|
value: unknown,
|
|
): Readonly<LegacySilenceCommitment> {
|
|
const commitment = object(value, 'commitment');
|
|
exact(
|
|
commitment,
|
|
[
|
|
'activationDigest',
|
|
'commitmentDigest',
|
|
'controller',
|
|
'cutoverId',
|
|
'instanceId',
|
|
'kind',
|
|
'observedAtMs',
|
|
'previousRecordDigest',
|
|
'profile',
|
|
'requestedAtMs',
|
|
'schemaVersion',
|
|
'state',
|
|
],
|
|
'commitment',
|
|
);
|
|
const controller = object(commitment.controller, 'controller');
|
|
exact(
|
|
controller,
|
|
[
|
|
'endpointDigest',
|
|
'kind',
|
|
'legacyContainerId',
|
|
'legacyContainerIdentityDigest',
|
|
'legacySourceBindingDigest',
|
|
],
|
|
'controller',
|
|
);
|
|
if (
|
|
commitment.schemaVersion !== 1 ||
|
|
commitment.kind !== 'qinglong3-local-legacy-silence-commitment' ||
|
|
commitment.state !== 'legacy_stopped' ||
|
|
typeof commitment.cutoverId !== 'string' ||
|
|
!ID_PATTERN.test(commitment.cutoverId) ||
|
|
(commitment.profile !== 'edge' && commitment.profile !== 'standalone') ||
|
|
typeof commitment.instanceId !== 'string' ||
|
|
!ID_PATTERN.test(commitment.instanceId) ||
|
|
typeof commitment.activationDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(commitment.activationDigest) ||
|
|
typeof commitment.previousRecordDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(commitment.previousRecordDigest) ||
|
|
!Number.isSafeInteger(commitment.requestedAtMs) ||
|
|
(commitment.requestedAtMs as number) < 0 ||
|
|
!Number.isSafeInteger(commitment.observedAtMs) ||
|
|
(commitment.observedAtMs as number) <
|
|
(commitment.requestedAtMs as number) ||
|
|
controller.kind !== 'docker' ||
|
|
typeof controller.endpointDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(controller.endpointDigest) ||
|
|
typeof controller.legacyContainerId !== 'string' ||
|
|
!CONTAINER_ID_PATTERN.test(controller.legacyContainerId) ||
|
|
typeof controller.legacyContainerIdentityDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(controller.legacyContainerIdentityDigest) ||
|
|
typeof controller.legacySourceBindingDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(controller.legacySourceBindingDigest) ||
|
|
typeof commitment.commitmentDigest !== 'string' ||
|
|
!DIGEST_PATTERN.test(commitment.commitmentDigest)
|
|
) {
|
|
invalid('commitment fields are invalid');
|
|
}
|
|
const { commitmentDigest, ...payload } = commitment;
|
|
if (digest(payload) !== commitmentDigest) {
|
|
invalid('commitment digest does not match');
|
|
}
|
|
return commitment as unknown as Readonly<LegacySilenceCommitment>;
|
|
}
|
|
|
|
export function verifyLocalApplicationCutoverCommitment(
|
|
config: Readonly<LocalApplicationProcessConfig>,
|
|
): void {
|
|
if (config.storage.mode === 'fresh') return;
|
|
if (config.cutover === undefined) {
|
|
invalid('adopted storage requires a v3 cutover commitment');
|
|
}
|
|
const commitment = parseLegacySilenceCommitment(
|
|
readPrivateLocalCommandFile(config.cutover.commitmentPath),
|
|
);
|
|
if (
|
|
commitment.commitmentDigest !==
|
|
config.cutover.expectedCommitmentDigest ||
|
|
commitment.cutoverId !== config.cutover.cutoverId ||
|
|
commitment.profile !== config.profile ||
|
|
commitment.instanceId !== config.instanceId ||
|
|
commitment.activationDigest !== config.storage.expectedActivationDigest
|
|
) {
|
|
invalid('commitment no longer matches the reviewed application identity');
|
|
}
|
|
}
|