mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): preserve adopted compose lineage
This commit is contained in:
@@ -383,7 +383,7 @@ jobs:
|
|||||||
- name: Verify non-root identity and architecture
|
- name: Verify non-root identity and architecture
|
||||||
env:
|
env:
|
||||||
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
|
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
|
||||||
EXPECTED: ${{ matrix.image_arch }} 65532:65532 2 44 44 44 1
|
EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 50 50 50 1
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.local.application-config"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-min"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-max"}} {{index .Config.Labels "io.qinglong.local.sqlite-write-contract"}} {{index .Config.Labels "io.qinglong.local.compose-selection"}}' "${IMAGE}")"
|
actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.local.application-config"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-min"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-max"}} {{index .Config.Labels "io.qinglong.local.sqlite-write-contract"}} {{index .Config.Labels "io.qinglong.local.compose-selection"}}' "${IMAGE}")"
|
||||||
|
|||||||
@@ -142,10 +142,10 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Local Application" \
|
|||||||
org.opencontainers.image.version="3.0.0-alpha.0" \
|
org.opencontainers.image.version="3.0.0-alpha.0" \
|
||||||
io.qinglong.profile="edge,standalone" \
|
io.qinglong.profile="edge,standalone" \
|
||||||
io.qinglong.ai="excluded" \
|
io.qinglong.ai="excluded" \
|
||||||
io.qinglong.local.application-config="2" \
|
io.qinglong.local.application-config="2,3,4" \
|
||||||
io.qinglong.local.sqlite-contract-min="44" \
|
io.qinglong.local.sqlite-contract-min="50" \
|
||||||
io.qinglong.local.sqlite-contract-max="44" \
|
io.qinglong.local.sqlite-contract-max="50" \
|
||||||
io.qinglong.local.sqlite-write-contract="44" \
|
io.qinglong.local.sqlite-write-contract="50" \
|
||||||
io.qinglong.local.compose-selection="1"
|
io.qinglong.local.compose-selection="1"
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -237,7 +237,32 @@ export function prepareLocalDeploymentAdoptedBundle(
|
|||||||
[material.paths.pluginActivation, 'pluginActivationRoot'],
|
[material.paths.pluginActivation, 'pluginActivationRoot'],
|
||||||
...(material.composeSelection === null
|
...(material.composeSelection === null
|
||||||
? []
|
? []
|
||||||
: ([[material.paths.composeRevisions, 'composeRevisionRoot']] as const)),
|
: ([
|
||||||
|
[material.paths.composeRevisions, 'composeRevisionRoot'],
|
||||||
|
[material.paths.composeRollouts, 'composeRolloutRoot'],
|
||||||
|
[material.paths.composeRolloutBackups, 'composeRolloutBackupRoot'],
|
||||||
|
[material.paths.composeRestores, 'composeRestoreRoot'],
|
||||||
|
[
|
||||||
|
material.paths.composeRestoreSafeguards,
|
||||||
|
'composeRestoreSafeguardRoot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
material.paths.composeEvidenceCollections,
|
||||||
|
'composeEvidenceCollectionRoot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
material.paths.composeCollectedEvidence,
|
||||||
|
'composeCollectedEvidenceRoot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
material.paths.composeCollectedRolloutBackups,
|
||||||
|
'composeCollectedRolloutBackupRoot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
material.paths.composeCollectedRestoreSafeguards,
|
||||||
|
'composeCollectedRestoreSafeguardRoot',
|
||||||
|
],
|
||||||
|
] as const)),
|
||||||
] as const;
|
] as const;
|
||||||
const directoryStatuses = directoryPaths.map(([directory, label]) =>
|
const directoryStatuses = directoryPaths.map(([directory, label]) =>
|
||||||
ensurePrivateDirectory(directory, identity.uid, label),
|
ensurePrivateDirectory(directory, identity.uid, label),
|
||||||
@@ -352,7 +377,32 @@ export function verifyLocalDeploymentAdoptedBundle(
|
|||||||
[material.paths.pluginActivation, 'pluginActivationRoot'],
|
[material.paths.pluginActivation, 'pluginActivationRoot'],
|
||||||
...(material.composeSelection === null
|
...(material.composeSelection === null
|
||||||
? []
|
? []
|
||||||
: ([[material.paths.composeRevisions, 'composeRevisionRoot']] as const)),
|
: ([
|
||||||
|
[material.paths.composeRevisions, 'composeRevisionRoot'],
|
||||||
|
[material.paths.composeRollouts, 'composeRolloutRoot'],
|
||||||
|
[material.paths.composeRolloutBackups, 'composeRolloutBackupRoot'],
|
||||||
|
[material.paths.composeRestores, 'composeRestoreRoot'],
|
||||||
|
[
|
||||||
|
material.paths.composeRestoreSafeguards,
|
||||||
|
'composeRestoreSafeguardRoot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
material.paths.composeEvidenceCollections,
|
||||||
|
'composeEvidenceCollectionRoot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
material.paths.composeCollectedEvidence,
|
||||||
|
'composeCollectedEvidenceRoot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
material.paths.composeCollectedRolloutBackups,
|
||||||
|
'composeCollectedRolloutBackupRoot',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
material.paths.composeCollectedRestoreSafeguards,
|
||||||
|
'composeCollectedRestoreSafeguardRoot',
|
||||||
|
],
|
||||||
|
] as const)),
|
||||||
] as const) {
|
] as const) {
|
||||||
validatePrivateDirectory(directory, identity.uid, label);
|
validatePrivateDirectory(directory, identity.uid, label);
|
||||||
}
|
}
|
||||||
@@ -420,7 +470,7 @@ export function verifyLocalDeploymentAdoptedBundle(
|
|||||||
}),
|
}),
|
||||||
directories: Object.freeze({
|
directories: Object.freeze({
|
||||||
created: 0,
|
created: 0,
|
||||||
existing: material.composeSelection === null ? 4 : 5,
|
existing: material.composeSelection === null ? 4 : 13,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export interface LocalDeploymentAdoptedBundlePaths {
|
|||||||
readonly composeSelection: string;
|
readonly composeSelection: string;
|
||||||
readonly composeRevisions: string;
|
readonly composeRevisions: string;
|
||||||
readonly composeRevision: string;
|
readonly composeRevision: string;
|
||||||
|
readonly composeRollouts: string;
|
||||||
|
readonly composeRolloutBackups: string;
|
||||||
|
readonly composeRestores: string;
|
||||||
|
readonly composeRestoreSafeguards: string;
|
||||||
|
readonly composeEvidenceCollections: string;
|
||||||
|
readonly composeCollectedEvidence: string;
|
||||||
|
readonly composeCollectedRolloutBackups: string;
|
||||||
|
readonly composeCollectedRestoreSafeguards: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalDeploymentAdoptedEvidence {
|
export interface LocalDeploymentAdoptedEvidence {
|
||||||
@@ -316,6 +324,22 @@ export function adoptedBundlePaths(
|
|||||||
composeSelection: path.join(service, 'compose.image.yaml'),
|
composeSelection: path.join(service, 'compose.image.yaml'),
|
||||||
composeRevisions: path.join(service, 'revisions'),
|
composeRevisions: path.join(service, 'revisions'),
|
||||||
composeRevision: path.join(service, 'revisions', '1.yaml'),
|
composeRevision: path.join(service, 'revisions', '1.yaml'),
|
||||||
|
composeRollouts: path.join(service, 'rollouts'),
|
||||||
|
composeRolloutBackups: path.join(service, 'rollout-backups'),
|
||||||
|
composeRestores: path.join(service, 'restores'),
|
||||||
|
composeRestoreSafeguards: path.join(service, 'restore-safeguards'),
|
||||||
|
composeEvidenceCollections: path.join(service, 'evidence-collections'),
|
||||||
|
composeCollectedEvidence: path.join(service, 'collected-evidence'),
|
||||||
|
composeCollectedRolloutBackups: path.join(
|
||||||
|
service,
|
||||||
|
'collected-evidence',
|
||||||
|
'rollout-backups',
|
||||||
|
),
|
||||||
|
composeCollectedRestoreSafeguards: path.join(
|
||||||
|
service,
|
||||||
|
'collected-evidence',
|
||||||
|
'restore-safeguards',
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
type LocalSqliteRolloutBackupOptions,
|
type LocalSqliteRolloutBackupOptions,
|
||||||
} from '@qinglong/local-sqlite/rollout-safety';
|
} from '@qinglong/local-sqlite/rollout-safety';
|
||||||
|
|
||||||
import { preflightLocalDeploymentCompose } from './composePreflight';
|
import {
|
||||||
|
preflightLocalDeploymentCompose,
|
||||||
|
type LocalDeploymentComposePreflightDependencies,
|
||||||
|
} from './composePreflight';
|
||||||
import { evidenceDigest, inspectCollectedEvidence } from './composeEvidence';
|
import { evidenceDigest, inspectCollectedEvidence } from './composeEvidence';
|
||||||
import {
|
import {
|
||||||
inspectActiveComposeImageSelection,
|
inspectActiveComposeImageSelection,
|
||||||
@@ -43,8 +46,15 @@ import {
|
|||||||
deploymentPaths,
|
deploymentPaths,
|
||||||
type LocalDeploymentPaths,
|
type LocalDeploymentPaths,
|
||||||
} from '../foundation/render';
|
} from '../foundation/render';
|
||||||
|
import {
|
||||||
|
assertLocalDeploymentComposeLineageReceipt,
|
||||||
|
inspectLocalDeploymentComposeLineage,
|
||||||
|
normalizeLocalDeploymentComposeLineageReceipt,
|
||||||
|
type LocalDeploymentComposeLineage,
|
||||||
|
type LocalDeploymentComposeLineageReceipt,
|
||||||
|
} from './composeLineage';
|
||||||
|
|
||||||
const RECEIPT_SCHEMA = 'qinglong/local-compose-rollout-receipt@v2';
|
const RECEIPT_SCHEMA = 'qinglong/local-compose-rollout-receipt@v3';
|
||||||
const CONTAINER_ID_PATTERN = /^[0-9a-f]{12,64}$/;
|
const CONTAINER_ID_PATTERN = /^[0-9a-f]{12,64}$/;
|
||||||
const UUID_V4_PATTERN =
|
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}$/;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
@@ -59,6 +69,7 @@ export interface LocalDeploymentComposeApplyDependencies {
|
|||||||
readonly createBackup?: typeof createLocalSqliteRolloutBackup;
|
readonly createBackup?: typeof createLocalSqliteRolloutBackup;
|
||||||
readonly inspectBackup?: typeof inspectLocalSqliteRolloutBackup;
|
readonly inspectBackup?: typeof inspectLocalSqliteRolloutBackup;
|
||||||
readonly openChangeObserver?: typeof openLocalSqliteChangeObserver;
|
readonly openChangeObserver?: typeof openLocalSqliteChangeObserver;
|
||||||
|
readonly auditSqlite?: LocalDeploymentComposePreflightDependencies['auditSqlite'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActiveEvidence {
|
interface ActiveEvidence {
|
||||||
@@ -88,6 +99,7 @@ interface RolloutReceipt {
|
|||||||
readonly attemptedGeneration: number;
|
readonly attemptedGeneration: number;
|
||||||
readonly recordedAtMs: number;
|
readonly recordedAtMs: number;
|
||||||
readonly healthEventDigest: string | null;
|
readonly healthEventDigest: string | null;
|
||||||
|
readonly lineage: Readonly<LocalDeploymentComposeLineageReceipt>;
|
||||||
readonly sqlite: Readonly<RolloutSqliteReceipt>;
|
readonly sqlite: Readonly<RolloutSqliteReceipt>;
|
||||||
readonly result: Readonly<LocalDeploymentComposeApplyResult>;
|
readonly result: Readonly<LocalDeploymentComposeApplyResult>;
|
||||||
}
|
}
|
||||||
@@ -181,6 +193,7 @@ function inspectRunningContainer(
|
|||||||
output: string,
|
output: string,
|
||||||
containerId: string,
|
containerId: string,
|
||||||
selection: Readonly<ComposeImageSelection>,
|
selection: Readonly<ComposeImageSelection>,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): boolean {
|
): boolean {
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -207,6 +220,19 @@ function inspectRunningContainer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
const labels = candidate?.Config?.Labels;
|
||||||
|
const lineageLabelsValid =
|
||||||
|
lineage.mode === 'fresh' ||
|
||||||
|
(labels?.['io.qinglong.deployment.mode'] === 'adopted' &&
|
||||||
|
labels['io.qinglong.deployment.profile'] === lineage.profile &&
|
||||||
|
labels['io.qinglong.deployment.instance'] === lineage.instanceId &&
|
||||||
|
labels['io.qinglong.deployment.bundle'] === lineage.bundleId &&
|
||||||
|
labels['io.qinglong.application.config'] ===
|
||||||
|
lineage.receipt.applicationConfigDigest &&
|
||||||
|
labels['io.qinglong.data.commit'] ===
|
||||||
|
lineage.receipt.legacyDataApplicationCommitDigest &&
|
||||||
|
labels['io.qinglong.data.receipt'] ===
|
||||||
|
lineage.receipt.legacyDataApplicationReceiptDigest);
|
||||||
return (
|
return (
|
||||||
(candidate?.Id === containerId ||
|
(candidate?.Id === containerId ||
|
||||||
(typeof candidate?.Id === 'string' &&
|
(typeof candidate?.Id === 'string' &&
|
||||||
@@ -214,10 +240,10 @@ function inspectRunningContainer(
|
|||||||
candidate?.State?.Running === true &&
|
candidate?.State?.Running === true &&
|
||||||
candidate.State.Status === 'running' &&
|
candidate.State.Status === 'running' &&
|
||||||
candidate.Config?.Image === selection.image &&
|
candidate.Config?.Image === selection.image &&
|
||||||
candidate.Config.Labels?.['io.qinglong.deployment.generation'] ===
|
labels?.['io.qinglong.deployment.generation'] ===
|
||||||
String(selection.generation) &&
|
String(selection.generation) &&
|
||||||
candidate.Config.Labels?.['io.qinglong.deployment.mutation'] ===
|
labels?.['io.qinglong.deployment.mutation'] === selection.mutationId &&
|
||||||
selection.mutationId &&
|
lineageLabelsValid &&
|
||||||
candidate.HostConfig?.ReadonlyRootfs === true &&
|
candidate.HostConfig?.ReadonlyRootfs === true &&
|
||||||
candidate.HostConfig.NetworkMode === 'none' &&
|
candidate.HostConfig.NetworkMode === 'none' &&
|
||||||
candidate.HostConfig.Privileged !== true
|
candidate.HostConfig.Privileged !== true
|
||||||
@@ -260,6 +286,7 @@ async function observeActive(
|
|||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
selection: Readonly<ComposeImageSelection>,
|
selection: Readonly<ComposeImageSelection>,
|
||||||
profile: LocalDeploymentProfile,
|
profile: LocalDeploymentProfile,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
dependencies: Required<
|
dependencies: Required<
|
||||||
Pick<LocalDeploymentComposeApplyDependencies, 'runDocker' | 'now' | 'wait'>
|
Pick<LocalDeploymentComposeApplyDependencies, 'runDocker' | 'now' | 'wait'>
|
||||||
>,
|
>,
|
||||||
@@ -285,6 +312,7 @@ async function observeActive(
|
|||||||
]),
|
]),
|
||||||
containerId,
|
containerId,
|
||||||
selection,
|
selection,
|
||||||
|
lineage,
|
||||||
);
|
);
|
||||||
if (running) {
|
if (running) {
|
||||||
const evidence = activeEventEvidence(
|
const evidence = activeEventEvidence(
|
||||||
@@ -321,9 +349,10 @@ function backupOptions(
|
|||||||
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
command: Readonly<LocalDeploymentComposeApplyCommand>,
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
profile: LocalDeploymentProfile,
|
profile: LocalDeploymentProfile,
|
||||||
|
databasePath: string,
|
||||||
): Readonly<LocalSqliteRolloutBackupOptions> {
|
): Readonly<LocalSqliteRolloutBackupOptions> {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
databasePath: paths.database,
|
databasePath,
|
||||||
backupPath: backupPathFor(paths, command.request.rolloutId),
|
backupPath: backupPathFor(paths, command.request.rolloutId),
|
||||||
profile,
|
profile,
|
||||||
});
|
});
|
||||||
@@ -389,6 +418,7 @@ async function applyAndObserveCandidate(
|
|||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
selection: Readonly<ComposeImageSelection>,
|
selection: Readonly<ComposeImageSelection>,
|
||||||
profile: LocalDeploymentProfile,
|
profile: LocalDeploymentProfile,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
dependencies: Required<
|
dependencies: Required<
|
||||||
Pick<
|
Pick<
|
||||||
LocalDeploymentComposeApplyDependencies,
|
LocalDeploymentComposeApplyDependencies,
|
||||||
@@ -402,23 +432,26 @@ async function applyAndObserveCandidate(
|
|||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const observer = dependencies.openChangeObserver({
|
const observer = dependencies.openChangeObserver({
|
||||||
databasePath: paths.database,
|
databasePath: lineage.databasePath,
|
||||||
profile,
|
profile,
|
||||||
});
|
});
|
||||||
let evidence: Readonly<ActiveEvidence> | null = null;
|
let evidence: Readonly<ActiveEvidence> | null = null;
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
applyActiveSelection(command, paths, dependencies.runDocker);
|
applyActiveSelection(command, paths, dependencies.runDocker);
|
||||||
evidence = await observeActive(
|
|
||||||
command,
|
|
||||||
paths,
|
|
||||||
selection,
|
|
||||||
profile,
|
|
||||||
dependencies,
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
evidence = null;
|
// Docker may lose the response after creating the exact candidate. The
|
||||||
|
// bounded observation below is the recovery authority; do not issue a
|
||||||
|
// second `compose up` in the same attempt.
|
||||||
}
|
}
|
||||||
|
evidence = await observeActive(
|
||||||
|
command,
|
||||||
|
paths,
|
||||||
|
selection,
|
||||||
|
profile,
|
||||||
|
lineage,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
let writeObservation: SqliteWriteObservation;
|
let writeObservation: SqliteWriteObservation;
|
||||||
try {
|
try {
|
||||||
writeObservation = observer.changed() ? 'changed' : 'unchanged';
|
writeObservation = observer.changed() ? 'changed' : 'unchanged';
|
||||||
@@ -467,6 +500,7 @@ async function readReceipt(
|
|||||||
expectedGeneration: number,
|
expectedGeneration: number,
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
inspectBackup: typeof inspectLocalSqliteRolloutBackup,
|
inspectBackup: typeof inspectLocalSqliteRolloutBackup,
|
||||||
|
expectedLineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Promise<Readonly<LocalDeploymentComposeApplyResult> | null> {
|
): Promise<Readonly<LocalDeploymentComposeApplyResult> | null> {
|
||||||
if (!fs.existsSync(filePath)) return null;
|
if (!fs.existsSync(filePath)) return null;
|
||||||
const stat = fs.lstatSync(filePath);
|
const stat = fs.lstatSync(filePath);
|
||||||
@@ -496,11 +530,15 @@ async function readReceipt(
|
|||||||
const healthKeys = Object.keys(receipt.result?.health ?? {}).sort();
|
const healthKeys = Object.keys(receipt.result?.health ?? {}).sort();
|
||||||
const serviceKeys = Object.keys(receipt.result?.service ?? {}).sort();
|
const serviceKeys = Object.keys(receipt.result?.service ?? {}).sort();
|
||||||
const sqliteKeys = Object.keys(receipt.sqlite ?? {}).sort();
|
const sqliteKeys = Object.keys(receipt.sqlite ?? {}).sort();
|
||||||
|
const lineage = normalizeLocalDeploymentComposeLineageReceipt(
|
||||||
|
receipt.lineage,
|
||||||
|
);
|
||||||
const backupKeys = Object.keys(receipt.sqlite?.backup ?? {}).sort();
|
const backupKeys = Object.keys(receipt.sqlite?.backup ?? {}).sort();
|
||||||
const expectedReceiptKeys = [
|
const expectedReceiptKeys = [
|
||||||
'attemptedGeneration',
|
'attemptedGeneration',
|
||||||
'commandDigest',
|
'commandDigest',
|
||||||
'healthEventDigest',
|
'healthEventDigest',
|
||||||
|
'lineage',
|
||||||
'recordedAtMs',
|
'recordedAtMs',
|
||||||
'result',
|
'result',
|
||||||
'rolloutId',
|
'rolloutId',
|
||||||
@@ -546,6 +584,7 @@ async function readReceipt(
|
|||||||
attemptedGeneration: receipt.attemptedGeneration,
|
attemptedGeneration: receipt.attemptedGeneration,
|
||||||
recordedAtMs: receipt.recordedAtMs,
|
recordedAtMs: receipt.recordedAtMs,
|
||||||
healthEventDigest: receipt.healthEventDigest,
|
healthEventDigest: receipt.healthEventDigest,
|
||||||
|
lineage,
|
||||||
sqlite: {
|
sqlite: {
|
||||||
contractVersion: receipt.sqlite?.contractVersion,
|
contractVersion: receipt.sqlite?.contractVersion,
|
||||||
writeContractVersion: receipt.sqlite?.writeContractVersion,
|
writeContractVersion: receipt.sqlite?.writeContractVersion,
|
||||||
@@ -636,11 +675,15 @@ async function readReceipt(
|
|||||||
) {
|
) {
|
||||||
configurationError('compose rollout receipt drifted');
|
configurationError('compose rollout receipt drifted');
|
||||||
}
|
}
|
||||||
|
assertLocalDeploymentComposeLineageReceipt(
|
||||||
|
receipt.lineage,
|
||||||
|
expectedLineage.receipt,
|
||||||
|
);
|
||||||
if (receipt.sqlite.backup !== null) {
|
if (receipt.sqlite.backup !== null) {
|
||||||
const backupPath = backupPathFor(paths, rolloutId);
|
const backupPath = backupPathFor(paths, rolloutId);
|
||||||
if (fs.existsSync(backupPath)) {
|
if (fs.existsSync(backupPath)) {
|
||||||
const inspected = await inspectBackup({
|
const inspected = await inspectBackup({
|
||||||
databasePath: paths.database,
|
databasePath: expectedLineage.databasePath,
|
||||||
backupPath,
|
backupPath,
|
||||||
profile: receipt.result.profile,
|
profile: receipt.result.profile,
|
||||||
});
|
});
|
||||||
@@ -698,6 +741,7 @@ function publishReceipt(
|
|||||||
rolloutResult: Readonly<LocalDeploymentComposeApplyResult>,
|
rolloutResult: Readonly<LocalDeploymentComposeApplyResult>,
|
||||||
evidence: Readonly<ActiveEvidence> | null,
|
evidence: Readonly<ActiveEvidence> | null,
|
||||||
sqlite: Readonly<RolloutSqliteReceipt>,
|
sqlite: Readonly<RolloutSqliteReceipt>,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
uid: number,
|
uid: number,
|
||||||
): void {
|
): void {
|
||||||
publishExactFile(
|
publishExactFile(
|
||||||
@@ -709,6 +753,7 @@ function publishReceipt(
|
|||||||
attemptedGeneration: command.request.expectedGeneration,
|
attemptedGeneration: command.request.expectedGeneration,
|
||||||
recordedAtMs: command.request.failureRollbackChangedAtMs,
|
recordedAtMs: command.request.failureRollbackChangedAtMs,
|
||||||
healthEventDigest: evidence?.digest ?? null,
|
healthEventDigest: evidence?.digest ?? null,
|
||||||
|
lineage: lineage.receipt,
|
||||||
sqlite,
|
sqlite,
|
||||||
result: rolloutResult,
|
result: rolloutResult,
|
||||||
}),
|
}),
|
||||||
@@ -763,6 +808,12 @@ export async function applyLocalDeploymentCompose(
|
|||||||
identity.uid,
|
identity.uid,
|
||||||
'composeRolloutBackupRoot',
|
'composeRolloutBackupRoot',
|
||||||
);
|
);
|
||||||
|
const lineage = inspectLocalDeploymentComposeLineage(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
identity.uid,
|
||||||
|
identity.gid,
|
||||||
|
command.options.allowRootService,
|
||||||
|
);
|
||||||
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
|
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
|
||||||
configurationError('compose rollout is fenced by an evidence collection');
|
configurationError('compose rollout is fenced by an evidence collection');
|
||||||
}
|
}
|
||||||
@@ -785,6 +836,7 @@ export async function applyLocalDeploymentCompose(
|
|||||||
dependencies.inspectBackup ?? inspectLocalSqliteRolloutBackup;
|
dependencies.inspectBackup ?? inspectLocalSqliteRolloutBackup;
|
||||||
const openChangeObserver =
|
const openChangeObserver =
|
||||||
dependencies.openChangeObserver ?? openLocalSqliteChangeObserver;
|
dependencies.openChangeObserver ?? openLocalSqliteChangeObserver;
|
||||||
|
const auditSqlite = dependencies.auditSqlite;
|
||||||
const receiptPath = path.join(
|
const receiptPath = path.join(
|
||||||
paths.composeRollouts,
|
paths.composeRollouts,
|
||||||
`${command.request.rolloutId}.json`,
|
`${command.request.rolloutId}.json`,
|
||||||
@@ -798,6 +850,7 @@ export async function applyLocalDeploymentCompose(
|
|||||||
command.request.expectedGeneration,
|
command.request.expectedGeneration,
|
||||||
paths,
|
paths,
|
||||||
inspectBackup,
|
inspectBackup,
|
||||||
|
lineage,
|
||||||
);
|
);
|
||||||
if (replay) {
|
if (replay) {
|
||||||
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
||||||
@@ -851,11 +904,11 @@ export async function applyLocalDeploymentCompose(
|
|||||||
expectedGeneration: command.request.expectedGeneration,
|
expectedGeneration: command.request.expectedGeneration,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ runDocker, validateSocket },
|
{ runDocker, validateSocket, auditSqlite },
|
||||||
);
|
);
|
||||||
if (selection.previousGeneration >= 1) {
|
if (selection.previousGeneration >= 1) {
|
||||||
rolloutBackup = await createBackup(
|
rolloutBackup = await createBackup(
|
||||||
backupOptions(command, paths, preflight.profile),
|
backupOptions(command, paths, preflight.profile, lineage.databasePath),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const candidate = await applyAndObserveCandidate(
|
const candidate = await applyAndObserveCandidate(
|
||||||
@@ -863,6 +916,7 @@ export async function applyLocalDeploymentCompose(
|
|||||||
paths,
|
paths,
|
||||||
selection,
|
selection,
|
||||||
preflight.profile,
|
preflight.profile,
|
||||||
|
lineage,
|
||||||
{ runDocker, now, wait, openChangeObserver },
|
{ runDocker, now, wait, openChangeObserver },
|
||||||
);
|
);
|
||||||
const evidence = candidate.evidence;
|
const evidence = candidate.evidence;
|
||||||
@@ -880,6 +934,7 @@ export async function applyLocalDeploymentCompose(
|
|||||||
active,
|
active,
|
||||||
evidence,
|
evidence,
|
||||||
sqliteReceipt(rolloutBackup, writeObservation),
|
sqliteReceipt(rolloutBackup, writeObservation),
|
||||||
|
lineage,
|
||||||
identity.uid,
|
identity.uid,
|
||||||
);
|
);
|
||||||
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
||||||
@@ -899,6 +954,7 @@ export async function applyLocalDeploymentCompose(
|
|||||||
stopped,
|
stopped,
|
||||||
null,
|
null,
|
||||||
sqliteReceipt(null, writeObservation),
|
sqliteReceipt(null, writeObservation),
|
||||||
|
lineage,
|
||||||
identity.uid,
|
identity.uid,
|
||||||
);
|
);
|
||||||
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
||||||
@@ -936,19 +992,30 @@ export async function applyLocalDeploymentCompose(
|
|||||||
options: command.options,
|
options: command.options,
|
||||||
request: { expectedGeneration: selection.generation },
|
request: { expectedGeneration: selection.generation },
|
||||||
},
|
},
|
||||||
{ runDocker, validateSocket },
|
{ runDocker, validateSocket, auditSqlite },
|
||||||
);
|
);
|
||||||
if (command.request.expectedGeneration > 1 && rolloutBackup === null) {
|
if (command.request.expectedGeneration > 1 && rolloutBackup === null) {
|
||||||
rolloutBackup = await inspectBackup(
|
rolloutBackup = await inspectBackup(
|
||||||
backupOptions(command, paths, rollbackPreflight.profile),
|
backupOptions(
|
||||||
|
command,
|
||||||
|
paths,
|
||||||
|
rollbackPreflight.profile,
|
||||||
|
lineage.databasePath,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
applyActiveSelection(rollbackCommand, paths, runDocker);
|
try {
|
||||||
|
applyActiveSelection(rollbackCommand, paths, runDocker);
|
||||||
|
} catch {
|
||||||
|
// As with the candidate, inspect the exact generation after a lost Docker
|
||||||
|
// response instead of blindly recreating the rollback container.
|
||||||
|
}
|
||||||
const rollbackEvidence = await observeActive(
|
const rollbackEvidence = await observeActive(
|
||||||
rollbackCommand,
|
rollbackCommand,
|
||||||
paths,
|
paths,
|
||||||
selection,
|
selection,
|
||||||
rollbackPreflight.profile,
|
rollbackPreflight.profile,
|
||||||
|
lineage,
|
||||||
{ runDocker, now, wait },
|
{ runDocker, now, wait },
|
||||||
);
|
);
|
||||||
if (!rollbackEvidence) {
|
if (!rollbackEvidence) {
|
||||||
@@ -968,6 +1035,7 @@ export async function applyLocalDeploymentCompose(
|
|||||||
rolledBack,
|
rolledBack,
|
||||||
rollbackEvidence,
|
rollbackEvidence,
|
||||||
sqliteReceipt(rolloutBackup, writeObservation),
|
sqliteReceipt(rolloutBackup, writeObservation),
|
||||||
|
lineage,
|
||||||
identity.uid,
|
identity.uid,
|
||||||
);
|
);
|
||||||
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
releaseLock(paths.composeRolloutLock, intent, identity.uid);
|
||||||
|
|||||||
@@ -37,11 +37,18 @@ import {
|
|||||||
deploymentPaths,
|
deploymentPaths,
|
||||||
type LocalDeploymentPaths,
|
type LocalDeploymentPaths,
|
||||||
} from '../foundation/render';
|
} from '../foundation/render';
|
||||||
|
import {
|
||||||
|
assertLocalDeploymentComposeLineageReceipt,
|
||||||
|
inspectLocalDeploymentComposeLineage,
|
||||||
|
normalizeLocalDeploymentComposeLineageReceipt,
|
||||||
|
type LocalDeploymentComposeLineage,
|
||||||
|
type LocalDeploymentComposeLineageReceipt,
|
||||||
|
} from './composeLineage';
|
||||||
|
|
||||||
const PREPARE_SCHEMA = 'qinglong/local-compose-evidence-collection-prepare@v1';
|
const PREPARE_SCHEMA = 'qinglong/local-compose-evidence-collection-prepare@v2';
|
||||||
const COMMIT_SCHEMA = 'qinglong/local-compose-evidence-collection-commit@v1';
|
const COMMIT_SCHEMA = 'qinglong/local-compose-evidence-collection-commit@v2';
|
||||||
const ROLLOUT_RECEIPT_SCHEMA = 'qinglong/local-compose-rollout-receipt@v2';
|
const ROLLOUT_RECEIPT_SCHEMA = 'qinglong/local-compose-rollout-receipt@v3';
|
||||||
const RESTORE_COMMIT_SCHEMA = 'qinglong/local-compose-restore-commit@v1';
|
const RESTORE_COMMIT_SCHEMA = 'qinglong/local-compose-restore-commit@v2';
|
||||||
const UUID_V4_PATTERN =
|
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}$/;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
@@ -61,6 +68,7 @@ interface CollectionPrepareReceipt {
|
|||||||
readonly collectionId: string;
|
readonly collectionId: string;
|
||||||
readonly generation: number;
|
readonly generation: number;
|
||||||
readonly profile: LocalDeploymentProfile;
|
readonly profile: LocalDeploymentProfile;
|
||||||
|
readonly lineage: Readonly<LocalDeploymentComposeLineageReceipt>;
|
||||||
readonly recordedAtMs: number;
|
readonly recordedAtMs: number;
|
||||||
readonly items: readonly Readonly<CollectionItem>[];
|
readonly items: readonly Readonly<CollectionItem>[];
|
||||||
}
|
}
|
||||||
@@ -72,6 +80,7 @@ interface CollectionCommitReceipt {
|
|||||||
readonly collectionId: string;
|
readonly collectionId: string;
|
||||||
readonly generation: number;
|
readonly generation: number;
|
||||||
readonly profile: LocalDeploymentProfile;
|
readonly profile: LocalDeploymentProfile;
|
||||||
|
readonly lineage: Readonly<LocalDeploymentComposeLineageReceipt>;
|
||||||
readonly recordedAtMs: number;
|
readonly recordedAtMs: number;
|
||||||
readonly items: readonly Readonly<CollectionItem>[];
|
readonly items: readonly Readonly<CollectionItem>[];
|
||||||
readonly bytes: number;
|
readonly bytes: number;
|
||||||
@@ -174,31 +183,11 @@ function exactSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readProfile(
|
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
|
||||||
uid: number,
|
|
||||||
): LocalDeploymentProfile {
|
|
||||||
const value = readJson(
|
|
||||||
boundedPrivateFile(
|
|
||||||
paths.applicationConfig,
|
|
||||||
uid,
|
|
||||||
'application configuration',
|
|
||||||
),
|
|
||||||
'application configuration',
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
value.schema !== 'qinglong/local-application-process@v2' ||
|
|
||||||
(value.profile !== 'edge' && value.profile !== 'standalone')
|
|
||||||
) {
|
|
||||||
configurationError('application configuration collection binding drifted');
|
|
||||||
}
|
|
||||||
return value.profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rolloutItem(
|
function rolloutItem(
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
uid: number,
|
uid: number,
|
||||||
rolloutId: string,
|
rolloutId: string,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Readonly<CollectionItem> {
|
): Readonly<CollectionItem> {
|
||||||
const receiptContents = boundedPrivateFile(
|
const receiptContents = boundedPrivateFile(
|
||||||
path.join(paths.composeRollouts, `${rolloutId}.json`),
|
path.join(paths.composeRollouts, `${rolloutId}.json`),
|
||||||
@@ -210,6 +199,7 @@ function rolloutItem(
|
|||||||
const sqlite = receipt.sqlite as Record<string, unknown> | undefined;
|
const sqlite = receipt.sqlite as Record<string, unknown> | undefined;
|
||||||
const backup = sqlite?.backup as Record<string, unknown> | undefined;
|
const backup = sqlite?.backup as Record<string, unknown> | undefined;
|
||||||
const result = receipt.result as Record<string, unknown> | undefined;
|
const result = receipt.result as Record<string, unknown> | undefined;
|
||||||
|
assertLocalDeploymentComposeLineageReceipt(receipt.lineage, lineage.receipt);
|
||||||
if (
|
if (
|
||||||
receipt.schema !== ROLLOUT_RECEIPT_SCHEMA ||
|
receipt.schema !== ROLLOUT_RECEIPT_SCHEMA ||
|
||||||
receipt.rolloutId !== rolloutId ||
|
receipt.rolloutId !== rolloutId ||
|
||||||
@@ -243,6 +233,7 @@ function restoreItem(
|
|||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
uid: number,
|
uid: number,
|
||||||
restoreId: string,
|
restoreId: string,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Readonly<CollectionItem> {
|
): Readonly<CollectionItem> {
|
||||||
const receiptContents = boundedPrivateFile(
|
const receiptContents = boundedPrivateFile(
|
||||||
path.join(paths.composeRestores, `${restoreId}.commit.json`),
|
path.join(paths.composeRestores, `${restoreId}.commit.json`),
|
||||||
@@ -251,6 +242,7 @@ function restoreItem(
|
|||||||
[1, 2],
|
[1, 2],
|
||||||
);
|
);
|
||||||
const receipt = readJson(receiptContents, 'compose restore commit receipt');
|
const receipt = readJson(receiptContents, 'compose restore commit receipt');
|
||||||
|
assertLocalDeploymentComposeLineageReceipt(receipt.lineage, lineage.receipt);
|
||||||
if (
|
if (
|
||||||
receipt.schema !== RESTORE_COMMIT_SCHEMA ||
|
receipt.schema !== RESTORE_COMMIT_SCHEMA ||
|
||||||
receipt.restoreId !== restoreId ||
|
receipt.restoreId !== restoreId ||
|
||||||
@@ -348,6 +340,7 @@ async function prepareItems(
|
|||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
uid: number,
|
uid: number,
|
||||||
profile: LocalDeploymentProfile,
|
profile: LocalDeploymentProfile,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Promise<readonly Readonly<CollectionItem>[]> {
|
): Promise<readonly Readonly<CollectionItem>[]> {
|
||||||
const rolloutCatalog = catalogIds(
|
const rolloutCatalog = catalogIds(
|
||||||
paths.composeRolloutBackups,
|
paths.composeRolloutBackups,
|
||||||
@@ -359,14 +352,14 @@ async function prepareItems(
|
|||||||
);
|
);
|
||||||
const rolloutItems = await Promise.all(
|
const rolloutItems = await Promise.all(
|
||||||
rolloutCatalog.map(async (id) => {
|
rolloutCatalog.map(async (id) => {
|
||||||
const item = rolloutItem(paths, uid, id);
|
const item = rolloutItem(paths, uid, id, lineage);
|
||||||
await inspectPhysicalItem(paths, profile, item);
|
await inspectPhysicalItem(paths, profile, item);
|
||||||
return item;
|
return item;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const restoreItems = await Promise.all(
|
const restoreItems = await Promise.all(
|
||||||
restoreCatalog.map(async (id) => {
|
restoreCatalog.map(async (id) => {
|
||||||
const item = restoreItem(paths, uid, id);
|
const item = restoreItem(paths, uid, id, lineage);
|
||||||
await inspectPhysicalItem(paths, profile, item);
|
await inspectPhysicalItem(paths, profile, item);
|
||||||
return item;
|
return item;
|
||||||
}),
|
}),
|
||||||
@@ -491,6 +484,7 @@ function inspectPrepareReceipt(
|
|||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
uid: number,
|
uid: number,
|
||||||
command: Readonly<LocalDeploymentComposeEvidenceCollectionPrepareCommand>,
|
command: Readonly<LocalDeploymentComposeEvidenceCollectionPrepareCommand>,
|
||||||
|
expectedLineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Readonly<CollectionPrepareReceipt> | null {
|
): Readonly<CollectionPrepareReceipt> | null {
|
||||||
const filePath = prepareReceiptPath(paths, command.request.collectionId);
|
const filePath = prepareReceiptPath(paths, command.request.collectionId);
|
||||||
if (!fs.existsSync(filePath)) return null;
|
if (!fs.existsSync(filePath)) return null;
|
||||||
@@ -505,7 +499,12 @@ function inspectPrepareReceipt(
|
|||||||
'compose evidence collection prepare receipt',
|
'compose evidence collection prepare receipt',
|
||||||
);
|
);
|
||||||
const items = parseItems(value.items);
|
const items = parseItems(value.items);
|
||||||
const receipt = { ...value, items } as unknown as CollectionPrepareReceipt;
|
const lineage = normalizeLocalDeploymentComposeLineageReceipt(value.lineage);
|
||||||
|
const receipt = {
|
||||||
|
...value,
|
||||||
|
lineage,
|
||||||
|
items,
|
||||||
|
} as unknown as CollectionPrepareReceipt;
|
||||||
if (
|
if (
|
||||||
Object.keys(value).sort().join(',') !==
|
Object.keys(value).sort().join(',') !==
|
||||||
[
|
[
|
||||||
@@ -513,6 +512,7 @@ function inspectPrepareReceipt(
|
|||||||
'commandDigest',
|
'commandDigest',
|
||||||
'generation',
|
'generation',
|
||||||
'items',
|
'items',
|
||||||
|
'lineage',
|
||||||
'profile',
|
'profile',
|
||||||
'recordedAtMs',
|
'recordedAtMs',
|
||||||
'schema',
|
'schema',
|
||||||
@@ -529,6 +529,10 @@ function inspectPrepareReceipt(
|
|||||||
) {
|
) {
|
||||||
configurationError('compose evidence collection prepare receipt drifted');
|
configurationError('compose evidence collection prepare receipt drifted');
|
||||||
}
|
}
|
||||||
|
assertLocalDeploymentComposeLineageReceipt(
|
||||||
|
receipt.lineage,
|
||||||
|
expectedLineage.receipt,
|
||||||
|
);
|
||||||
publishExactFile(
|
publishExactFile(
|
||||||
filePath,
|
filePath,
|
||||||
contents,
|
contents,
|
||||||
@@ -543,6 +547,7 @@ function inspectCommitReceipt(
|
|||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
uid: number,
|
uid: number,
|
||||||
command: Readonly<LocalDeploymentComposeEvidenceCollectionCommand>,
|
command: Readonly<LocalDeploymentComposeEvidenceCollectionCommand>,
|
||||||
|
expectedLineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Readonly<CollectionCommitReceipt> | null {
|
): Readonly<CollectionCommitReceipt> | null {
|
||||||
const filePath = commitReceiptPath(paths, command.request.collectionId);
|
const filePath = commitReceiptPath(paths, command.request.collectionId);
|
||||||
if (!fs.existsSync(filePath)) return null;
|
if (!fs.existsSync(filePath)) return null;
|
||||||
@@ -557,7 +562,12 @@ function inspectCommitReceipt(
|
|||||||
'compose evidence collection commit receipt',
|
'compose evidence collection commit receipt',
|
||||||
);
|
);
|
||||||
const items = parseItems(value.items);
|
const items = parseItems(value.items);
|
||||||
const receipt = { ...value, items } as unknown as CollectionCommitReceipt;
|
const lineage = normalizeLocalDeploymentComposeLineageReceipt(value.lineage);
|
||||||
|
const receipt = {
|
||||||
|
...value,
|
||||||
|
lineage,
|
||||||
|
items,
|
||||||
|
} as unknown as CollectionCommitReceipt;
|
||||||
if (
|
if (
|
||||||
Object.keys(value).sort().join(',') !==
|
Object.keys(value).sort().join(',') !==
|
||||||
[
|
[
|
||||||
@@ -566,6 +576,7 @@ function inspectCommitReceipt(
|
|||||||
'commandDigest',
|
'commandDigest',
|
||||||
'generation',
|
'generation',
|
||||||
'items',
|
'items',
|
||||||
|
'lineage',
|
||||||
'prepareReceiptDigest',
|
'prepareReceiptDigest',
|
||||||
'profile',
|
'profile',
|
||||||
'recordedAtMs',
|
'recordedAtMs',
|
||||||
@@ -590,6 +601,10 @@ function inspectCommitReceipt(
|
|||||||
) {
|
) {
|
||||||
configurationError('compose evidence collection commit receipt drifted');
|
configurationError('compose evidence collection commit receipt drifted');
|
||||||
}
|
}
|
||||||
|
assertLocalDeploymentComposeLineageReceipt(
|
||||||
|
receipt.lineage,
|
||||||
|
expectedLineage.receipt,
|
||||||
|
);
|
||||||
publishExactFile(
|
publishExactFile(
|
||||||
filePath,
|
filePath,
|
||||||
contents,
|
contents,
|
||||||
@@ -658,8 +673,9 @@ async function prepareCollection(
|
|||||||
command: Readonly<LocalDeploymentComposeEvidenceCollectionPrepareCommand>,
|
command: Readonly<LocalDeploymentComposeEvidenceCollectionPrepareCommand>,
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
uid: number,
|
uid: number,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Promise<Readonly<LocalDeploymentComposeEvidenceCollectionResult>> {
|
): Promise<Readonly<LocalDeploymentComposeEvidenceCollectionResult>> {
|
||||||
const replay = inspectPrepareReceipt(paths, uid, command);
|
const replay = inspectPrepareReceipt(paths, uid, command, lineage);
|
||||||
if (replay) return result(command, 'existing', replay.profile, replay.items);
|
if (replay) return result(command, 'existing', replay.profile, replay.items);
|
||||||
assertOperationsIdle(paths);
|
assertOperationsIdle(paths);
|
||||||
const selection = inspectActiveComposeImageSelection(
|
const selection = inspectActiveComposeImageSelection(
|
||||||
@@ -672,8 +688,8 @@ async function prepareCollection(
|
|||||||
'active compose generation does not match evidence collection',
|
'active compose generation does not match evidence collection',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const profile = readProfile(paths, uid);
|
const profile = lineage.profile;
|
||||||
const items = await prepareItems(command, paths, uid, profile);
|
const items = await prepareItems(command, paths, uid, profile, lineage);
|
||||||
if (items.some((item) => item.recordedAtMs > command.request.preparedAtMs)) {
|
if (items.some((item) => item.recordedAtMs > command.request.preparedAtMs)) {
|
||||||
configurationError(
|
configurationError(
|
||||||
'compose evidence collection precedes retained evidence',
|
'compose evidence collection precedes retained evidence',
|
||||||
@@ -699,6 +715,7 @@ async function prepareCollection(
|
|||||||
collectionId: command.request.collectionId,
|
collectionId: command.request.collectionId,
|
||||||
generation: command.request.expectedGeneration,
|
generation: command.request.expectedGeneration,
|
||||||
profile,
|
profile,
|
||||||
|
lineage: lineage.receipt,
|
||||||
recordedAtMs: command.request.preparedAtMs,
|
recordedAtMs: command.request.preparedAtMs,
|
||||||
items,
|
items,
|
||||||
});
|
});
|
||||||
@@ -826,13 +843,14 @@ async function commitCollection(
|
|||||||
command: Readonly<LocalDeploymentComposeEvidenceCollectionCommand>,
|
command: Readonly<LocalDeploymentComposeEvidenceCollectionCommand>,
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
uid: number,
|
uid: number,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Promise<Readonly<LocalDeploymentComposeEvidenceCollectionResult>> {
|
): Promise<Readonly<LocalDeploymentComposeEvidenceCollectionResult>> {
|
||||||
if (
|
if (
|
||||||
command.operation !== 'local.deployment.compose.evidence-collection.commit'
|
command.operation !== 'local.deployment.compose.evidence-collection.commit'
|
||||||
) {
|
) {
|
||||||
configurationError('compose evidence collection commit command is invalid');
|
configurationError('compose evidence collection commit command is invalid');
|
||||||
}
|
}
|
||||||
const existing = inspectCommitReceipt(paths, uid, command);
|
const existing = inspectCommitReceipt(paths, uid, command, lineage);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const prepareContents = boundedPrivateFile(
|
const prepareContents = boundedPrivateFile(
|
||||||
prepareReceiptPath(paths, command.request.collectionId),
|
prepareReceiptPath(paths, command.request.collectionId),
|
||||||
@@ -875,7 +893,7 @@ async function commitCollection(
|
|||||||
return result(command, 'existing', existing.profile, existing.items);
|
return result(command, 'existing', existing.profile, existing.items);
|
||||||
}
|
}
|
||||||
const prepared = prepareCommandFromLock(command, paths, uid);
|
const prepared = prepareCommandFromLock(command, paths, uid);
|
||||||
const prepareReceipt = inspectPrepareReceipt(paths, uid, prepared);
|
const prepareReceipt = inspectPrepareReceipt(paths, uid, prepared, lineage);
|
||||||
if (!prepareReceipt) {
|
if (!prepareReceipt) {
|
||||||
configurationError(
|
configurationError(
|
||||||
'compose evidence collection prepare receipt is unavailable',
|
'compose evidence collection prepare receipt is unavailable',
|
||||||
@@ -911,6 +929,7 @@ async function commitCollection(
|
|||||||
collectionId: command.request.collectionId,
|
collectionId: command.request.collectionId,
|
||||||
generation: command.request.expectedGeneration,
|
generation: command.request.expectedGeneration,
|
||||||
profile: prepareReceipt.profile,
|
profile: prepareReceipt.profile,
|
||||||
|
lineage: lineage.receipt,
|
||||||
recordedAtMs: command.request.committedAtMs,
|
recordedAtMs: command.request.committedAtMs,
|
||||||
items: prepareReceipt.items,
|
items: prepareReceipt.items,
|
||||||
bytes: prepareReceipt.items.reduce(
|
bytes: prepareReceipt.items.reduce(
|
||||||
@@ -992,10 +1011,16 @@ export async function collectLocalDeploymentComposeEvidence(
|
|||||||
identity.uid,
|
identity.uid,
|
||||||
'composeCollectedRestoreSafeguardRoot',
|
'composeCollectedRestoreSafeguardRoot',
|
||||||
);
|
);
|
||||||
|
const lineage = inspectLocalDeploymentComposeLineage(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
identity.uid,
|
||||||
|
identity.gid,
|
||||||
|
command.options.allowRootService,
|
||||||
|
);
|
||||||
return command.operation ===
|
return command.operation ===
|
||||||
'local.deployment.compose.evidence-collection.prepare'
|
'local.deployment.compose.evidence-collection.prepare'
|
||||||
? prepareCollection(command, paths, identity.uid)
|
? prepareCollection(command, paths, identity.uid, lineage)
|
||||||
: commitCollection(command, paths, identity.uid);
|
: commitCollection(command, paths, identity.uid, lineage);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectLocalDeploymentComposeEvidencePrepareCommandFile(
|
export function collectLocalDeploymentComposeEvidencePrepareCommandFile(
|
||||||
|
|||||||
@@ -0,0 +1,794 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { cutoverDigest } from '../cutover/targetEvidence';
|
||||||
|
import type {
|
||||||
|
LocalDeploymentPrepareCommand,
|
||||||
|
LocalDeploymentProfile,
|
||||||
|
} from '../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||||
|
import {
|
||||||
|
applicationConfiguration,
|
||||||
|
deploymentPaths,
|
||||||
|
descriptor,
|
||||||
|
} from '../foundation/render';
|
||||||
|
import type { NormalizedLocalDeploymentAdoptedBundleCommand } from '../adopted-bundle/contract';
|
||||||
|
import {
|
||||||
|
renderLocalDeploymentAdoptedBundleMaterial,
|
||||||
|
verifyLocalDeploymentAdoptedEvidence,
|
||||||
|
type LocalDeploymentAdoptedBundleReceipt,
|
||||||
|
} from '../adopted-bundle/material';
|
||||||
|
import { inspectComposeImageSelectionGeneration } from './composeRevision';
|
||||||
|
|
||||||
|
const APPLICATION_SCHEMA_V2 = 'qinglong/local-application-process@v2';
|
||||||
|
const APPLICATION_SCHEMA_V4 = 'qinglong/local-application-process@v4';
|
||||||
|
const LINEAGE_SCHEMA = 'qinglong/local-compose-deployment-lineage@v1';
|
||||||
|
const MAX_PRIVATE_FILE_BYTES = 64 * 1024;
|
||||||
|
const DIGEST_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}$/;
|
||||||
|
|
||||||
|
export interface LocalDeploymentComposeLineageReceipt {
|
||||||
|
readonly schema: typeof LINEAGE_SCHEMA;
|
||||||
|
readonly mode: 'fresh' | 'adopted';
|
||||||
|
readonly applicationConfigDigest: string;
|
||||||
|
readonly adoptedBundleDigest: string | null;
|
||||||
|
readonly activationDigest: string | null;
|
||||||
|
readonly commitmentDigest: string | null;
|
||||||
|
readonly legacyDataApplicationCommitDigest: string | null;
|
||||||
|
readonly legacyDataApplicationReceiptDigest: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalDeploymentComposeLineage {
|
||||||
|
readonly mode: 'fresh' | 'adopted';
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly profile: LocalDeploymentProfile;
|
||||||
|
readonly busyTimeoutMs?: number;
|
||||||
|
readonly databasePath: string;
|
||||||
|
readonly sourcePath: string | null;
|
||||||
|
readonly bundleId: string | null;
|
||||||
|
readonly receipt: Readonly<LocalDeploymentComposeLineageReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedAdoptedApplication {
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly profile: LocalDeploymentProfile;
|
||||||
|
readonly busyTimeoutMs?: number;
|
||||||
|
readonly storage: Readonly<{
|
||||||
|
sourcePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
recoveryPath: string;
|
||||||
|
manifestPath: string;
|
||||||
|
activationPath: string;
|
||||||
|
expectedActivationDigest: string;
|
||||||
|
}>;
|
||||||
|
readonly cutover: Readonly<{
|
||||||
|
cutoverId: string;
|
||||||
|
commitmentPath: string;
|
||||||
|
expectedCommitmentDigest: string;
|
||||||
|
}>;
|
||||||
|
readonly legacyDataApplication: Readonly<{
|
||||||
|
commitPath: string;
|
||||||
|
expectedCommitDigest: string;
|
||||||
|
expectedReceiptDigest: 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: Readonly<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 sha256(value: string | Buffer): string {
|
||||||
|
return crypto.createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||||
|
configurationError(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolutePath(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('//') ||
|
||||||
|
Buffer.byteLength(value, 'utf8') > 4_096
|
||||||
|
) {
|
||||||
|
configurationError(`${label} is not a canonical bounded absolute path`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strictDescendant(root: string, candidate: string): boolean {
|
||||||
|
const relative = path.relative(root, candidate);
|
||||||
|
return (
|
||||||
|
relative.length > 0 &&
|
||||||
|
relative !== '..' &&
|
||||||
|
!relative.startsWith(`..${path.sep}`) &&
|
||||||
|
!path.isAbsolute(relative)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExactPrivateFile(
|
||||||
|
filePath: string,
|
||||||
|
uid: number,
|
||||||
|
gid: number,
|
||||||
|
label: string,
|
||||||
|
): string {
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
let bytes: Buffer | undefined;
|
||||||
|
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.isSymbolicLink() ||
|
||||||
|
opened.dev !== before.dev ||
|
||||||
|
opened.ino !== before.ino ||
|
||||||
|
Number(opened.uid) !== uid ||
|
||||||
|
Number(opened.gid) !== gid ||
|
||||||
|
(Number(opened.mode) & 0o777) !== 0o600 ||
|
||||||
|
opened.nlink !== 1n ||
|
||||||
|
opened.size < 2n ||
|
||||||
|
opened.size > BigInt(MAX_PRIVATE_FILE_BYTES) ||
|
||||||
|
fs.realpathSync(filePath) !== filePath
|
||||||
|
) {
|
||||||
|
configurationError(`${label} identity is invalid`);
|
||||||
|
}
|
||||||
|
bytes = Buffer.alloc(Number(opened.size));
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < bytes.byteLength) {
|
||||||
|
const count = fs.readSync(
|
||||||
|
descriptor,
|
||||||
|
bytes,
|
||||||
|
offset,
|
||||||
|
bytes.byteLength - offset,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
if (count === 0) break;
|
||||||
|
offset += count;
|
||||||
|
}
|
||||||
|
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||||
|
if (
|
||||||
|
offset !== bytes.byteLength ||
|
||||||
|
after.dev !== opened.dev ||
|
||||||
|
after.ino !== opened.ino ||
|
||||||
|
after.size !== opened.size ||
|
||||||
|
after.mtimeNs !== opened.mtimeNs ||
|
||||||
|
after.ctimeNs !== opened.ctimeNs ||
|
||||||
|
after.uid !== opened.uid ||
|
||||||
|
after.gid !== opened.gid ||
|
||||||
|
after.mode !== opened.mode ||
|
||||||
|
after.nlink !== opened.nlink
|
||||||
|
) {
|
||||||
|
configurationError(`${label} changed while reading`);
|
||||||
|
}
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
return configurationError(`${label} cannot be read`, error);
|
||||||
|
} finally {
|
||||||
|
bytes?.fill(0);
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyExactPrivateFile(
|
||||||
|
filePath: string,
|
||||||
|
expected: string,
|
||||||
|
uid: number,
|
||||||
|
gid: number,
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
if (readExactPrivateFile(filePath, uid, gid, label) !== expected) {
|
||||||
|
configurationError(`${label} content drifted`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(contents: string, label: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
return object(JSON.parse(contents), label);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
return configurationError(`${label} is invalid`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalBusyTimeout(value: unknown): number | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
(value as number) < 100 ||
|
||||||
|
(value as number) > 30_000
|
||||||
|
) {
|
||||||
|
configurationError('application busyTimeoutMs is invalid');
|
||||||
|
}
|
||||||
|
return value as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFreshApplication(
|
||||||
|
value: Readonly<Record<string, unknown>>,
|
||||||
|
): Readonly<{
|
||||||
|
instanceId: string;
|
||||||
|
profile: LocalDeploymentProfile;
|
||||||
|
databasePath: string;
|
||||||
|
busyTimeoutMs?: number;
|
||||||
|
}> {
|
||||||
|
const storage = object(value.storage, 'application storage');
|
||||||
|
if (
|
||||||
|
typeof value.instanceId !== 'string' ||
|
||||||
|
(value.profile !== 'edge' && value.profile !== 'standalone') ||
|
||||||
|
storage.mode !== 'fresh'
|
||||||
|
) {
|
||||||
|
configurationError('application configuration is not a fresh Compose v2');
|
||||||
|
}
|
||||||
|
const databasePath = absolutePath(
|
||||||
|
storage.databasePath,
|
||||||
|
'application databasePath',
|
||||||
|
);
|
||||||
|
const busyTimeoutMs = optionalBusyTimeout(storage.busyTimeoutMs);
|
||||||
|
return Object.freeze({
|
||||||
|
instanceId: value.instanceId,
|
||||||
|
profile: value.profile,
|
||||||
|
databasePath,
|
||||||
|
...(busyTimeoutMs === undefined ? {} : { busyTimeoutMs }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdoptedApplication(
|
||||||
|
value: Readonly<Record<string, unknown>>,
|
||||||
|
deploymentRoot: string,
|
||||||
|
): Readonly<ParsedAdoptedApplication> {
|
||||||
|
exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'ai',
|
||||||
|
'cutover',
|
||||||
|
'instanceId',
|
||||||
|
'legacyDataApplication',
|
||||||
|
'pluginPackages',
|
||||||
|
'profile',
|
||||||
|
'runtime',
|
||||||
|
'schema',
|
||||||
|
'storage',
|
||||||
|
],
|
||||||
|
'adopted application configuration',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
typeof value.instanceId !== 'string' ||
|
||||||
|
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.instanceId) ||
|
||||||
|
(value.profile !== 'edge' && value.profile !== 'standalone')
|
||||||
|
) {
|
||||||
|
configurationError('adopted application identity is invalid');
|
||||||
|
}
|
||||||
|
const storage = object(value.storage, 'adopted storage');
|
||||||
|
const optionalStorageKeys = Object.hasOwn(storage, 'busyTimeoutMs')
|
||||||
|
? ['busyTimeoutMs']
|
||||||
|
: [];
|
||||||
|
exact(
|
||||||
|
storage,
|
||||||
|
[
|
||||||
|
'activationPath',
|
||||||
|
'expectedActivationDigest',
|
||||||
|
'manifestPath',
|
||||||
|
'mode',
|
||||||
|
'recoveryPath',
|
||||||
|
'sourcePath',
|
||||||
|
'targetPath',
|
||||||
|
...optionalStorageKeys,
|
||||||
|
],
|
||||||
|
'adopted storage',
|
||||||
|
);
|
||||||
|
const runtime = object(value.runtime, 'adopted runtime');
|
||||||
|
exact(
|
||||||
|
runtime,
|
||||||
|
['artifactRoot', 'receiptRoot', 'secretKeyringPath'],
|
||||||
|
'adopted runtime',
|
||||||
|
);
|
||||||
|
const pluginPackages = object(
|
||||||
|
value.pluginPackages,
|
||||||
|
'adopted plugin packages',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
pluginPackages,
|
||||||
|
[
|
||||||
|
'activationRoot',
|
||||||
|
'maxPages',
|
||||||
|
'pageSize',
|
||||||
|
'recoverySource',
|
||||||
|
'stagingRoot',
|
||||||
|
'taskPublicationMaxPages',
|
||||||
|
'taskPublicationPageSize',
|
||||||
|
],
|
||||||
|
'adopted plugin packages',
|
||||||
|
);
|
||||||
|
const recoverySource = object(
|
||||||
|
pluginPackages.recoverySource,
|
||||||
|
'plugin recovery source',
|
||||||
|
);
|
||||||
|
exact(recoverySource, ['mode'], 'plugin recovery source');
|
||||||
|
const ai = object(value.ai, 'adopted AI');
|
||||||
|
exact(ai, ['deployment'], 'adopted AI');
|
||||||
|
const cutover = object(value.cutover, 'adopted cutover');
|
||||||
|
exact(
|
||||||
|
cutover,
|
||||||
|
['commitmentPath', 'cutoverId', 'expectedCommitmentDigest'],
|
||||||
|
'adopted cutover',
|
||||||
|
);
|
||||||
|
const dataApplication = object(
|
||||||
|
value.legacyDataApplication,
|
||||||
|
'legacy data application',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
dataApplication,
|
||||||
|
['commitPath', 'expectedCommitDigest', 'expectedReceiptDigest'],
|
||||||
|
'legacy data application',
|
||||||
|
);
|
||||||
|
const sourcePath = absolutePath(storage.sourcePath, 'sourcePath');
|
||||||
|
const authorityPaths = {
|
||||||
|
targetPath: absolutePath(storage.targetPath, 'targetPath'),
|
||||||
|
recoveryPath: absolutePath(storage.recoveryPath, 'recoveryPath'),
|
||||||
|
manifestPath: absolutePath(storage.manifestPath, 'manifestPath'),
|
||||||
|
activationPath: absolutePath(storage.activationPath, 'activationPath'),
|
||||||
|
commitmentPath: absolutePath(cutover.commitmentPath, 'commitmentPath'),
|
||||||
|
commitPath: absolutePath(dataApplication.commitPath, 'commitPath'),
|
||||||
|
};
|
||||||
|
const paths = deploymentPaths(deploymentRoot);
|
||||||
|
const pageSize = value.profile === 'edge' ? 4 : 16;
|
||||||
|
if (
|
||||||
|
storage.mode !== 'adopted' ||
|
||||||
|
strictDescendant(deploymentRoot, sourcePath) ||
|
||||||
|
Object.values(authorityPaths).some(
|
||||||
|
(candidate) => !strictDescendant(deploymentRoot, candidate),
|
||||||
|
) ||
|
||||||
|
new Set([sourcePath, ...Object.values(authorityPaths)]).size !== 7 ||
|
||||||
|
runtime.receiptRoot !== paths.receipts ||
|
||||||
|
runtime.artifactRoot !== paths.artifacts ||
|
||||||
|
runtime.secretKeyringPath !== paths.localSecretKeyring ||
|
||||||
|
pluginPackages.stagingRoot !== paths.pluginStaging ||
|
||||||
|
pluginPackages.activationRoot !== paths.pluginActivation ||
|
||||||
|
recoverySource.mode !== 'disabled' ||
|
||||||
|
pluginPackages.pageSize !== pageSize ||
|
||||||
|
pluginPackages.maxPages !== pageSize ||
|
||||||
|
pluginPackages.taskPublicationPageSize !== pageSize ||
|
||||||
|
pluginPackages.taskPublicationMaxPages !== pageSize ||
|
||||||
|
ai.deployment !== 'excluded' ||
|
||||||
|
typeof cutover.cutoverId !== 'string' ||
|
||||||
|
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(cutover.cutoverId)
|
||||||
|
) {
|
||||||
|
configurationError('adopted application authority binding drifted');
|
||||||
|
}
|
||||||
|
const busyTimeoutMs = optionalBusyTimeout(storage.busyTimeoutMs);
|
||||||
|
return Object.freeze({
|
||||||
|
instanceId: value.instanceId,
|
||||||
|
profile: value.profile,
|
||||||
|
...(busyTimeoutMs === undefined ? {} : { busyTimeoutMs }),
|
||||||
|
storage: Object.freeze({
|
||||||
|
sourcePath,
|
||||||
|
targetPath: authorityPaths.targetPath,
|
||||||
|
recoveryPath: authorityPaths.recoveryPath,
|
||||||
|
manifestPath: authorityPaths.manifestPath,
|
||||||
|
activationPath: authorityPaths.activationPath,
|
||||||
|
expectedActivationDigest: digest(
|
||||||
|
storage.expectedActivationDigest,
|
||||||
|
'expectedActivationDigest',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
cutover: Object.freeze({
|
||||||
|
cutoverId: cutover.cutoverId,
|
||||||
|
commitmentPath: authorityPaths.commitmentPath,
|
||||||
|
expectedCommitmentDigest: digest(
|
||||||
|
cutover.expectedCommitmentDigest,
|
||||||
|
'expectedCommitmentDigest',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
legacyDataApplication: Object.freeze({
|
||||||
|
commitPath: authorityPaths.commitPath,
|
||||||
|
expectedCommitDigest: digest(
|
||||||
|
dataApplication.expectedCommitDigest,
|
||||||
|
'expectedCommitDigest',
|
||||||
|
),
|
||||||
|
expectedReceiptDigest: digest(
|
||||||
|
dataApplication.expectedReceiptDigest,
|
||||||
|
'expectedReceiptDigest',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBundleReceipt(
|
||||||
|
contents: string,
|
||||||
|
): Readonly<LocalDeploymentAdoptedBundleReceipt> {
|
||||||
|
const value = parseJson(contents, 'adopted bundle receipt');
|
||||||
|
exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'activationDigest',
|
||||||
|
'applicationConfigDigest',
|
||||||
|
'bundleDigest',
|
||||||
|
'bundleId',
|
||||||
|
'commitmentDigest',
|
||||||
|
'composeSelectionDigest',
|
||||||
|
'cutoverId',
|
||||||
|
'deploymentRootDigest',
|
||||||
|
'instanceId',
|
||||||
|
'kind',
|
||||||
|
'legacyDataApplicationCommitDigest',
|
||||||
|
'legacyDataApplicationReceiptDigest',
|
||||||
|
'manifestDigest',
|
||||||
|
'preparedAtMs',
|
||||||
|
'profile',
|
||||||
|
'recoverySha256',
|
||||||
|
'schemaVersion',
|
||||||
|
'serviceDescriptorDigest',
|
||||||
|
'serviceKind',
|
||||||
|
'sourcePathDigest',
|
||||||
|
'sourceSha256',
|
||||||
|
'state',
|
||||||
|
'targetIdentityDigest',
|
||||||
|
],
|
||||||
|
'adopted bundle receipt',
|
||||||
|
);
|
||||||
|
const { bundleDigest, ...payload } = value;
|
||||||
|
if (
|
||||||
|
value.schemaVersion !== 1 ||
|
||||||
|
value.kind !== 'qinglong3-local-adopted-deployment-bundle' ||
|
||||||
|
value.state !== 'prepared' ||
|
||||||
|
typeof value.bundleId !== 'string' ||
|
||||||
|
!UUID_V4_PATTERN.test(value.bundleId) ||
|
||||||
|
!Number.isSafeInteger(value.preparedAtMs) ||
|
||||||
|
(value.preparedAtMs as number) < 0 ||
|
||||||
|
(value.profile !== 'edge' && value.profile !== 'standalone') ||
|
||||||
|
typeof value.instanceId !== 'string' ||
|
||||||
|
typeof value.cutoverId !== 'string' ||
|
||||||
|
value.serviceKind !== 'compose' ||
|
||||||
|
Object.entries(value).some(([key, candidate]) =>
|
||||||
|
key.endsWith('Digest')
|
||||||
|
? typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate)
|
||||||
|
: false,
|
||||||
|
) ||
|
||||||
|
typeof value.sourceSha256 !== 'string' ||
|
||||||
|
!DIGEST_PATTERN.test(value.sourceSha256) ||
|
||||||
|
typeof value.recoverySha256 !== 'string' ||
|
||||||
|
!DIGEST_PATTERN.test(value.recoverySha256) ||
|
||||||
|
cutoverDigest(payload) !== bundleDigest
|
||||||
|
) {
|
||||||
|
configurationError('adopted bundle receipt drifted');
|
||||||
|
}
|
||||||
|
return value as unknown as Readonly<LocalDeploymentAdoptedBundleReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineageReceipt(input: {
|
||||||
|
readonly mode: 'fresh' | 'adopted';
|
||||||
|
readonly applicationConfigDigest: string;
|
||||||
|
readonly adoptedBundleDigest?: string;
|
||||||
|
readonly activationDigest?: string;
|
||||||
|
readonly commitmentDigest?: string;
|
||||||
|
readonly legacyDataApplicationCommitDigest?: string;
|
||||||
|
readonly legacyDataApplicationReceiptDigest?: string;
|
||||||
|
}): Readonly<LocalDeploymentComposeLineageReceipt> {
|
||||||
|
const adopted = input.mode === 'adopted';
|
||||||
|
return Object.freeze({
|
||||||
|
schema: LINEAGE_SCHEMA,
|
||||||
|
mode: input.mode,
|
||||||
|
applicationConfigDigest: input.applicationConfigDigest,
|
||||||
|
adoptedBundleDigest: adopted ? input.adoptedBundleDigest! : null,
|
||||||
|
activationDigest: adopted ? input.activationDigest! : null,
|
||||||
|
commitmentDigest: adopted ? input.commitmentDigest! : null,
|
||||||
|
legacyDataApplicationCommitDigest: adopted
|
||||||
|
? input.legacyDataApplicationCommitDigest!
|
||||||
|
: null,
|
||||||
|
legacyDataApplicationReceiptDigest: adopted
|
||||||
|
? input.legacyDataApplicationReceiptDigest!
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectLocalDeploymentComposeLineage(
|
||||||
|
deploymentRoot: string,
|
||||||
|
uid: number,
|
||||||
|
gid: number,
|
||||||
|
allowRootService: boolean,
|
||||||
|
): Readonly<LocalDeploymentComposeLineage> {
|
||||||
|
const paths = deploymentPaths(deploymentRoot);
|
||||||
|
const applicationContents = readExactPrivateFile(
|
||||||
|
paths.applicationConfig,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
'application configuration',
|
||||||
|
);
|
||||||
|
const application = parseJson(
|
||||||
|
applicationContents,
|
||||||
|
'application configuration',
|
||||||
|
);
|
||||||
|
if (application.schema === APPLICATION_SCHEMA_V2) {
|
||||||
|
const fresh = parseFreshApplication(application);
|
||||||
|
const syntheticPrepare: Readonly<LocalDeploymentPrepareCommand> = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.prepare',
|
||||||
|
options: {
|
||||||
|
deploymentRoot,
|
||||||
|
profile: fresh.profile,
|
||||||
|
instanceId: fresh.instanceId,
|
||||||
|
...(fresh.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: fresh.busyTimeoutMs }),
|
||||||
|
service: {
|
||||||
|
kind: 'compose',
|
||||||
|
releaseSelection: {
|
||||||
|
path: paths.composeSelection,
|
||||||
|
expectedSelectionDigest: 'sha256:'.concat('0'.repeat(64)),
|
||||||
|
},
|
||||||
|
allowRootService,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
ownerPepperKeyId: 'lineage',
|
||||||
|
registerMutationId: '00000000-0000-4000-8000-000000000001',
|
||||||
|
activateMutationId: '00000000-0000-4000-8000-000000000002',
|
||||||
|
registeredAtMs: 0,
|
||||||
|
activatedAtMs: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const expectedApplication = applicationConfiguration(
|
||||||
|
syntheticPrepare,
|
||||||
|
paths,
|
||||||
|
);
|
||||||
|
if (applicationContents !== expectedApplication) {
|
||||||
|
configurationError('fresh application configuration drifted');
|
||||||
|
}
|
||||||
|
const expectedDescriptor = descriptor(
|
||||||
|
syntheticPrepare,
|
||||||
|
paths.applicationConfig,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
);
|
||||||
|
verifyExactPrivateFile(
|
||||||
|
path.join(paths.service, expectedDescriptor.fileName),
|
||||||
|
expectedDescriptor.contents,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
'fresh Compose descriptor',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
mode: 'fresh' as const,
|
||||||
|
instanceId: fresh.instanceId,
|
||||||
|
profile: fresh.profile,
|
||||||
|
...(fresh.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: fresh.busyTimeoutMs }),
|
||||||
|
databasePath: paths.database,
|
||||||
|
sourcePath: null,
|
||||||
|
bundleId: null,
|
||||||
|
receipt: lineageReceipt({
|
||||||
|
mode: 'fresh',
|
||||||
|
applicationConfigDigest: sha256(applicationContents),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (application.schema !== APPLICATION_SCHEMA_V4) {
|
||||||
|
configurationError('Compose requires an Application v2 or adopted v4');
|
||||||
|
}
|
||||||
|
const adopted = parseAdoptedApplication(application, deploymentRoot);
|
||||||
|
const bundleContents = readExactPrivateFile(
|
||||||
|
path.join(paths.service, 'adopted-bundle.json'),
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
'adopted bundle receipt',
|
||||||
|
);
|
||||||
|
const bundle = parseBundleReceipt(bundleContents);
|
||||||
|
if (
|
||||||
|
bundle.profile !== adopted.profile ||
|
||||||
|
bundle.instanceId !== adopted.instanceId ||
|
||||||
|
bundle.cutoverId !== adopted.cutover.cutoverId ||
|
||||||
|
bundle.deploymentRootDigest !== sha256(deploymentRoot) ||
|
||||||
|
bundle.sourcePathDigest !== sha256(adopted.storage.sourcePath) ||
|
||||||
|
bundle.applicationConfigDigest !== sha256(applicationContents) ||
|
||||||
|
bundle.activationDigest !== adopted.storage.expectedActivationDigest ||
|
||||||
|
bundle.commitmentDigest !== adopted.cutover.expectedCommitmentDigest ||
|
||||||
|
bundle.legacyDataApplicationCommitDigest !==
|
||||||
|
adopted.legacyDataApplication.expectedCommitDigest ||
|
||||||
|
bundle.legacyDataApplicationReceiptDigest !==
|
||||||
|
adopted.legacyDataApplication.expectedReceiptDigest
|
||||||
|
) {
|
||||||
|
configurationError('adopted bundle application binding drifted');
|
||||||
|
}
|
||||||
|
const initialSelection = inspectComposeImageSelectionGeneration(
|
||||||
|
paths.composeRevisions,
|
||||||
|
1,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const command: Readonly<NormalizedLocalDeploymentAdoptedBundleCommand> =
|
||||||
|
Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
operation: 'local.deployment.adopted.verify' as const,
|
||||||
|
options: Object.freeze({
|
||||||
|
deploymentRoot,
|
||||||
|
profile: adopted.profile,
|
||||||
|
instanceId: adopted.instanceId,
|
||||||
|
...(adopted.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: adopted.busyTimeoutMs }),
|
||||||
|
service: Object.freeze({
|
||||||
|
kind: 'compose' as const,
|
||||||
|
allowRootService,
|
||||||
|
releaseSelection: Object.freeze({
|
||||||
|
path: paths.composeSelection,
|
||||||
|
expectedSelectionDigest: initialSelection.selectionDigest,
|
||||||
|
authority: initialSelection,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
request: Object.freeze({
|
||||||
|
bundleId: bundle.bundleId,
|
||||||
|
preparedAtMs: bundle.preparedAtMs,
|
||||||
|
cutoverId: adopted.cutover.cutoverId,
|
||||||
|
storage: adopted.storage,
|
||||||
|
cutover: Object.freeze({
|
||||||
|
commitmentPath: adopted.cutover.commitmentPath,
|
||||||
|
expectedCommitmentDigest: adopted.cutover.expectedCommitmentDigest,
|
||||||
|
}),
|
||||||
|
legacyDataApplication: adopted.legacyDataApplication,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const evidence = verifyLocalDeploymentAdoptedEvidence(command, uid, gid);
|
||||||
|
const material = renderLocalDeploymentAdoptedBundleMaterial(
|
||||||
|
command,
|
||||||
|
evidence,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
);
|
||||||
|
verifyExactPrivateFile(
|
||||||
|
material.paths.applicationConfig,
|
||||||
|
material.applicationConfig,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
'adopted application configuration',
|
||||||
|
);
|
||||||
|
verifyExactPrivateFile(
|
||||||
|
material.paths.descriptor,
|
||||||
|
material.descriptor.contents,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
'adopted Compose descriptor',
|
||||||
|
);
|
||||||
|
verifyExactPrivateFile(
|
||||||
|
material.paths.bundleReceipt,
|
||||||
|
material.receiptContents,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
'adopted bundle receipt',
|
||||||
|
);
|
||||||
|
if (material.composeSelection === null) {
|
||||||
|
configurationError('adopted Compose selection is unavailable');
|
||||||
|
}
|
||||||
|
verifyExactPrivateFile(
|
||||||
|
material.paths.composeRevision,
|
||||||
|
material.composeSelection,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
'initial adopted Compose revision',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
mode: 'adopted' as const,
|
||||||
|
instanceId: adopted.instanceId,
|
||||||
|
profile: adopted.profile,
|
||||||
|
...(adopted.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: adopted.busyTimeoutMs }),
|
||||||
|
databasePath: adopted.storage.targetPath,
|
||||||
|
sourcePath: adopted.storage.sourcePath,
|
||||||
|
bundleId: bundle.bundleId,
|
||||||
|
receipt: lineageReceipt({
|
||||||
|
mode: 'adopted',
|
||||||
|
applicationConfigDigest: bundle.applicationConfigDigest,
|
||||||
|
adoptedBundleDigest: bundle.bundleDigest,
|
||||||
|
activationDigest: bundle.activationDigest,
|
||||||
|
commitmentDigest: bundle.commitmentDigest,
|
||||||
|
legacyDataApplicationCommitDigest:
|
||||||
|
bundle.legacyDataApplicationCommitDigest,
|
||||||
|
legacyDataApplicationReceiptDigest:
|
||||||
|
bundle.legacyDataApplicationReceiptDigest,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalDeploymentComposeLineageReceipt(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalDeploymentComposeLineageReceipt> {
|
||||||
|
const receipt = object(value, 'Compose deployment lineage');
|
||||||
|
exact(
|
||||||
|
receipt,
|
||||||
|
[
|
||||||
|
'activationDigest',
|
||||||
|
'adoptedBundleDigest',
|
||||||
|
'applicationConfigDigest',
|
||||||
|
'commitmentDigest',
|
||||||
|
'legacyDataApplicationCommitDigest',
|
||||||
|
'legacyDataApplicationReceiptDigest',
|
||||||
|
'mode',
|
||||||
|
'schema',
|
||||||
|
],
|
||||||
|
'Compose deployment lineage',
|
||||||
|
);
|
||||||
|
const adopted = receipt.mode === 'adopted';
|
||||||
|
if (
|
||||||
|
receipt.schema !== LINEAGE_SCHEMA ||
|
||||||
|
(receipt.mode !== 'fresh' && !adopted) ||
|
||||||
|
typeof receipt.applicationConfigDigest !== 'string' ||
|
||||||
|
!DIGEST_PATTERN.test(receipt.applicationConfigDigest) ||
|
||||||
|
[
|
||||||
|
receipt.adoptedBundleDigest,
|
||||||
|
receipt.activationDigest,
|
||||||
|
receipt.commitmentDigest,
|
||||||
|
receipt.legacyDataApplicationCommitDigest,
|
||||||
|
receipt.legacyDataApplicationReceiptDigest,
|
||||||
|
].some((candidate) =>
|
||||||
|
adopted
|
||||||
|
? typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate)
|
||||||
|
: candidate !== null,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
configurationError('Compose deployment lineage is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schema: LINEAGE_SCHEMA,
|
||||||
|
mode: receipt.mode,
|
||||||
|
applicationConfigDigest: receipt.applicationConfigDigest,
|
||||||
|
adoptedBundleDigest: receipt.adoptedBundleDigest,
|
||||||
|
activationDigest: receipt.activationDigest,
|
||||||
|
commitmentDigest: receipt.commitmentDigest,
|
||||||
|
legacyDataApplicationCommitDigest:
|
||||||
|
receipt.legacyDataApplicationCommitDigest,
|
||||||
|
legacyDataApplicationReceiptDigest:
|
||||||
|
receipt.legacyDataApplicationReceiptDigest,
|
||||||
|
}) as Readonly<LocalDeploymentComposeLineageReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertLocalDeploymentComposeLineageReceipt(
|
||||||
|
value: unknown,
|
||||||
|
expected: Readonly<LocalDeploymentComposeLineageReceipt>,
|
||||||
|
): void {
|
||||||
|
const actual = normalizeLocalDeploymentComposeLineageReceipt(value);
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
configurationError('Compose deployment lineage drifted');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||||
@@ -31,6 +30,10 @@ import {
|
|||||||
deploymentPaths,
|
deploymentPaths,
|
||||||
descriptor,
|
descriptor,
|
||||||
} from '../foundation/render';
|
} from '../foundation/render';
|
||||||
|
import {
|
||||||
|
inspectLocalDeploymentComposeLineage,
|
||||||
|
type LocalDeploymentComposeLineage,
|
||||||
|
} from './composeLineage';
|
||||||
|
|
||||||
const CONTAINER_ROOT = '/var/lib/qinglong3';
|
const CONTAINER_ROOT = '/var/lib/qinglong3';
|
||||||
const MAX_DOCKER_OUTPUT_BYTES = 256 * 1024;
|
const MAX_DOCKER_OUTPUT_BYTES = 256 * 1024;
|
||||||
@@ -46,87 +49,10 @@ export interface LocalDeploymentComposePreflightDependencies {
|
|||||||
readonly validateSocket?: (socketPath: string, uid: number) => void;
|
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 {
|
function configurationError(message: string, cause?: unknown): never {
|
||||||
throw new LocalDeploymentConfigurationError(message, { cause });
|
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 {
|
function parseJson(value: string, label: string): unknown {
|
||||||
if (
|
if (
|
||||||
Buffer.byteLength(value, 'utf8') < 2 ||
|
Buffer.byteLength(value, 'utf8') < 2 ||
|
||||||
@@ -157,6 +83,7 @@ function inspectImage(
|
|||||||
image: string,
|
image: string,
|
||||||
profile: LocalDeploymentProfile,
|
profile: LocalDeploymentProfile,
|
||||||
sqliteContractVersion: number,
|
sqliteContractVersion: number,
|
||||||
|
applicationConfigVersion: '2' | '4',
|
||||||
): 'amd64' | 'arm64' {
|
): 'amd64' | 'arm64' {
|
||||||
const parsed = parseJson(output, 'Docker image inspect') as unknown[];
|
const parsed = parseJson(output, 'Docker image inspect') as unknown[];
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
||||||
@@ -194,6 +121,10 @@ function inspectImage(
|
|||||||
const minimum = safeLabel(labels, 'io.qinglong.local.sqlite-contract-min');
|
const minimum = safeLabel(labels, 'io.qinglong.local.sqlite-contract-min');
|
||||||
const maximum = safeLabel(labels, 'io.qinglong.local.sqlite-contract-max');
|
const maximum = safeLabel(labels, 'io.qinglong.local.sqlite-contract-max');
|
||||||
const profiles = safeLabel(labels, 'io.qinglong.profile').split(',');
|
const profiles = safeLabel(labels, 'io.qinglong.profile').split(',');
|
||||||
|
const applicationConfigs = safeLabel(
|
||||||
|
labels,
|
||||||
|
'io.qinglong.local.application-config',
|
||||||
|
).split(',');
|
||||||
if (
|
if (
|
||||||
!/^[1-9][0-9]{0,3}$/.test(minimum) ||
|
!/^[1-9][0-9]{0,3}$/.test(minimum) ||
|
||||||
!/^[1-9][0-9]{0,3}$/.test(maximum) ||
|
!/^[1-9][0-9]{0,3}$/.test(maximum) ||
|
||||||
@@ -201,7 +132,7 @@ function inspectImage(
|
|||||||
Number(maximum) < sqliteContractVersion ||
|
Number(maximum) < sqliteContractVersion ||
|
||||||
safeLabel(labels, 'io.qinglong.local.sqlite-write-contract') !==
|
safeLabel(labels, 'io.qinglong.local.sqlite-write-contract') !==
|
||||||
String(sqliteContractVersion) ||
|
String(sqliteContractVersion) ||
|
||||||
safeLabel(labels, 'io.qinglong.local.application-config') !== '2' ||
|
!applicationConfigs.includes(applicationConfigVersion) ||
|
||||||
safeLabel(labels, 'io.qinglong.local.compose-selection') !== '1' ||
|
safeLabel(labels, 'io.qinglong.local.compose-selection') !== '1' ||
|
||||||
safeLabel(labels, 'io.qinglong.ai') !== 'excluded' ||
|
safeLabel(labels, 'io.qinglong.ai') !== 'excluded' ||
|
||||||
!profiles.includes(profile) ||
|
!profiles.includes(profile) ||
|
||||||
@@ -233,10 +164,15 @@ function inspectComposeConfig(
|
|||||||
image: string;
|
image: string;
|
||||||
generation: number;
|
generation: number;
|
||||||
mutationId: string;
|
mutationId: string;
|
||||||
|
releaseSelectionDigest: string;
|
||||||
|
releaseSetDigest: string;
|
||||||
|
catalogManifestDigest: string;
|
||||||
|
catalogConsumptionReportDigest: string;
|
||||||
deploymentRoot: string;
|
deploymentRoot: string;
|
||||||
uid: number;
|
uid: number;
|
||||||
gid: number;
|
gid: number;
|
||||||
profile: LocalDeploymentProfile;
|
profile: LocalDeploymentProfile;
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>;
|
||||||
}>,
|
}>,
|
||||||
): void {
|
): void {
|
||||||
const config = parseJson(output, 'Docker Compose config') as {
|
const config = parseJson(output, 'Docker Compose config') as {
|
||||||
@@ -267,7 +203,8 @@ function inspectComposeConfig(
|
|||||||
service.user !== `${expected.uid}:${expected.gid}` ||
|
service.user !== `${expected.uid}:${expected.gid}` ||
|
||||||
service.read_only !== true ||
|
service.read_only !== true ||
|
||||||
service.network_mode !== 'none' ||
|
service.network_mode !== 'none' ||
|
||||||
service.restart !== 'unless-stopped' ||
|
service.restart !==
|
||||||
|
(expected.lineage.mode === 'fresh' ? 'unless-stopped' : 'no') ||
|
||||||
Number(service.mem_limit) !== expectedMemory ||
|
Number(service.mem_limit) !== expectedMemory ||
|
||||||
Number(service.pids_limit) !== expectedPids ||
|
Number(service.pids_limit) !== expectedPids ||
|
||||||
service.privileged === true ||
|
service.privileged === true ||
|
||||||
@@ -279,7 +216,9 @@ function inspectComposeConfig(
|
|||||||
!exactArray(service.security_opt, ['no-new-privileges:true']) ||
|
!exactArray(service.security_opt, ['no-new-privileges:true']) ||
|
||||||
!exactArray(service.command, [
|
!exactArray(service.command, [
|
||||||
'--config',
|
'--config',
|
||||||
`${CONTAINER_ROOT}/local-application.json`,
|
expected.lineage.mode === 'fresh'
|
||||||
|
? `${CONTAINER_ROOT}/local-application.json`
|
||||||
|
: path.join(expected.deploymentRoot, 'local-application.json'),
|
||||||
])
|
])
|
||||||
) {
|
) {
|
||||||
configurationError('Docker Compose service contract is invalid');
|
configurationError('Docker Compose service contract is invalid');
|
||||||
@@ -287,9 +226,44 @@ function inspectComposeConfig(
|
|||||||
const labels = service.labels as
|
const labels = service.labels as
|
||||||
| Readonly<Record<string, unknown>>
|
| Readonly<Record<string, unknown>>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
const expectedLabels =
|
||||||
|
expected.lineage.mode === 'fresh'
|
||||||
|
? {
|
||||||
|
'io.qinglong.deployment.generation': String(expected.generation),
|
||||||
|
'io.qinglong.deployment.mutation': expected.mutationId,
|
||||||
|
'io.qinglong.release.selection': expected.releaseSelectionDigest,
|
||||||
|
'io.qinglong.release.set': expected.releaseSetDigest,
|
||||||
|
'io.qinglong.release.catalog-manifest':
|
||||||
|
expected.catalogManifestDigest,
|
||||||
|
'io.qinglong.release.catalog-report':
|
||||||
|
expected.catalogConsumptionReportDigest,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
'io.qinglong.deployment.mode': 'adopted',
|
||||||
|
'io.qinglong.deployment.profile': expected.profile,
|
||||||
|
'io.qinglong.deployment.instance': expected.lineage.instanceId,
|
||||||
|
'io.qinglong.deployment.bundle': expected.lineage.bundleId!,
|
||||||
|
'io.qinglong.application.config':
|
||||||
|
expected.lineage.receipt.applicationConfigDigest,
|
||||||
|
'io.qinglong.data.commit':
|
||||||
|
expected.lineage.receipt.legacyDataApplicationCommitDigest!,
|
||||||
|
'io.qinglong.data.receipt':
|
||||||
|
expected.lineage.receipt.legacyDataApplicationReceiptDigest!,
|
||||||
|
'io.qinglong.deployment.generation': String(expected.generation),
|
||||||
|
'io.qinglong.deployment.mutation': expected.mutationId,
|
||||||
|
'io.qinglong.release.selection': expected.releaseSelectionDigest,
|
||||||
|
'io.qinglong.release.set': expected.releaseSetDigest,
|
||||||
|
'io.qinglong.release.catalog-manifest':
|
||||||
|
expected.catalogManifestDigest,
|
||||||
|
'io.qinglong.release.catalog-report':
|
||||||
|
expected.catalogConsumptionReportDigest,
|
||||||
|
};
|
||||||
if (
|
if (
|
||||||
!labels ||
|
!labels ||
|
||||||
Object.keys(labels).length !== 2 ||
|
Object.keys(labels).length !== Object.keys(expectedLabels).length ||
|
||||||
|
Object.entries(expectedLabels).some(
|
||||||
|
([key, value]) => labels[key] !== value,
|
||||||
|
) ||
|
||||||
labels['io.qinglong.deployment.generation'] !==
|
labels['io.qinglong.deployment.generation'] !==
|
||||||
String(expected.generation) ||
|
String(expected.generation) ||
|
||||||
labels['io.qinglong.deployment.mutation'] !== expected.mutationId
|
labels['io.qinglong.deployment.mutation'] !== expected.mutationId
|
||||||
@@ -297,15 +271,32 @@ function inspectComposeConfig(
|
|||||||
configurationError('Docker Compose deployment labels are invalid');
|
configurationError('Docker Compose deployment labels are invalid');
|
||||||
}
|
}
|
||||||
const volumes = service.volumes as readonly unknown[] | undefined;
|
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;
|
const tmpfs = service.tmpfs as readonly unknown[] | undefined;
|
||||||
|
const expectedVolumes =
|
||||||
|
expected.lineage.mode === 'fresh'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'bind',
|
||||||
|
source: expected.deploymentRoot,
|
||||||
|
target: CONTAINER_ROOT,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
type: 'bind',
|
||||||
|
source: expected.deploymentRoot,
|
||||||
|
target: expected.deploymentRoot,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bind',
|
||||||
|
source: expected.lineage.sourcePath,
|
||||||
|
target: expected.lineage.sourcePath,
|
||||||
|
read_only: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
if (
|
if (
|
||||||
!Array.isArray(volumes) ||
|
!Array.isArray(volumes) ||
|
||||||
volumes.length !== 1 ||
|
JSON.stringify(volumes) !== JSON.stringify(expectedVolumes) ||
|
||||||
!volume ||
|
|
||||||
volume.type !== 'bind' ||
|
|
||||||
volume.source !== expected.deploymentRoot ||
|
|
||||||
volume.target !== CONTAINER_ROOT ||
|
|
||||||
!Array.isArray(tmpfs) ||
|
!Array.isArray(tmpfs) ||
|
||||||
tmpfs.length !== 1 ||
|
tmpfs.length !== 1 ||
|
||||||
typeof tmpfs[0] !== 'string' ||
|
typeof tmpfs[0] !== 'string' ||
|
||||||
@@ -353,62 +344,66 @@ export async function preflightLocalDeploymentCompose(
|
|||||||
'active compose generation does not match expectedGeneration',
|
'active compose generation does not match expectedGeneration',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const application = readApplicationIdentity(
|
const application = inspectLocalDeploymentComposeLineage(
|
||||||
paths.applicationConfig,
|
command.options.deploymentRoot,
|
||||||
identity.uid,
|
|
||||||
);
|
|
||||||
const syntheticPrepare: Readonly<LocalDeploymentPrepareCommand> = {
|
|
||||||
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',
|
|
||||||
releaseSelection: {
|
|
||||||
path: paths.composeSelection,
|
|
||||||
expectedSelectionDigest: selection.selectionDigest,
|
|
||||||
},
|
|
||||||
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.uid,
|
||||||
identity.gid,
|
identity.gid,
|
||||||
|
command.options.allowRootService,
|
||||||
);
|
);
|
||||||
preflightPublishedFile(
|
if (application.mode === 'fresh') {
|
||||||
path.join(paths.service, expectedDescriptor.fileName),
|
const syntheticPrepare: Readonly<LocalDeploymentPrepareCommand> = {
|
||||||
expectedDescriptor.contents,
|
schemaVersion: 1,
|
||||||
expectedDescriptor.mode,
|
operation: 'local.deployment.prepare',
|
||||||
identity.uid,
|
options: {
|
||||||
'service descriptor',
|
deploymentRoot: command.options.deploymentRoot,
|
||||||
);
|
profile: application.profile,
|
||||||
|
instanceId: application.instanceId,
|
||||||
|
...(application.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: application.busyTimeoutMs }),
|
||||||
|
service: {
|
||||||
|
kind: 'compose',
|
||||||
|
releaseSelection: {
|
||||||
|
path: paths.composeSelection,
|
||||||
|
expectedSelectionDigest: selection.selectionDigest,
|
||||||
|
},
|
||||||
|
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 =
|
const auditSqlite =
|
||||||
dependencies.auditSqlite ?? inspectLocalSqliteReadinessPath;
|
dependencies.auditSqlite ?? inspectLocalSqliteReadinessPath;
|
||||||
const sqlite = await auditSqlite({
|
const sqlite = await auditSqlite({
|
||||||
databasePath: paths.database,
|
databasePath: application.databasePath,
|
||||||
profile: application.profile,
|
profile: application.profile,
|
||||||
...(application.busyTimeoutMs === undefined
|
...(application.busyTimeoutMs === undefined
|
||||||
? {}
|
? {}
|
||||||
@@ -429,6 +424,7 @@ export async function preflightLocalDeploymentCompose(
|
|||||||
selection.image,
|
selection.image,
|
||||||
application.profile,
|
application.profile,
|
||||||
sqlite.contractVersion,
|
sqlite.contractVersion,
|
||||||
|
application.mode === 'fresh' ? '2' : '4',
|
||||||
);
|
);
|
||||||
const composeOutput = runDocker({
|
const composeOutput = runDocker({
|
||||||
executable: command.options.dockerExecutable,
|
executable: command.options.dockerExecutable,
|
||||||
@@ -451,10 +447,15 @@ export async function preflightLocalDeploymentCompose(
|
|||||||
image: selection.image,
|
image: selection.image,
|
||||||
generation: selection.generation,
|
generation: selection.generation,
|
||||||
mutationId: selection.mutationId,
|
mutationId: selection.mutationId,
|
||||||
|
releaseSelectionDigest: selection.selectionDigest,
|
||||||
|
releaseSetDigest: selection.releaseSetDigest,
|
||||||
|
catalogManifestDigest: selection.catalogManifestDigest,
|
||||||
|
catalogConsumptionReportDigest: selection.catalogConsumptionReportDigest,
|
||||||
deploymentRoot: command.options.deploymentRoot,
|
deploymentRoot: command.options.deploymentRoot,
|
||||||
uid: identity.uid,
|
uid: identity.uid,
|
||||||
gid: identity.gid,
|
gid: identity.gid,
|
||||||
profile: application.profile,
|
profile: application.profile,
|
||||||
|
lineage: application,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -49,9 +49,16 @@ import {
|
|||||||
deploymentPaths,
|
deploymentPaths,
|
||||||
type LocalDeploymentPaths,
|
type LocalDeploymentPaths,
|
||||||
} from '../foundation/render';
|
} from '../foundation/render';
|
||||||
|
import {
|
||||||
|
assertLocalDeploymentComposeLineageReceipt,
|
||||||
|
inspectLocalDeploymentComposeLineage,
|
||||||
|
normalizeLocalDeploymentComposeLineageReceipt,
|
||||||
|
type LocalDeploymentComposeLineage,
|
||||||
|
type LocalDeploymentComposeLineageReceipt,
|
||||||
|
} from './composeLineage';
|
||||||
|
|
||||||
const PREPARE_SCHEMA = 'qinglong/local-compose-restore-prepare@v1';
|
const PREPARE_SCHEMA = 'qinglong/local-compose-restore-prepare@v2';
|
||||||
const COMMIT_SCHEMA = 'qinglong/local-compose-restore-commit@v1';
|
const COMMIT_SCHEMA = 'qinglong/local-compose-restore-commit@v2';
|
||||||
const UUID_V4_PATTERN =
|
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}$/;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
@@ -68,6 +75,7 @@ interface RestorePrepareReceipt {
|
|||||||
readonly generation: number;
|
readonly generation: number;
|
||||||
readonly recordedAtMs: number;
|
readonly recordedAtMs: number;
|
||||||
readonly profile: LocalDeploymentProfile;
|
readonly profile: LocalDeploymentProfile;
|
||||||
|
readonly lineage: Readonly<LocalDeploymentComposeLineageReceipt>;
|
||||||
readonly current: Readonly<LocalSqliteSnapshotEvidence>;
|
readonly current: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
readonly source: Readonly<LocalSqliteSnapshotEvidence>;
|
readonly source: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
readonly safeguard: Readonly<LocalSqliteSnapshotEvidence>;
|
readonly safeguard: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
@@ -82,6 +90,7 @@ interface RestoreCommitReceipt {
|
|||||||
readonly generation: number;
|
readonly generation: number;
|
||||||
readonly recordedAtMs: number;
|
readonly recordedAtMs: number;
|
||||||
readonly profile: LocalDeploymentProfile;
|
readonly profile: LocalDeploymentProfile;
|
||||||
|
readonly lineage: Readonly<LocalDeploymentComposeLineageReceipt>;
|
||||||
readonly source: Readonly<LocalSqliteSnapshotEvidence>;
|
readonly source: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
readonly safeguard: Readonly<LocalSqliteSnapshotEvidence>;
|
readonly safeguard: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
readonly restored: Readonly<LocalSqliteSnapshotEvidence>;
|
readonly restored: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
@@ -211,40 +220,6 @@ function boundedReceipt(
|
|||||||
return fs.readFileSync(filePath, 'utf8');
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
function readApplicationProfile(
|
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
|
||||||
uid: number,
|
|
||||||
): LocalDeploymentProfile {
|
|
||||||
const contents = boundedReceipt(
|
|
||||||
paths.applicationConfig,
|
|
||||||
uid,
|
|
||||||
'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;
|
|
||||||
readonly storage?: {
|
|
||||||
readonly mode?: unknown;
|
|
||||||
readonly databasePath?: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
if (
|
|
||||||
candidate?.schema !== 'qinglong/local-application-process@v2' ||
|
|
||||||
(candidate.profile !== 'edge' && candidate.profile !== 'standalone') ||
|
|
||||||
candidate.storage?.mode !== 'fresh' ||
|
|
||||||
candidate.storage.databasePath !== '/var/lib/qinglong3/qinglong3.sqlite'
|
|
||||||
) {
|
|
||||||
configurationError('application configuration restore binding drifted');
|
|
||||||
}
|
|
||||||
return candidate.profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
function composeArgs(
|
function composeArgs(
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
args: readonly string[],
|
args: readonly string[],
|
||||||
@@ -280,12 +255,17 @@ function stopAndVerifyCompose(
|
|||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
runDocker: LocalDeploymentDockerRunner,
|
runDocker: LocalDeploymentDockerRunner,
|
||||||
): void {
|
): void {
|
||||||
docker(
|
try {
|
||||||
command,
|
docker(
|
||||||
runDocker,
|
command,
|
||||||
composeArgs(paths, ['stop', '--timeout', '30', 'qinglong3']),
|
runDocker,
|
||||||
45_000,
|
composeArgs(paths, ['stop', '--timeout', '30', 'qinglong3']),
|
||||||
);
|
45_000,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// A lost Docker response is reconciled by the inspect-only observation
|
||||||
|
// below. The restore ceremony never repeats `compose stop` blindly.
|
||||||
|
}
|
||||||
const containerId = docker(
|
const containerId = docker(
|
||||||
command,
|
command,
|
||||||
runDocker,
|
runDocker,
|
||||||
@@ -418,6 +398,7 @@ async function readRestoreState(
|
|||||||
command: Readonly<LocalDeploymentComposeRestorePrepareCommand>,
|
command: Readonly<LocalDeploymentComposeRestorePrepareCommand>,
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
uid: number,
|
uid: number,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Promise<Readonly<RestoreState>> {
|
): Promise<Readonly<RestoreState>> {
|
||||||
const failedIntent = boundedReceipt(
|
const failedIntent = boundedReceipt(
|
||||||
paths.composeRolloutLock,
|
paths.composeRolloutLock,
|
||||||
@@ -478,7 +459,7 @@ async function readRestoreState(
|
|||||||
) {
|
) {
|
||||||
configurationError('compose restore recovery generation drifted');
|
configurationError('compose restore recovery generation drifted');
|
||||||
}
|
}
|
||||||
const profile = readApplicationProfile(paths, uid);
|
const profile = lineage.profile;
|
||||||
const sourcePath = path.join(
|
const sourcePath = path.join(
|
||||||
paths.composeRolloutBackups,
|
paths.composeRolloutBackups,
|
||||||
`${command.request.sourceRolloutId}.sqlite`,
|
`${command.request.sourceRolloutId}.sqlite`,
|
||||||
@@ -503,6 +484,7 @@ async function inspectPrepareReceipt(
|
|||||||
expectedCommandDigest: string,
|
expectedCommandDigest: string,
|
||||||
command: Readonly<LocalDeploymentComposeRestorePrepareCommand>,
|
command: Readonly<LocalDeploymentComposeRestorePrepareCommand>,
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
|
expectedLineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Promise<Readonly<RestorePrepareReceipt> | null> {
|
): Promise<Readonly<RestorePrepareReceipt> | null> {
|
||||||
if (!fs.existsSync(filePath)) return null;
|
if (!fs.existsSync(filePath)) return null;
|
||||||
const contents = boundedReceipt(
|
const contents = boundedReceipt(
|
||||||
@@ -518,6 +500,9 @@ async function inspectPrepareReceipt(
|
|||||||
configurationError('compose restore prepare receipt is invalid', error);
|
configurationError('compose restore prepare receipt is invalid', error);
|
||||||
}
|
}
|
||||||
const receipt = value as RestorePrepareReceipt;
|
const receipt = value as RestorePrepareReceipt;
|
||||||
|
const lineage = normalizeLocalDeploymentComposeLineageReceipt(
|
||||||
|
receipt.lineage,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!receipt ||
|
!receipt ||
|
||||||
typeof receipt !== 'object' ||
|
typeof receipt !== 'object' ||
|
||||||
@@ -528,6 +513,7 @@ async function inspectPrepareReceipt(
|
|||||||
'current',
|
'current',
|
||||||
'failedApplyCommandDigest',
|
'failedApplyCommandDigest',
|
||||||
'generation',
|
'generation',
|
||||||
|
'lineage',
|
||||||
'profile',
|
'profile',
|
||||||
'recordedAtMs',
|
'recordedAtMs',
|
||||||
'restoreId',
|
'restoreId',
|
||||||
@@ -550,10 +536,12 @@ async function inspectPrepareReceipt(
|
|||||||
!snapshotIsValid(receipt.source) ||
|
!snapshotIsValid(receipt.source) ||
|
||||||
!snapshotIsValid(receipt.safeguard) ||
|
!snapshotIsValid(receipt.safeguard) ||
|
||||||
receipt.current.sha256 === receipt.source.sha256 ||
|
receipt.current.sha256 === receipt.source.sha256 ||
|
||||||
|
JSON.stringify(receipt.lineage) !== JSON.stringify(lineage) ||
|
||||||
contents !== receiptContents(receipt)
|
contents !== receiptContents(receipt)
|
||||||
) {
|
) {
|
||||||
configurationError('compose restore prepare receipt drifted');
|
configurationError('compose restore prepare receipt drifted');
|
||||||
}
|
}
|
||||||
|
assertLocalDeploymentComposeLineageReceipt(lineage, expectedLineage.receipt);
|
||||||
const source = await inspectLocalSqliteSnapshot({
|
const source = await inspectLocalSqliteSnapshot({
|
||||||
databasePath: path.join(
|
databasePath: path.join(
|
||||||
paths.composeRolloutBackups,
|
paths.composeRolloutBackups,
|
||||||
@@ -586,6 +574,7 @@ function inspectCommitReceipt(
|
|||||||
uid: number,
|
uid: number,
|
||||||
expectedCommandDigest: string,
|
expectedCommandDigest: string,
|
||||||
command: Readonly<LocalDeploymentComposeRestoreCommand>,
|
command: Readonly<LocalDeploymentComposeRestoreCommand>,
|
||||||
|
expectedLineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Readonly<RestoreCommitReceipt> | null {
|
): Readonly<RestoreCommitReceipt> | null {
|
||||||
if (!fs.existsSync(filePath)) return null;
|
if (!fs.existsSync(filePath)) return null;
|
||||||
const contents = boundedReceipt(
|
const contents = boundedReceipt(
|
||||||
@@ -601,6 +590,9 @@ function inspectCommitReceipt(
|
|||||||
configurationError('compose restore commit receipt is invalid', error);
|
configurationError('compose restore commit receipt is invalid', error);
|
||||||
}
|
}
|
||||||
const receipt = value as RestoreCommitReceipt;
|
const receipt = value as RestoreCommitReceipt;
|
||||||
|
const lineage = normalizeLocalDeploymentComposeLineageReceipt(
|
||||||
|
receipt.lineage,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!receipt ||
|
!receipt ||
|
||||||
typeof receipt !== 'object' ||
|
typeof receipt !== 'object' ||
|
||||||
@@ -609,6 +601,7 @@ function inspectCommitReceipt(
|
|||||||
[
|
[
|
||||||
'commandDigest',
|
'commandDigest',
|
||||||
'generation',
|
'generation',
|
||||||
|
'lineage',
|
||||||
'prepareReceiptDigest',
|
'prepareReceiptDigest',
|
||||||
'profile',
|
'profile',
|
||||||
'recordedAtMs',
|
'recordedAtMs',
|
||||||
@@ -636,10 +629,12 @@ function inspectCommitReceipt(
|
|||||||
!snapshotIsValid(receipt.safeguard) ||
|
!snapshotIsValid(receipt.safeguard) ||
|
||||||
!snapshotIsValid(receipt.restored) ||
|
!snapshotIsValid(receipt.restored) ||
|
||||||
receipt.source.sha256 !== receipt.restored.sha256 ||
|
receipt.source.sha256 !== receipt.restored.sha256 ||
|
||||||
|
JSON.stringify(receipt.lineage) !== JSON.stringify(lineage) ||
|
||||||
contents !== receiptContents(receipt)
|
contents !== receiptContents(receipt)
|
||||||
) {
|
) {
|
||||||
configurationError('compose restore commit receipt drifted');
|
configurationError('compose restore commit receipt drifted');
|
||||||
}
|
}
|
||||||
|
assertLocalDeploymentComposeLineageReceipt(lineage, expectedLineage.receipt);
|
||||||
publishExactFile(
|
publishExactFile(
|
||||||
filePath,
|
filePath,
|
||||||
contents,
|
contents,
|
||||||
@@ -671,6 +666,7 @@ async function prepareRestore(
|
|||||||
command: Readonly<LocalDeploymentComposeRestorePrepareCommand>,
|
command: Readonly<LocalDeploymentComposeRestorePrepareCommand>,
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
dependencies: LocalDeploymentComposeRestoreDependencies,
|
dependencies: LocalDeploymentComposeRestoreDependencies,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Promise<Readonly<LocalDeploymentComposeRestoreResult>> {
|
): Promise<Readonly<LocalDeploymentComposeRestoreResult>> {
|
||||||
const identity = currentIdentity();
|
const identity = currentIdentity();
|
||||||
if (fs.existsSync(commitReceiptPath(paths, command.request.restoreId))) {
|
if (fs.existsSync(commitReceiptPath(paths, command.request.restoreId))) {
|
||||||
@@ -680,7 +676,7 @@ async function prepareRestore(
|
|||||||
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
dependencies.validateSocket ?? validateLocalDeploymentDockerSocket;
|
||||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||||
const state = await readRestoreState(command, paths, identity.uid);
|
const state = await readRestoreState(command, paths, identity.uid, lineage);
|
||||||
const intent = `${JSON.stringify(command, null, 2)}\n`;
|
const intent = `${JSON.stringify(command, null, 2)}\n`;
|
||||||
publishExactFile(
|
publishExactFile(
|
||||||
paths.composeRestoreLock,
|
paths.composeRestoreLock,
|
||||||
@@ -701,10 +697,11 @@ async function prepareRestore(
|
|||||||
commandDigest(command),
|
commandDigest(command),
|
||||||
command,
|
command,
|
||||||
paths,
|
paths,
|
||||||
|
lineage,
|
||||||
);
|
);
|
||||||
if (replay) {
|
if (replay) {
|
||||||
const current = await inspectLocalSqliteSnapshot({
|
const current = await inspectLocalSqliteSnapshot({
|
||||||
databasePath: paths.database,
|
databasePath: lineage.databasePath,
|
||||||
profile: replay.profile,
|
profile: replay.profile,
|
||||||
});
|
});
|
||||||
exactSnapshot(replay.current, current, 'compose restore current database');
|
exactSnapshot(replay.current, current, 'compose restore current database');
|
||||||
@@ -717,7 +714,7 @@ async function prepareRestore(
|
|||||||
}
|
}
|
||||||
const checkpoint = dependencies.checkpoint ?? checkpointLocalSqliteForRestore;
|
const checkpoint = dependencies.checkpoint ?? checkpointLocalSqliteForRestore;
|
||||||
const current = await checkpoint({
|
const current = await checkpoint({
|
||||||
databasePath: paths.database,
|
databasePath: lineage.databasePath,
|
||||||
profile: state.profile,
|
profile: state.profile,
|
||||||
});
|
});
|
||||||
if (current.sha256 === state.source.sha256) {
|
if (current.sha256 === state.source.sha256) {
|
||||||
@@ -728,7 +725,7 @@ async function prepareRestore(
|
|||||||
const createSafeguard =
|
const createSafeguard =
|
||||||
dependencies.createSafeguard ?? createLocalSqliteRolloutBackup;
|
dependencies.createSafeguard ?? createLocalSqliteRolloutBackup;
|
||||||
const safeguard = await createSafeguard({
|
const safeguard = await createSafeguard({
|
||||||
databasePath: paths.database,
|
databasePath: lineage.databasePath,
|
||||||
backupPath: safeguardPath(paths, command.request.restoreId),
|
backupPath: safeguardPath(paths, command.request.restoreId),
|
||||||
profile: state.profile,
|
profile: state.profile,
|
||||||
});
|
});
|
||||||
@@ -741,6 +738,7 @@ async function prepareRestore(
|
|||||||
generation: command.request.expectedGeneration,
|
generation: command.request.expectedGeneration,
|
||||||
recordedAtMs: command.request.preparedAtMs,
|
recordedAtMs: command.request.preparedAtMs,
|
||||||
profile: state.profile,
|
profile: state.profile,
|
||||||
|
lineage: lineage.receipt,
|
||||||
current: snapshotReceipt(current),
|
current: snapshotReceipt(current),
|
||||||
source: snapshotReceipt(state.source),
|
source: snapshotReceipt(state.source),
|
||||||
safeguard: snapshotReceipt(safeguard),
|
safeguard: snapshotReceipt(safeguard),
|
||||||
@@ -764,6 +762,7 @@ async function commitRestore(
|
|||||||
command: Readonly<LocalDeploymentComposeRestoreCommand>,
|
command: Readonly<LocalDeploymentComposeRestoreCommand>,
|
||||||
paths: Readonly<LocalDeploymentPaths>,
|
paths: Readonly<LocalDeploymentPaths>,
|
||||||
dependencies: LocalDeploymentComposeRestoreDependencies,
|
dependencies: LocalDeploymentComposeRestoreDependencies,
|
||||||
|
lineage: Readonly<LocalDeploymentComposeLineage>,
|
||||||
): Promise<Readonly<LocalDeploymentComposeRestoreResult>> {
|
): Promise<Readonly<LocalDeploymentComposeRestoreResult>> {
|
||||||
if (command.operation !== 'local.deployment.compose.restore.commit') {
|
if (command.operation !== 'local.deployment.compose.restore.commit') {
|
||||||
configurationError('compose restore commit command is invalid');
|
configurationError('compose restore commit command is invalid');
|
||||||
@@ -775,6 +774,7 @@ async function commitRestore(
|
|||||||
identity.uid,
|
identity.uid,
|
||||||
commandDigest(command),
|
commandDigest(command),
|
||||||
command,
|
command,
|
||||||
|
lineage,
|
||||||
);
|
);
|
||||||
if (replay) {
|
if (replay) {
|
||||||
const sourcePath = path.join(
|
const sourcePath = path.join(
|
||||||
@@ -945,11 +945,17 @@ async function commitRestore(
|
|||||||
commandDigest(prepareCommand),
|
commandDigest(prepareCommand),
|
||||||
prepareCommand,
|
prepareCommand,
|
||||||
paths,
|
paths,
|
||||||
|
lineage,
|
||||||
);
|
);
|
||||||
if (!prepareReceipt) {
|
if (!prepareReceipt) {
|
||||||
configurationError('compose restore prepare receipt is unavailable');
|
configurationError('compose restore prepare receipt is unavailable');
|
||||||
}
|
}
|
||||||
const state = await readRestoreState(prepareCommand, paths, identity.uid);
|
const state = await readRestoreState(
|
||||||
|
prepareCommand,
|
||||||
|
paths,
|
||||||
|
identity.uid,
|
||||||
|
lineage,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
state.failedCommandDigest !== prepareReceipt.failedApplyCommandDigest ||
|
state.failedCommandDigest !== prepareReceipt.failedApplyCommandDigest ||
|
||||||
state.source.sha256 !== prepareReceipt.source.sha256
|
state.source.sha256 !== prepareReceipt.source.sha256
|
||||||
@@ -961,11 +967,11 @@ async function commitRestore(
|
|||||||
validateSocket(command.options.dockerSocketPath, identity.uid);
|
validateSocket(command.options.dockerSocketPath, identity.uid);
|
||||||
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
const runDocker = dependencies.runDocker ?? runLocalDeploymentDockerCommand;
|
||||||
stopAndVerifyCompose(command, paths, runDocker);
|
stopAndVerifyCompose(command, paths, runDocker);
|
||||||
if (fs.existsSync(paths.database)) {
|
if (fs.existsSync(lineage.databasePath)) {
|
||||||
const checkpoint =
|
const checkpoint =
|
||||||
dependencies.checkpoint ?? checkpointLocalSqliteForRestore;
|
dependencies.checkpoint ?? checkpointLocalSqliteForRestore;
|
||||||
const current = await checkpoint({
|
const current = await checkpoint({
|
||||||
databasePath: paths.database,
|
databasePath: lineage.databasePath,
|
||||||
profile: prepareReceipt.profile,
|
profile: prepareReceipt.profile,
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
@@ -980,12 +986,13 @@ async function commitRestore(
|
|||||||
const restoreSnapshot =
|
const restoreSnapshot =
|
||||||
dependencies.restoreSnapshot ?? restoreLocalSqliteSnapshot;
|
dependencies.restoreSnapshot ?? restoreLocalSqliteSnapshot;
|
||||||
const restored: Readonly<LocalSqliteRestoreEvidence> = await restoreSnapshot({
|
const restored: Readonly<LocalSqliteRestoreEvidence> = await restoreSnapshot({
|
||||||
databasePath: paths.database,
|
databasePath: lineage.databasePath,
|
||||||
profile: prepareReceipt.profile,
|
profile: prepareReceipt.profile,
|
||||||
|
preserveDatabaseIdentity: lineage.mode === 'adopted',
|
||||||
sourceSnapshotPath: state.sourcePath,
|
sourceSnapshotPath: state.sourcePath,
|
||||||
restoreStagePath: path.join(
|
restoreStagePath: path.join(
|
||||||
path.dirname(paths.database),
|
path.dirname(lineage.databasePath),
|
||||||
`.${path.basename(paths.database)}.${
|
`.${path.basename(lineage.databasePath)}.${
|
||||||
command.request.restoreId
|
command.request.restoreId
|
||||||
}.restore-stage`,
|
}.restore-stage`,
|
||||||
),
|
),
|
||||||
@@ -1011,6 +1018,7 @@ async function commitRestore(
|
|||||||
generation: command.request.expectedGeneration,
|
generation: command.request.expectedGeneration,
|
||||||
recordedAtMs: command.request.committedAtMs,
|
recordedAtMs: command.request.committedAtMs,
|
||||||
profile: prepareReceipt.profile,
|
profile: prepareReceipt.profile,
|
||||||
|
lineage: lineage.receipt,
|
||||||
source: prepareReceipt.source,
|
source: prepareReceipt.source,
|
||||||
safeguard: prepareReceipt.safeguard,
|
safeguard: prepareReceipt.safeguard,
|
||||||
restored: snapshotReceipt(restored),
|
restored: snapshotReceipt(restored),
|
||||||
@@ -1073,13 +1081,19 @@ export async function restoreLocalDeploymentCompose(
|
|||||||
identity.uid,
|
identity.uid,
|
||||||
'composeRestoreSafeguardRoot',
|
'composeRestoreSafeguardRoot',
|
||||||
);
|
);
|
||||||
|
const lineage = inspectLocalDeploymentComposeLineage(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
identity.uid,
|
||||||
|
identity.gid,
|
||||||
|
command.options.allowRootService,
|
||||||
|
);
|
||||||
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
|
if (fs.existsSync(paths.composeEvidenceCollectionLock)) {
|
||||||
configurationError('compose restore is fenced by an evidence collection');
|
configurationError('compose restore is fenced by an evidence collection');
|
||||||
}
|
}
|
||||||
preflightRestoreCatalog(paths, command.request.restoreId);
|
preflightRestoreCatalog(paths, command.request.restoreId);
|
||||||
return command.operation === 'local.deployment.compose.restore.prepare'
|
return command.operation === 'local.deployment.compose.restore.prepare'
|
||||||
? prepareRestore(command, paths, dependencies)
|
? prepareRestore(command, paths, dependencies, lineage)
|
||||||
: commitRestore(command, paths, dependencies);
|
: commitRestore(command, paths, dependencies, lineage);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreLocalDeploymentComposeCommandFile(
|
export function restoreLocalDeploymentComposeCommandFile(
|
||||||
|
|||||||
@@ -4,16 +4,26 @@ const crypto = require('node:crypto');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
LocalDeploymentConfigurationError,
|
LocalDeploymentConfigurationError,
|
||||||
|
applyLocalDeploymentCompose,
|
||||||
|
collectLocalDeploymentComposeEvidence,
|
||||||
prepareLocalDeploymentAdoptedBundle,
|
prepareLocalDeploymentAdoptedBundle,
|
||||||
|
preflightLocalDeploymentCompose,
|
||||||
|
restoreLocalDeploymentCompose,
|
||||||
|
switchLocalDeploymentComposeRevision,
|
||||||
verifyLocalDeploymentAdoptedBundle,
|
verifyLocalDeploymentAdoptedBundle,
|
||||||
} = require('../dist/deployment/localDeployment.js');
|
} = require('../dist/deployment/localDeployment.js');
|
||||||
const {
|
const {
|
||||||
createLocalDataDirectoryApplicationCommit,
|
createLocalDataDirectoryApplicationCommit,
|
||||||
} = require('@qinglong/local-sqlite/data-directory-application-commit');
|
} = require('@qinglong/local-sqlite/data-directory-application-commit');
|
||||||
|
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||||
|
const {
|
||||||
|
createLocalSqliteRolloutBackup,
|
||||||
|
} = require('@qinglong/local-sqlite/rollout-safety');
|
||||||
const {
|
const {
|
||||||
normalizeLocalApplicationProcessConfig,
|
normalizeLocalApplicationProcessConfig,
|
||||||
} = require('../../ql3-local-application/dist/production-process/processConfig.js');
|
} = require('../../ql3-local-application/dist/production-process/processConfig.js');
|
||||||
@@ -42,8 +52,8 @@ function writePrivate(filePath, value) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseSelection(managementRoot) {
|
function releaseSelection(managementRoot, marker = 'a') {
|
||||||
const image = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat(
|
const image = `ghcr.io/example/qinglong3-local-application@sha256:${marker.repeat(
|
||||||
64,
|
64,
|
||||||
)}`;
|
)}`;
|
||||||
const releaseSetDigest = prefixedDigest(`release-set:${image}`);
|
const releaseSetDigest = prefixedDigest(`release-set:${image}`);
|
||||||
@@ -86,7 +96,10 @@ function releaseSelection(managementRoot) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const selectionDigest = prefixedDigest(JSON.stringify(unsigned));
|
const selectionDigest = prefixedDigest(JSON.stringify(unsigned));
|
||||||
const filePath = path.join(managementRoot, 'release-selection.json');
|
const filePath = path.join(
|
||||||
|
managementRoot,
|
||||||
|
`release-selection-${marker}.json`,
|
||||||
|
);
|
||||||
writePrivate(filePath, { ...unsigned, selectionDigest });
|
writePrivate(filePath, { ...unsigned, selectionDigest });
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
@@ -247,13 +260,373 @@ function fixture(t, kind) {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
|
managementRoot,
|
||||||
command,
|
command,
|
||||||
commitPath,
|
commitPath,
|
||||||
commitmentPath,
|
commitmentPath,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
recoveryPath,
|
||||||
|
manifestPath,
|
||||||
|
activationPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function prepareRuntimeComposeFixture(t) {
|
||||||
|
const state = fixture(t, 'compose');
|
||||||
|
fs.unlinkSync(state.targetPath);
|
||||||
|
await migrateLocalSqlitePath({
|
||||||
|
databasePath: state.targetPath,
|
||||||
|
profile: 'edge',
|
||||||
|
busyTimeoutMs: 100,
|
||||||
|
});
|
||||||
|
const target = fs.statSync(state.targetPath, { bigint: true });
|
||||||
|
const activation = JSON.parse(fs.readFileSync(state.activationPath, 'utf8'));
|
||||||
|
delete activation.activationDigest;
|
||||||
|
activation.targetSha256 = hexDigest(fs.readFileSync(state.targetPath));
|
||||||
|
activation.targetDevice = target.dev.toString();
|
||||||
|
activation.targetInode = target.ino.toString();
|
||||||
|
const activationDigest = canonicalDigest(activation);
|
||||||
|
writePrivate(state.activationPath, { ...activation, activationDigest });
|
||||||
|
const commitment = JSON.parse(fs.readFileSync(state.commitmentPath, 'utf8'));
|
||||||
|
delete commitment.commitmentDigest;
|
||||||
|
commitment.activationDigest = activationDigest;
|
||||||
|
const commitmentDigest = canonicalDigest(commitment);
|
||||||
|
writePrivate(state.commitmentPath, { ...commitment, commitmentDigest });
|
||||||
|
state.command.request.storage.expectedActivationDigest = activationDigest;
|
||||||
|
state.command.request.cutover.expectedCommitmentDigest = commitmentDigest;
|
||||||
|
prepareLocalDeploymentAdoptedBundle(state.command);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedComposeGeneration(state) {
|
||||||
|
const contents = fs.readFileSync(
|
||||||
|
path.join(state.root, 'service/compose.image.yaml'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
generation: Number(/^ generation: ([0-9]+)$/m.exec(contents)[1]),
|
||||||
|
mutationId: /^ mutation_id: ([0-9a-f-]+)$/m.exec(contents)[1],
|
||||||
|
image: /^ image: ([^\n]+)$/m.exec(contents)[1],
|
||||||
|
releaseSelectionDigest: /^ release_selection_digest: ([^\n]+)$/m.exec(
|
||||||
|
contents,
|
||||||
|
)[1],
|
||||||
|
releaseSetDigest: /^ release_set_digest: ([^\n]+)$/m.exec(contents)[1],
|
||||||
|
catalogManifestDigest: /^ catalog_manifest_digest: ([^\n]+)$/m.exec(
|
||||||
|
contents,
|
||||||
|
)[1],
|
||||||
|
catalogConsumptionReportDigest:
|
||||||
|
/^ catalog_consumption_report_digest: ([^\n]+)$/m.exec(contents)[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adoptedDockerHarness(state, options = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const containerId = '1'.repeat(64);
|
||||||
|
const bundle = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(state.root, 'service/adopted-bundle.json')),
|
||||||
|
);
|
||||||
|
const compose = fs.readFileSync(
|
||||||
|
path.join(state.root, 'service/compose.yaml'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const projectName = /^name: ([a-z0-9_-]+)$/m.exec(compose)[1];
|
||||||
|
let running = false;
|
||||||
|
let loseNextUpResponse = options.loseNextUpResponse === true;
|
||||||
|
const labels = (selected) => ({
|
||||||
|
'io.qinglong.deployment.mode': 'adopted',
|
||||||
|
'io.qinglong.deployment.profile': 'edge',
|
||||||
|
'io.qinglong.deployment.instance': 'edge-router-1',
|
||||||
|
'io.qinglong.deployment.bundle': bundle.bundleId,
|
||||||
|
'io.qinglong.application.config': bundle.applicationConfigDigest,
|
||||||
|
'io.qinglong.data.commit': bundle.legacyDataApplicationCommitDigest,
|
||||||
|
'io.qinglong.data.receipt': bundle.legacyDataApplicationReceiptDigest,
|
||||||
|
'io.qinglong.deployment.generation': String(selected.generation),
|
||||||
|
'io.qinglong.deployment.mutation': selected.mutationId,
|
||||||
|
'io.qinglong.release.selection': selected.releaseSelectionDigest,
|
||||||
|
'io.qinglong.release.set': selected.releaseSetDigest,
|
||||||
|
'io.qinglong.release.catalog-manifest': selected.catalogManifestDigest,
|
||||||
|
'io.qinglong.release.catalog-report':
|
||||||
|
selected.catalogConsumptionReportDigest,
|
||||||
|
});
|
||||||
|
const runDocker = ({ args }) => {
|
||||||
|
calls.push(args);
|
||||||
|
const selected = selectedComposeGeneration(state);
|
||||||
|
if (args[0] === 'image') {
|
||||||
|
return JSON.stringify([
|
||||||
|
{
|
||||||
|
Id: `sha256:${'2'.repeat(64)}`,
|
||||||
|
RepoDigests: [selected.image],
|
||||||
|
Architecture: 'arm64',
|
||||||
|
Os: 'linux',
|
||||||
|
Config: {
|
||||||
|
User: '65532:65532',
|
||||||
|
Entrypoint: [
|
||||||
|
'node',
|
||||||
|
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
|
||||||
|
],
|
||||||
|
Labels: {
|
||||||
|
'io.qinglong.local.sqlite-contract-min': '50',
|
||||||
|
'io.qinglong.local.sqlite-contract-max': '50',
|
||||||
|
'io.qinglong.local.sqlite-write-contract': '50',
|
||||||
|
'io.qinglong.local.application-config': '2,3,4',
|
||||||
|
'io.qinglong.local.compose-selection': '1',
|
||||||
|
'io.qinglong.ai': 'excluded',
|
||||||
|
'io.qinglong.profile': 'edge,standalone',
|
||||||
|
'org.opencontainers.image.source':
|
||||||
|
'https://github.com/whyour/qinglong',
|
||||||
|
'org.opencontainers.image.revision': '3'.repeat(40),
|
||||||
|
'org.opencontainers.image.version': '3.0.0-alpha.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (args[0] === 'compose' && args.includes('config')) {
|
||||||
|
const volumes = [
|
||||||
|
{ type: 'bind', source: state.root, target: state.root },
|
||||||
|
{
|
||||||
|
type: 'bind',
|
||||||
|
source: state.sourcePath,
|
||||||
|
target: state.sourcePath,
|
||||||
|
read_only: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (options.driftMount === true) volumes[0].target = '/var/lib/qinglong3';
|
||||||
|
return JSON.stringify({
|
||||||
|
name: projectName,
|
||||||
|
services: {
|
||||||
|
qinglong3: {
|
||||||
|
image: selected.image,
|
||||||
|
user: `${process.getuid()}:${process.getgid()}`,
|
||||||
|
read_only: true,
|
||||||
|
network_mode: 'none',
|
||||||
|
restart: 'no',
|
||||||
|
mem_limit: 128 * 1024 * 1024,
|
||||||
|
pids_limit: 64,
|
||||||
|
cap_drop: ['ALL'],
|
||||||
|
security_opt: ['no-new-privileges:true'],
|
||||||
|
command: [
|
||||||
|
'--config',
|
||||||
|
path.join(state.root, 'local-application.json'),
|
||||||
|
],
|
||||||
|
labels: labels(selected),
|
||||||
|
volumes,
|
||||||
|
tmpfs: ['/tmp:rw,noexec,nosuid,nodev,size=16m'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (args[0] === 'compose' && args.includes('up')) {
|
||||||
|
running = true;
|
||||||
|
if (loseNextUpResponse) {
|
||||||
|
loseNextUpResponse = false;
|
||||||
|
throw new Error('injected Docker response loss');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (args[0] === 'compose' && args.includes('stop')) {
|
||||||
|
running = false;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (args[0] === 'compose' && args.includes('ps')) return `${containerId}\n`;
|
||||||
|
if (args[0] === 'container' && args[1] === 'inspect') {
|
||||||
|
return JSON.stringify([
|
||||||
|
{
|
||||||
|
Id: containerId,
|
||||||
|
State: { Running: running, Status: running ? 'running' : 'exited' },
|
||||||
|
Config: { Image: selected.image, Labels: labels(selected) },
|
||||||
|
HostConfig: {
|
||||||
|
ReadonlyRootfs: true,
|
||||||
|
NetworkMode: 'none',
|
||||||
|
Privileged: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (args[0] === 'container' && args[1] === 'logs') {
|
||||||
|
return `${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
component: 'qinglong3-local-application',
|
||||||
|
level: 'info',
|
||||||
|
event: 'active',
|
||||||
|
profile: 'edge',
|
||||||
|
aiStatus: 'deployment_excluded',
|
||||||
|
instanceId: 'edge-router-1',
|
||||||
|
})}\n`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
runDocker,
|
||||||
|
now: () => 10_000,
|
||||||
|
wait: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeApplyCommand(state, generation, suffix) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.compose.apply',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.root,
|
||||||
|
dockerExecutable: fs.realpathSync(process.execPath),
|
||||||
|
dockerSocketPath: path.join(state.managementRoot, 'docker.sock'),
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
expectedGeneration: generation,
|
||||||
|
rolloutId: `00000000-0000-4000-8000-000000000d${suffix}`,
|
||||||
|
startedAtMs: 1786416000500,
|
||||||
|
failureRollbackMutationId: `00000000-0000-4000-8000-000000000e${suffix}`,
|
||||||
|
failureRollbackChangedAtMs: 1786416000501,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function composePreflightCommand(state, generation) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.compose.preflight',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.root,
|
||||||
|
dockerExecutable: fs.realpathSync(process.execPath),
|
||||||
|
dockerSocketPath: path.join(state.managementRoot, 'docker.sock'),
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: { expectedGeneration: generation },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeRevisionCommand(state, operation, request) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation,
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.root,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeRestoreCommands(state, failedCommand, suffix) {
|
||||||
|
const options = {
|
||||||
|
deploymentRoot: state.root,
|
||||||
|
dockerExecutable: fs.realpathSync(process.execPath),
|
||||||
|
dockerSocketPath: path.join(state.managementRoot, 'docker.sock'),
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
};
|
||||||
|
const restoreId = `00000000-0000-4000-8000-000000000d${suffix}`;
|
||||||
|
return {
|
||||||
|
prepare: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.compose.restore.prepare',
|
||||||
|
options,
|
||||||
|
request: {
|
||||||
|
expectedGeneration: failedCommand.request.expectedGeneration + 1,
|
||||||
|
restoreId,
|
||||||
|
sourceRolloutId: failedCommand.request.rolloutId,
|
||||||
|
preparedAtMs: 1786416000600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commit: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.compose.restore.commit',
|
||||||
|
options,
|
||||||
|
request: {
|
||||||
|
expectedGeneration: failedCommand.request.expectedGeneration + 1,
|
||||||
|
restoreId,
|
||||||
|
committedAtMs: 1786416000601,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeEvidenceCollectionCommands(
|
||||||
|
state,
|
||||||
|
generation,
|
||||||
|
restoreId,
|
||||||
|
suffix,
|
||||||
|
) {
|
||||||
|
const options = {
|
||||||
|
deploymentRoot: state.root,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
};
|
||||||
|
const collectionId = `00000000-0000-4000-8000-000000000d${suffix}`;
|
||||||
|
return {
|
||||||
|
prepare: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.compose.evidence-collection.prepare',
|
||||||
|
options,
|
||||||
|
request: {
|
||||||
|
expectedGeneration: generation,
|
||||||
|
collectionId,
|
||||||
|
rolloutIds: [],
|
||||||
|
restoreIds: [restoreId],
|
||||||
|
preparedAtMs: 1786416000700,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commit: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.compose.evidence-collection.commit',
|
||||||
|
options,
|
||||||
|
request: {
|
||||||
|
expectedGeneration: generation,
|
||||||
|
collectionId,
|
||||||
|
committedAtMs: 1786416000701,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFailedRestoreState(state, currentGeneration, suffix) {
|
||||||
|
const attemptedGeneration = currentGeneration + 1;
|
||||||
|
const upgradeMutationId = `00000000-0000-4000-8000-000000000a${suffix}`;
|
||||||
|
await switchLocalDeploymentComposeRevision(
|
||||||
|
composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
|
||||||
|
expectedGeneration: currentGeneration,
|
||||||
|
releaseSelection: releaseSelection(
|
||||||
|
state.managementRoot,
|
||||||
|
String(attemptedGeneration),
|
||||||
|
),
|
||||||
|
mutationId: upgradeMutationId,
|
||||||
|
changedAtMs: 1786416000400 + attemptedGeneration,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const failedCommand = composeApplyCommand(state, attemptedGeneration, suffix);
|
||||||
|
const intent = `${JSON.stringify(failedCommand, null, 2)}\n`;
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(state.root, 'service/.compose-rollout.lock'),
|
||||||
|
intent,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
await createLocalSqliteRolloutBackup({
|
||||||
|
databasePath: state.targetPath,
|
||||||
|
backupPath: path.join(
|
||||||
|
state.root,
|
||||||
|
'service',
|
||||||
|
'rollout-backups',
|
||||||
|
`${failedCommand.request.rolloutId}.sqlite`,
|
||||||
|
),
|
||||||
|
profile: 'edge',
|
||||||
|
});
|
||||||
|
const writer = new DatabaseSync(state.targetPath);
|
||||||
|
writer.exec(`PRAGMA user_version = ${900 + attemptedGeneration}`);
|
||||||
|
writer.close();
|
||||||
|
await switchLocalDeploymentComposeRevision(
|
||||||
|
composeRevisionCommand(state, 'local.deployment.compose.rollback', {
|
||||||
|
expectedGeneration: attemptedGeneration,
|
||||||
|
targetGeneration: currentGeneration,
|
||||||
|
mutationId: failedCommand.request.failureRollbackMutationId,
|
||||||
|
changedAtMs: failedCommand.request.failureRollbackChangedAtMs,
|
||||||
|
}),
|
||||||
|
intent,
|
||||||
|
);
|
||||||
|
return failedCommand;
|
||||||
|
}
|
||||||
|
|
||||||
for (const kind of ['systemd', 'openrc', 'compose']) {
|
for (const kind of ['systemd', 'openrc', 'compose']) {
|
||||||
test(`prepares and verifies an exact adopted ${kind} bundle without activation`, (t) => {
|
test(`prepares and verifies an exact adopted ${kind} bundle without activation`, (t) => {
|
||||||
const state = fixture(t, kind);
|
const state = fixture(t, kind);
|
||||||
@@ -406,6 +779,233 @@ test('requires the legacy source to be the only authority outside deployment roo
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preflights adopted Compose identity mounts and rejects mount drift', async (t) => {
|
||||||
|
const state = await prepareRuntimeComposeFixture(t);
|
||||||
|
const harness = adoptedDockerHarness(state);
|
||||||
|
const ready = await preflightLocalDeploymentCompose(
|
||||||
|
composePreflightCommand(state, 1),
|
||||||
|
{
|
||||||
|
runDocker: harness.runDocker,
|
||||||
|
validateSocket() {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(ready.status, 'ready');
|
||||||
|
assert.equal(ready.profile, 'edge');
|
||||||
|
assert.equal(ready.sqlite.contractVersion, 50);
|
||||||
|
await assert.rejects(
|
||||||
|
preflightLocalDeploymentCompose(composePreflightCommand(state, 1), {
|
||||||
|
runDocker: adoptedDockerHarness(state, { driftMount: true }).runDocker,
|
||||||
|
validateSocket() {},
|
||||||
|
}),
|
||||||
|
LocalDeploymentConfigurationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dockerComposeAvailable =
|
||||||
|
spawnSync('docker', ['compose', 'version'], { encoding: 'utf8' }).status ===
|
||||||
|
0;
|
||||||
|
|
||||||
|
test(
|
||||||
|
'accepts the real Docker Compose adopted config projection',
|
||||||
|
{ skip: !dockerComposeAvailable },
|
||||||
|
async (t) => {
|
||||||
|
const state = await prepareRuntimeComposeFixture(t);
|
||||||
|
const harness = adoptedDockerHarness(state);
|
||||||
|
const runDocker = ({ args, ...request }) => {
|
||||||
|
if (args[0] === 'compose' && args.includes('config')) {
|
||||||
|
const result = spawnSync('docker', args, { encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
return harness.runDocker({ args, ...request });
|
||||||
|
};
|
||||||
|
const ready = await preflightLocalDeploymentCompose(
|
||||||
|
composePreflightCommand(state, 1),
|
||||||
|
{ runDocker, validateSocket() {} },
|
||||||
|
);
|
||||||
|
assert.equal(ready.status, 'ready');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('recovers adopted Compose up response loss and binds the rollout receipt', async (t) => {
|
||||||
|
const state = await prepareRuntimeComposeFixture(t);
|
||||||
|
const harness = adoptedDockerHarness(state, { loseNextUpResponse: true });
|
||||||
|
const command = composeApplyCommand(state, 1, '89');
|
||||||
|
const dependencies = {
|
||||||
|
runDocker: harness.runDocker,
|
||||||
|
validateSocket() {},
|
||||||
|
now: harness.now,
|
||||||
|
wait: harness.wait,
|
||||||
|
};
|
||||||
|
const applied = await applyLocalDeploymentCompose(command, dependencies);
|
||||||
|
assert.equal(applied.status, 'active');
|
||||||
|
assert.equal(
|
||||||
|
harness.calls.filter((args) => args[0] === 'compose' && args.includes('up'))
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const receipt = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
state.root,
|
||||||
|
'service',
|
||||||
|
'rollouts',
|
||||||
|
`${command.request.rolloutId}.json`,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(receipt.schema, 'qinglong/local-compose-rollout-receipt@v3');
|
||||||
|
assert.equal(receipt.lineage.mode, 'adopted');
|
||||||
|
assert.equal(
|
||||||
|
receipt.lineage.legacyDataApplicationCommitDigest,
|
||||||
|
state.command.request.legacyDataApplication.expectedCommitDigest,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
receipt.lineage.legacyDataApplicationReceiptDigest,
|
||||||
|
state.command.request.legacyDataApplication.expectedReceiptDigest,
|
||||||
|
);
|
||||||
|
const replay = await applyLocalDeploymentCompose(command, dependencies);
|
||||||
|
assert.equal(replay.status, 'active');
|
||||||
|
assert.equal(
|
||||||
|
harness.calls.filter((args) => args[0] === 'compose' && args.includes('up'))
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const commitment = JSON.parse(fs.readFileSync(state.commitmentPath, 'utf8'));
|
||||||
|
writePrivate(state.commitmentPath, {
|
||||||
|
...commitment,
|
||||||
|
observedAtMs: commitment.observedAtMs + 1,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalDeploymentCompose(command, dependencies),
|
||||||
|
LocalDeploymentConfigurationError,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
harness.calls.filter((args) => args[0] === 'compose' && args.includes('up'))
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves adopted restore identity and carries lineage through evidence collection', async (t) => {
|
||||||
|
const state = await prepareRuntimeComposeFixture(t);
|
||||||
|
const activated = fs.statSync(state.targetPath, { bigint: true });
|
||||||
|
const failedCommand = await createFailedRestoreState(state, 1, '90');
|
||||||
|
const restore = composeRestoreCommands(state, failedCommand, '91');
|
||||||
|
const harness = adoptedDockerHarness(state);
|
||||||
|
const dependencies = {
|
||||||
|
runDocker: harness.runDocker,
|
||||||
|
validateSocket() {},
|
||||||
|
};
|
||||||
|
const prepared = await restoreLocalDeploymentCompose(
|
||||||
|
restore.prepare,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
assert.equal(prepared.status, 'prepared');
|
||||||
|
const committed = await restoreLocalDeploymentCompose(
|
||||||
|
restore.commit,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
assert.equal(committed.status, 'restored');
|
||||||
|
const restored = fs.statSync(state.targetPath, { bigint: true });
|
||||||
|
assert.equal(restored.dev, activated.dev);
|
||||||
|
assert.equal(restored.ino, activated.ino);
|
||||||
|
const commitPath = path.join(
|
||||||
|
state.root,
|
||||||
|
'service',
|
||||||
|
'restores',
|
||||||
|
`${restore.commit.request.restoreId}.commit.json`,
|
||||||
|
);
|
||||||
|
const restoreReceipt = JSON.parse(fs.readFileSync(commitPath, 'utf8'));
|
||||||
|
assert.equal(
|
||||||
|
restoreReceipt.schema,
|
||||||
|
'qinglong/local-compose-restore-commit@v2',
|
||||||
|
);
|
||||||
|
assert.equal(restoreReceipt.lineage.mode, 'adopted');
|
||||||
|
assert.equal(
|
||||||
|
restoreReceipt.lineage.legacyDataApplicationReceiptDigest,
|
||||||
|
state.command.request.legacyDataApplication.expectedReceiptDigest,
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyHarness = adoptedDockerHarness(state);
|
||||||
|
await applyLocalDeploymentCompose(failedCommand, {
|
||||||
|
runDocker: applyHarness.runDocker,
|
||||||
|
validateSocket() {},
|
||||||
|
now: applyHarness.now,
|
||||||
|
wait: applyHarness.wait,
|
||||||
|
});
|
||||||
|
const secondRestoreId = '00000000-0000-4000-8000-000000000d92';
|
||||||
|
const secondSafeguard = await createLocalSqliteRolloutBackup({
|
||||||
|
databasePath: state.targetPath,
|
||||||
|
backupPath: path.join(
|
||||||
|
state.root,
|
||||||
|
'service',
|
||||||
|
'restore-safeguards',
|
||||||
|
`${secondRestoreId}.sqlite`,
|
||||||
|
),
|
||||||
|
profile: 'edge',
|
||||||
|
});
|
||||||
|
const secondReceipt = {
|
||||||
|
...restoreReceipt,
|
||||||
|
commandDigest: 'd'.repeat(64),
|
||||||
|
restoreId: secondRestoreId,
|
||||||
|
recordedAtMs: restoreReceipt.recordedAtMs + 1,
|
||||||
|
safeguard: {
|
||||||
|
contractVersion: secondSafeguard.contractVersion,
|
||||||
|
sha256: secondSafeguard.sha256,
|
||||||
|
bytes: secondSafeguard.bytes,
|
||||||
|
pageCount: secondSafeguard.pageCount,
|
||||||
|
pageSize: secondSafeguard.pageSize,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writePrivate(
|
||||||
|
path.join(
|
||||||
|
state.root,
|
||||||
|
'service',
|
||||||
|
'restores',
|
||||||
|
`${secondRestoreId}.commit.json`,
|
||||||
|
),
|
||||||
|
`${JSON.stringify(secondReceipt, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
const collection = composeEvidenceCollectionCommands(
|
||||||
|
state,
|
||||||
|
3,
|
||||||
|
restore.commit.request.restoreId,
|
||||||
|
'93',
|
||||||
|
);
|
||||||
|
const collectionPrepared = await collectLocalDeploymentComposeEvidence(
|
||||||
|
collection.prepare,
|
||||||
|
);
|
||||||
|
assert.equal(collectionPrepared.status, 'prepared');
|
||||||
|
const collectionCommitted = await collectLocalDeploymentComposeEvidence(
|
||||||
|
collection.commit,
|
||||||
|
);
|
||||||
|
assert.equal(collectionCommitted.status, 'collected');
|
||||||
|
const collectionReceipt = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
state.root,
|
||||||
|
'service',
|
||||||
|
'evidence-collections',
|
||||||
|
`${collection.commit.request.collectionId}.commit.json`,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
collectionReceipt.schema,
|
||||||
|
'qinglong/local-compose-evidence-collection-commit@v2',
|
||||||
|
);
|
||||||
|
assert.deepEqual(collectionReceipt.lineage, restoreReceipt.lineage);
|
||||||
|
const replay = await restoreLocalDeploymentCompose(
|
||||||
|
restore.commit,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
assert.equal(replay.status, 'existing');
|
||||||
|
assert.equal(replay.sqlite.safeguard, 'collected');
|
||||||
|
});
|
||||||
|
|
||||||
test('exposes separate exact prepare and verify CLI operations', (t) => {
|
test('exposes separate exact prepare and verify CLI operations', (t) => {
|
||||||
const state = fixture(t, 'systemd');
|
const state = fixture(t, 'systemd');
|
||||||
const cli = path.resolve(
|
const cli = path.resolve(
|
||||||
|
|||||||
@@ -450,6 +450,15 @@ function composeDockerHarness(
|
|||||||
generation: Number(/^ generation: ([0-9]+)$/m.exec(source)[1]),
|
generation: Number(/^ generation: ([0-9]+)$/m.exec(source)[1]),
|
||||||
mutationId: /^ mutation_id: ([0-9a-f-]+)$/m.exec(source)[1],
|
mutationId: /^ mutation_id: ([0-9a-f-]+)$/m.exec(source)[1],
|
||||||
image: /^ image: ([^\n]+)$/m.exec(source)[1],
|
image: /^ image: ([^\n]+)$/m.exec(source)[1],
|
||||||
|
releaseSelectionDigest: /^ release_selection_digest: ([^\n]+)$/m.exec(
|
||||||
|
source,
|
||||||
|
)[1],
|
||||||
|
releaseSetDigest: /^ release_set_digest: ([^\n]+)$/m.exec(source)[1],
|
||||||
|
catalogManifestDigest: /^ catalog_manifest_digest: ([^\n]+)$/m.exec(
|
||||||
|
source,
|
||||||
|
)[1],
|
||||||
|
catalogConsumptionReportDigest:
|
||||||
|
/^ catalog_consumption_report_digest: ([^\n]+)$/m.exec(source)[1],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const composeSource = fs.readFileSync(
|
const composeSource = fs.readFileSync(
|
||||||
@@ -457,6 +466,12 @@ function composeDockerHarness(
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
const projectName = /^name: ([a-z0-9_-]+)$/m.exec(composeSource)[1];
|
const projectName = /^name: ([a-z0-9_-]+)$/m.exec(composeSource)[1];
|
||||||
|
const release = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
state.command.options.service.releaseSelection.path,
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
let running = true;
|
let running = true;
|
||||||
const runDocker = ({ args }) => {
|
const runDocker = ({ args }) => {
|
||||||
calls.push(args);
|
calls.push(args);
|
||||||
@@ -509,6 +524,12 @@ function composeDockerHarness(
|
|||||||
labels: {
|
labels: {
|
||||||
'io.qinglong.deployment.generation': String(selected.generation),
|
'io.qinglong.deployment.generation': String(selected.generation),
|
||||||
'io.qinglong.deployment.mutation': selected.mutationId,
|
'io.qinglong.deployment.mutation': selected.mutationId,
|
||||||
|
'io.qinglong.release.selection': selected.releaseSelectionDigest,
|
||||||
|
'io.qinglong.release.set': selected.releaseSetDigest,
|
||||||
|
'io.qinglong.release.catalog-manifest':
|
||||||
|
selected.catalogManifestDigest,
|
||||||
|
'io.qinglong.release.catalog-report':
|
||||||
|
selected.catalogConsumptionReportDigest,
|
||||||
},
|
},
|
||||||
volumes: [
|
volumes: [
|
||||||
{
|
{
|
||||||
@@ -1137,6 +1158,12 @@ test('preflights exact local image, Compose merge and SQLite capability', async
|
|||||||
await prepareLocalDeployment(state.command);
|
await prepareLocalDeployment(state.command);
|
||||||
const dockerSocketPath = path.join(state.managementRoot, 'docker.sock');
|
const dockerSocketPath = path.join(state.managementRoot, 'docker.sock');
|
||||||
const image = state.composeImage;
|
const image = state.composeImage;
|
||||||
|
const release = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
state.command.options.service.releaseSelection.path,
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
const composeSource = fs.readFileSync(
|
const composeSource = fs.readFileSync(
|
||||||
path.join(state.deploymentRoot, 'service', 'compose.yaml'),
|
path.join(state.deploymentRoot, 'service', 'compose.yaml'),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -1206,6 +1233,12 @@ test('preflights exact local image, Compose merge and SQLite capability', async
|
|||||||
'io.qinglong.deployment.generation': '1',
|
'io.qinglong.deployment.generation': '1',
|
||||||
'io.qinglong.deployment.mutation':
|
'io.qinglong.deployment.mutation':
|
||||||
state.command.request.activateMutationId,
|
state.command.request.activateMutationId,
|
||||||
|
'io.qinglong.release.selection': release.selectionDigest,
|
||||||
|
'io.qinglong.release.set': release.releaseSetDigest,
|
||||||
|
'io.qinglong.release.catalog-manifest':
|
||||||
|
release.catalog.manifestDigest,
|
||||||
|
'io.qinglong.release.catalog-report':
|
||||||
|
release.catalog.consumptionReportDigest,
|
||||||
},
|
},
|
||||||
volumes: [
|
volumes: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface LocalSqliteRestoreOptions extends LocalSqliteDatabaseOptions {
|
|||||||
readonly replacedDatabasePath: string;
|
readonly replacedDatabasePath: string;
|
||||||
readonly expectedCurrentSha256: string;
|
readonly expectedCurrentSha256: string;
|
||||||
readonly expectedSourceSha256: string;
|
readonly expectedSourceSha256: string;
|
||||||
|
readonly preserveDatabaseIdentity?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalSqliteRestoreEvidence
|
export interface LocalSqliteRestoreEvidence
|
||||||
@@ -58,6 +59,7 @@ export interface LocalSqliteRestoreEvidence
|
|||||||
|
|
||||||
export interface LocalSqliteRestoreDependencies {
|
export interface LocalSqliteRestoreDependencies {
|
||||||
readonly copySnapshot?: (sourcePath: string, targetPath: string) => void;
|
readonly copySnapshot?: (sourcePath: string, targetPath: string) => void;
|
||||||
|
readonly rewriteSnapshot?: (sourcePath: string, targetPath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalSqliteChangeObserver {
|
export interface LocalSqliteChangeObserver {
|
||||||
@@ -582,6 +584,239 @@ function sameSnapshot(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rewriteSnapshotFilePreservingIdentity(
|
||||||
|
sourcePath: string,
|
||||||
|
targetPath: string,
|
||||||
|
): void {
|
||||||
|
const targetIdentity = fs.lstatSync(targetPath, { bigint: true });
|
||||||
|
let sourceDescriptor: number | undefined;
|
||||||
|
let targetDescriptor: number | undefined;
|
||||||
|
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||||
|
try {
|
||||||
|
sourceDescriptor = fs.openSync(
|
||||||
|
sourcePath,
|
||||||
|
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
|
targetDescriptor = fs.openSync(
|
||||||
|
targetPath,
|
||||||
|
fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
|
const openedTarget = fs.fstatSync(targetDescriptor, { bigint: true });
|
||||||
|
if (
|
||||||
|
!openedTarget.isFile() ||
|
||||||
|
openedTarget.dev !== targetIdentity.dev ||
|
||||||
|
openedTarget.ino !== targetIdentity.ino ||
|
||||||
|
openedTarget.nlink !== 1n
|
||||||
|
) {
|
||||||
|
configurationError('identity-preserving restore target drifted');
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
const count = fs.readSync(
|
||||||
|
sourceDescriptor,
|
||||||
|
buffer,
|
||||||
|
0,
|
||||||
|
buffer.byteLength,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (count === 0) break;
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < count) {
|
||||||
|
const written = fs.writeSync(
|
||||||
|
targetDescriptor,
|
||||||
|
buffer,
|
||||||
|
offset,
|
||||||
|
count - offset,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (written < 1) {
|
||||||
|
configurationError('identity-preserving restore write stalled');
|
||||||
|
}
|
||||||
|
offset += written;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.fsyncSync(targetDescriptor);
|
||||||
|
const writtenTarget = fs.fstatSync(targetDescriptor, { bigint: true });
|
||||||
|
if (
|
||||||
|
writtenTarget.dev !== targetIdentity.dev ||
|
||||||
|
writtenTarget.ino !== targetIdentity.ino ||
|
||||||
|
writtenTarget.nlink !== 1n
|
||||||
|
) {
|
||||||
|
configurationError('identity-preserving restore target changed');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
buffer.fill(0);
|
||||||
|
if (targetDescriptor !== undefined) fs.closeSync(targetDescriptor);
|
||||||
|
if (sourceDescriptor !== undefined) fs.closeSync(sourceDescriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSnapshotPreservingIdentity(
|
||||||
|
options: Readonly<LocalSqliteRestoreOptions>,
|
||||||
|
dependencies: LocalSqliteRestoreDependencies,
|
||||||
|
input: Readonly<{
|
||||||
|
uid: number;
|
||||||
|
databasePath: string;
|
||||||
|
sourceSnapshotPath: string;
|
||||||
|
restoreStagePath: string;
|
||||||
|
replacedDatabasePath: string;
|
||||||
|
source: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
|
}>,
|
||||||
|
): Promise<Readonly<LocalSqliteRestoreEvidence>> {
|
||||||
|
if (!fs.existsSync(input.databasePath)) {
|
||||||
|
configurationError(
|
||||||
|
'identity-preserving restore requires the activated database inode',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const targetIdentity = fs.lstatSync(input.databasePath);
|
||||||
|
if (
|
||||||
|
!targetIdentity.isFile() ||
|
||||||
|
targetIdentity.isSymbolicLink() ||
|
||||||
|
targetIdentity.uid !== input.uid ||
|
||||||
|
(targetIdentity.mode & 0o777) !== 0o600 ||
|
||||||
|
targetIdentity.nlink !== 1 ||
|
||||||
|
fs.realpathSync(input.databasePath) !== input.databasePath
|
||||||
|
) {
|
||||||
|
configurationError('identity-preserving restore database is invalid');
|
||||||
|
}
|
||||||
|
let current: Readonly<LocalSqliteSnapshotEvidence> | undefined;
|
||||||
|
try {
|
||||||
|
current = await inspectSnapshotFile(
|
||||||
|
input.databasePath,
|
||||||
|
options.profile,
|
||||||
|
input.uid,
|
||||||
|
'restore current database',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!fs.existsSync(input.restoreStagePath) ||
|
||||||
|
!fs.existsSync(input.replacedDatabasePath)
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const restoredAtEntry =
|
||||||
|
current !== undefined && sameSnapshot(current, input.source);
|
||||||
|
if (
|
||||||
|
current !== undefined &&
|
||||||
|
!restoredAtEntry &&
|
||||||
|
current.sha256 !== options.expectedCurrentSha256
|
||||||
|
) {
|
||||||
|
configurationError('restore current database drifted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(input.restoreStagePath) && !restoredAtEntry) {
|
||||||
|
try {
|
||||||
|
(
|
||||||
|
dependencies.copySnapshot ??
|
||||||
|
((sourcePath: string, targetPath: string) =>
|
||||||
|
fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL))
|
||||||
|
)(input.sourceSnapshotPath, input.restoreStagePath);
|
||||||
|
fs.chmodSync(input.restoreStagePath, 0o600);
|
||||||
|
syncFile(input.restoreStagePath);
|
||||||
|
syncDirectory(path.dirname(input.restoreStagePath));
|
||||||
|
} catch (error) {
|
||||||
|
if (fs.existsSync(input.restoreStagePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(input.restoreStagePath);
|
||||||
|
syncDirectory(path.dirname(input.restoreStagePath));
|
||||||
|
} catch {
|
||||||
|
// A deterministic stage remains fail-closed for exact replay.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configurationError('restore stage could not be created', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fs.existsSync(input.restoreStagePath)) {
|
||||||
|
const staged = await inspectSnapshotFile(
|
||||||
|
input.restoreStagePath,
|
||||||
|
options.profile,
|
||||||
|
input.uid,
|
||||||
|
'restore stage',
|
||||||
|
);
|
||||||
|
if (!sameSnapshot(staged, input.source)) {
|
||||||
|
configurationError('restore stage drifted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!restoredAtEntry && !fs.existsSync(input.replacedDatabasePath)) {
|
||||||
|
if (current === undefined) {
|
||||||
|
configurationError('restore replacement evidence is unavailable');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(
|
||||||
|
input.databasePath,
|
||||||
|
input.replacedDatabasePath,
|
||||||
|
fs.constants.COPYFILE_EXCL,
|
||||||
|
);
|
||||||
|
fs.chmodSync(input.replacedDatabasePath, 0o600);
|
||||||
|
syncFile(input.replacedDatabasePath);
|
||||||
|
syncDirectory(path.dirname(input.replacedDatabasePath));
|
||||||
|
} catch (error) {
|
||||||
|
configurationError(
|
||||||
|
'restore replacement evidence cannot be created',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fs.existsSync(input.replacedDatabasePath)) {
|
||||||
|
const replaced = await inspectSnapshotFile(
|
||||||
|
input.replacedDatabasePath,
|
||||||
|
options.profile,
|
||||||
|
input.uid,
|
||||||
|
'replaced database',
|
||||||
|
);
|
||||||
|
if (replaced.sha256 !== options.expectedCurrentSha256) {
|
||||||
|
configurationError('replaced database evidence drifted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!restoredAtEntry) {
|
||||||
|
try {
|
||||||
|
(dependencies.rewriteSnapshot ?? rewriteSnapshotFilePreservingIdentity)(
|
||||||
|
input.restoreStagePath,
|
||||||
|
input.databasePath,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
configurationError(
|
||||||
|
'identity-preserving restore write could not complete',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const afterIdentity = validateDatabaseFile(
|
||||||
|
input.databasePath,
|
||||||
|
input.uid,
|
||||||
|
'identity-preserving restored database',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
afterIdentity.dev !== targetIdentity.dev ||
|
||||||
|
afterIdentity.ino !== targetIdentity.ino
|
||||||
|
) {
|
||||||
|
configurationError('identity-preserving restore changed the target inode');
|
||||||
|
}
|
||||||
|
const restored = await inspectSnapshotFile(
|
||||||
|
input.databasePath,
|
||||||
|
options.profile,
|
||||||
|
input.uid,
|
||||||
|
'restored database',
|
||||||
|
);
|
||||||
|
if (!sameSnapshot(restored, input.source)) {
|
||||||
|
configurationError('restored database drifted');
|
||||||
|
}
|
||||||
|
for (const evidencePath of [
|
||||||
|
input.restoreStagePath,
|
||||||
|
input.replacedDatabasePath,
|
||||||
|
]) {
|
||||||
|
if (!fs.existsSync(evidencePath)) continue;
|
||||||
|
fs.unlinkSync(evidencePath);
|
||||||
|
syncDirectory(path.dirname(evidencePath));
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
status: restoredAtEntry ? ('existing' as const) : ('restored' as const),
|
||||||
|
...restored,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreLocalSqliteSnapshot(
|
export async function restoreLocalSqliteSnapshot(
|
||||||
options: Readonly<LocalSqliteRestoreOptions>,
|
options: Readonly<LocalSqliteRestoreOptions>,
|
||||||
dependencies: LocalSqliteRestoreDependencies = {},
|
dependencies: LocalSqliteRestoreDependencies = {},
|
||||||
@@ -608,6 +843,8 @@ export async function restoreLocalSqliteSnapshot(
|
|||||||
!DIGEST_PATTERN.test(options.expectedCurrentSha256) ||
|
!DIGEST_PATTERN.test(options.expectedCurrentSha256) ||
|
||||||
!DIGEST_PATTERN.test(options.expectedSourceSha256) ||
|
!DIGEST_PATTERN.test(options.expectedSourceSha256) ||
|
||||||
options.expectedCurrentSha256 === options.expectedSourceSha256 ||
|
options.expectedCurrentSha256 === options.expectedSourceSha256 ||
|
||||||
|
(options.preserveDatabaseIdentity !== undefined &&
|
||||||
|
typeof options.preserveDatabaseIdentity !== 'boolean') ||
|
||||||
new Set([
|
new Set([
|
||||||
databasePath,
|
databasePath,
|
||||||
sourceSnapshotPath,
|
sourceSnapshotPath,
|
||||||
@@ -641,6 +878,17 @@ export async function restoreLocalSqliteSnapshot(
|
|||||||
}
|
}
|
||||||
assertNoRestoreSidecars(databasePath, uid);
|
assertNoRestoreSidecars(databasePath, uid);
|
||||||
|
|
||||||
|
if (options.preserveDatabaseIdentity === true) {
|
||||||
|
return restoreSnapshotPreservingIdentity(options, dependencies, {
|
||||||
|
uid,
|
||||||
|
databasePath,
|
||||||
|
sourceSnapshotPath,
|
||||||
|
restoreStagePath,
|
||||||
|
replacedDatabasePath,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let restoredAtEntry = false;
|
let restoredAtEntry = false;
|
||||||
if (fs.existsSync(databasePath)) {
|
if (fs.existsSync(databasePath)) {
|
||||||
const current = await inspectSnapshotFile(
|
const current = await inspectSnapshotFile(
|
||||||
|
|||||||
@@ -210,6 +210,72 @@ test('converges the moved-current restore window and cleans ENOSPC stage', async
|
|||||||
assert.equal(fs.existsSync(replacedDatabasePath), false);
|
assert.equal(fs.existsSync(replacedDatabasePath), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preserves an activated inode and recovers an ENOSPC partial rewrite', async (t) => {
|
||||||
|
const state = fixture(t);
|
||||||
|
await migrateLocalSqlitePath(state);
|
||||||
|
const activated = fs.statSync(state.databasePath, { bigint: true });
|
||||||
|
const source = await createLocalSqliteRolloutBackup(state);
|
||||||
|
const writer = new DatabaseSync(state.databasePath);
|
||||||
|
writer.exec('PRAGMA user_version = 29');
|
||||||
|
writer.close();
|
||||||
|
const current = await checkpointLocalSqliteForRestore(state);
|
||||||
|
const restoreStagePath = path.join(state.root, '.identity.restore-stage');
|
||||||
|
const replacedDatabasePath = path.join(
|
||||||
|
path.dirname(state.backupPath),
|
||||||
|
'identity.replaced.sqlite',
|
||||||
|
);
|
||||||
|
const restoreOptions = {
|
||||||
|
databasePath: state.databasePath,
|
||||||
|
profile: state.profile,
|
||||||
|
sourceSnapshotPath: state.backupPath,
|
||||||
|
restoreStagePath,
|
||||||
|
replacedDatabasePath,
|
||||||
|
expectedCurrentSha256: current.sha256,
|
||||||
|
expectedSourceSha256: source.sha256,
|
||||||
|
preserveDatabaseIdentity: true,
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
restoreLocalSqliteSnapshot(restoreOptions, {
|
||||||
|
rewriteSnapshot(_sourcePath, targetPath) {
|
||||||
|
const descriptor = fs.openSync(
|
||||||
|
targetPath,
|
||||||
|
fs.constants.O_WRONLY |
|
||||||
|
fs.constants.O_TRUNC |
|
||||||
|
fs.constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
fs.writeSync(descriptor, Buffer.from('partial'));
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error('injected identity restore ENOSPC'), {
|
||||||
|
code: 'ENOSPC',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/identity-preserving restore write could not complete/,
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(restoreStagePath), true);
|
||||||
|
assert.equal(fs.existsSync(replacedDatabasePath), true);
|
||||||
|
const partial = fs.statSync(state.databasePath, { bigint: true });
|
||||||
|
assert.equal(partial.dev, activated.dev);
|
||||||
|
assert.equal(partial.ino, activated.ino);
|
||||||
|
|
||||||
|
const recovered = await restoreLocalSqliteSnapshot(restoreOptions);
|
||||||
|
assert.equal(recovered.status, 'restored');
|
||||||
|
assert.equal(recovered.sha256, source.sha256);
|
||||||
|
const restored = fs.statSync(state.databasePath, { bigint: true });
|
||||||
|
assert.equal(restored.dev, activated.dev);
|
||||||
|
assert.equal(restored.ino, activated.ino);
|
||||||
|
assert.equal(fs.existsSync(restoreStagePath), false);
|
||||||
|
assert.equal(fs.existsSync(replacedDatabasePath), false);
|
||||||
|
assert.equal(
|
||||||
|
(await restoreLocalSqliteSnapshot(restoreOptions)).status,
|
||||||
|
'existing',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('rollout safety subpath excludes DDL and mutable repositories', () => {
|
test('rollout safety subpath excludes DDL and mutable repositories', () => {
|
||||||
const script = `
|
const script = `
|
||||||
const safety = require(${JSON.stringify(
|
const safety = require(${JSON.stringify(
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ function expectedImageConfig(architecture, revision, image) {
|
|||||||
WorkingDir: '/opt/qinglong',
|
WorkingDir: '/opt/qinglong',
|
||||||
Labels: {
|
Labels: {
|
||||||
'io.qinglong.ai': 'excluded',
|
'io.qinglong.ai': 'excluded',
|
||||||
'io.qinglong.local.application-config': '2',
|
'io.qinglong.local.application-config': '2,3,4',
|
||||||
'io.qinglong.local.compose-selection': '1',
|
'io.qinglong.local.compose-selection': '1',
|
||||||
'io.qinglong.local.sqlite-contract-max': '41',
|
'io.qinglong.local.sqlite-contract-max': '41',
|
||||||
'io.qinglong.local.sqlite-contract-min': '41',
|
'io.qinglong.local.sqlite-contract-min': '41',
|
||||||
|
|||||||
@@ -246,10 +246,10 @@ function auditDockerfile(contents, findings) {
|
|||||||
) ||
|
) ||
|
||||||
!contents.includes('io.qinglong.ai="excluded"') ||
|
!contents.includes('io.qinglong.ai="excluded"') ||
|
||||||
!contents.includes('io.qinglong.profile="edge,standalone"') ||
|
!contents.includes('io.qinglong.profile="edge,standalone"') ||
|
||||||
!contents.includes('io.qinglong.local.application-config="2"') ||
|
!contents.includes('io.qinglong.local.application-config="2,3,4"') ||
|
||||||
!contents.includes('io.qinglong.local.sqlite-contract-min="44"') ||
|
!contents.includes('io.qinglong.local.sqlite-contract-min="50"') ||
|
||||||
!contents.includes('io.qinglong.local.sqlite-contract-max="44"') ||
|
!contents.includes('io.qinglong.local.sqlite-contract-max="50"') ||
|
||||||
!contents.includes('io.qinglong.local.sqlite-write-contract="44"') ||
|
!contents.includes('io.qinglong.local.sqlite-write-contract="50"') ||
|
||||||
!contents.includes('io.qinglong.local.compose-selection="1"')
|
!contents.includes('io.qinglong.local.compose-selection="1"')
|
||||||
) {
|
) {
|
||||||
addFinding(findings, 'RUNTIME_IDENTITY_OR_LABEL_DRIFT');
|
addFinding(findings, 'RUNTIME_IDENTITY_OR_LABEL_DRIFT');
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ function createFixture(t, options = {}) {
|
|||||||
...(isLocal
|
...(isLocal
|
||||||
? {
|
? {
|
||||||
'io.qinglong.ai': 'excluded',
|
'io.qinglong.ai': 'excluded',
|
||||||
'io.qinglong.local.application-config': '2',
|
'io.qinglong.local.application-config': '2,3,4',
|
||||||
'io.qinglong.local.compose-selection': '1',
|
'io.qinglong.local.compose-selection': '1',
|
||||||
'io.qinglong.local.sqlite-contract-max': '41',
|
'io.qinglong.local.sqlite-contract-max': '41',
|
||||||
'io.qinglong.local.sqlite-contract-min': '41',
|
'io.qinglong.local.sqlite-contract-min': '41',
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ test('rejects removal of the SQLite rollout compatibility labels', () => {
|
|||||||
const dockerfilePath = path.join(current.target, 'Dockerfile');
|
const dockerfilePath = path.join(current.target, 'Dockerfile');
|
||||||
const dockerfile = fs
|
const dockerfile = fs
|
||||||
.readFileSync(dockerfilePath, 'utf8')
|
.readFileSync(dockerfilePath, 'utf8')
|
||||||
.replace(' io.qinglong.local.sqlite-write-contract="44" \\\n', '');
|
.replace(' io.qinglong.local.sqlite-write-contract="50" \\\n', '');
|
||||||
fs.writeFileSync(dockerfilePath, dockerfile);
|
fs.writeFileSync(dockerfilePath, dockerfile);
|
||||||
const report = auditLocalImageContract(current.root);
|
const report = auditLocalImageContract(current.root);
|
||||||
assert.equal(report.compatible, false);
|
assert.equal(report.compatible, false);
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 134,
|
sourceFiles: 135,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 133,
|
nestedSourceFiles: 134,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user