mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): apply legacy data transformation
This commit is contained in:
+272
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
applyReviewedLocalDataDirectoryAdoption,
|
||||
LocalDataDirectoryAdoptionApplicationConfigurationError,
|
||||
} from '@qinglong/local-admin/data-directory-adoption';
|
||||
import { LocalSecretKeyringFileProvider } from '@qinglong/local-secret';
|
||||
import { openLocalSqliteBootstrapDatabase } from '@qinglong/local-sqlite/bootstrap';
|
||||
import {
|
||||
establishAuthenticatedLocalCommand,
|
||||
type AuthenticatedLocalCommand,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
|
||||
import {
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION,
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION,
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION,
|
||||
LocalDataDirectoryAdoptionConfigurationError,
|
||||
type ApplyLocalDataDirectoryAdoptionCommand,
|
||||
type VerifyLocalDataDirectoryAdoptionApplicationCommand,
|
||||
type VerifyLocalDataDirectoryAdoptionCommand,
|
||||
} from '../contract';
|
||||
import { verifyLocalDataDirectoryAdoption } from '../staging';
|
||||
import { transformationAuthority } from '../transformation/files';
|
||||
import {
|
||||
loadStaticTransformation,
|
||||
verifyTransformationManifestBinding,
|
||||
} from '../transformation/manifest';
|
||||
import {
|
||||
reclaimCommittedTransformationModel,
|
||||
verifyCommittedTransformationModel,
|
||||
} from './cleanup';
|
||||
|
||||
export interface LocalDataDirectoryApplicationResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION;
|
||||
readonly status: 'committed' | 'verified';
|
||||
readonly evidence: Readonly<{
|
||||
profile: 'edge' | 'standalone';
|
||||
databaseStatus: 'inserted' | 'existing';
|
||||
sourceStageManifestDigest: string;
|
||||
transformationDigest: string;
|
||||
modelDigest: string;
|
||||
publicationDigest: string;
|
||||
receiptDigest: string;
|
||||
commitDigest: string;
|
||||
secretCount: number;
|
||||
environmentSecretCount: number;
|
||||
sshSecretCount: number;
|
||||
committedAtMs: number;
|
||||
modelReclaimed: true;
|
||||
plaintextFilesRemoved: true;
|
||||
physicalErasureGuaranteed: false;
|
||||
}>;
|
||||
}
|
||||
|
||||
type ApplicationCommand =
|
||||
| Readonly<ApplyLocalDataDirectoryAdoptionCommand>
|
||||
| Readonly<VerifyLocalDataDirectoryAdoptionApplicationCommand>;
|
||||
|
||||
function sourceVerificationCommand(
|
||||
command: ApplicationCommand,
|
||||
): Readonly<VerifyLocalDataDirectoryAdoptionCommand> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
dataRoot: command.options.dataRoot,
|
||||
stagingRoot: command.options.stagingRoot,
|
||||
profile: command.options.profile,
|
||||
sqlite: command.options.sqlite,
|
||||
expectedManifestDigest: command.options.expectedManifestDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPreparedModel(
|
||||
command: ApplicationCommand,
|
||||
authority: ReturnType<typeof transformationAuthority>,
|
||||
) {
|
||||
const before = await verifyLocalDataDirectoryAdoption(
|
||||
sourceVerificationCommand(command),
|
||||
);
|
||||
const loaded = loadStaticTransformation({
|
||||
authority,
|
||||
profile: command.options.profile,
|
||||
projectId: command.options.projectId,
|
||||
sourceStageManifestDigest: command.options.expectedManifestDigest,
|
||||
expectedTransformationDigest: command.options.expectedTransformationDigest,
|
||||
});
|
||||
if (
|
||||
loaded.manifest.assessment !== 'ready' ||
|
||||
loaded.manifest.model.manualCategories !== 0 ||
|
||||
(loaded.prepared.model.manualReview as { readonly required?: unknown })
|
||||
.required !== false
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionApplicationConfigurationError(
|
||||
'manual review must be resolved in a new transformation before apply',
|
||||
);
|
||||
}
|
||||
const after = await verifyLocalDataDirectoryAdoption(
|
||||
sourceVerificationCommand(command),
|
||||
);
|
||||
if (JSON.stringify(before.evidence) !== JSON.stringify(after.evidence)) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'staged source changed while preparing the application transaction',
|
||||
);
|
||||
}
|
||||
return loaded.prepared;
|
||||
}
|
||||
|
||||
function result(
|
||||
operation: LocalDataDirectoryApplicationResult['operation'],
|
||||
status: LocalDataDirectoryApplicationResult['status'],
|
||||
databaseStatus: 'inserted' | 'existing',
|
||||
adoption: Awaited<
|
||||
ReturnType<typeof applyReviewedLocalDataDirectoryAdoption>
|
||||
>['adoption'],
|
||||
commit: ReturnType<typeof reclaimCommittedTransformationModel>,
|
||||
): Readonly<LocalDataDirectoryApplicationResult> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
status,
|
||||
evidence: Object.freeze({
|
||||
profile: adoption.profile,
|
||||
databaseStatus,
|
||||
sourceStageManifestDigest: adoption.sourceStageManifestDigest,
|
||||
transformationDigest: adoption.transformationDigest,
|
||||
modelDigest: adoption.modelDigest,
|
||||
publicationDigest: adoption.publicationDigest,
|
||||
receiptDigest: adoption.receiptDigest,
|
||||
commitDigest: commit.commitDigest,
|
||||
secretCount: adoption.receipt.secretCount,
|
||||
environmentSecretCount: adoption.receipt.environmentSecretCount,
|
||||
sshSecretCount: adoption.receipt.sshSecretCount,
|
||||
committedAtMs: adoption.committedAtMs,
|
||||
modelReclaimed: true,
|
||||
plaintextFilesRemoved: true,
|
||||
physicalErasureGuaranteed: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticatedAuthority(command: ApplicationCommand): Promise<
|
||||
Readonly<{
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>;
|
||||
close(): Promise<void>;
|
||||
}>
|
||||
> {
|
||||
const database = await openLocalSqliteBootstrapDatabase({
|
||||
databasePath: command.options.sqlite.targetPath,
|
||||
profile: command.options.profile,
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
try {
|
||||
const authenticated = await establishAuthenticatedLocalCommand(database, {
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
databasePath: command.options.sqlite.targetPath,
|
||||
ownerPepperKeyringDirectory: command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: command.options.credentialFilePath,
|
||||
authenticationNamespace: 'local_data_adoption',
|
||||
});
|
||||
return Object.freeze({
|
||||
authenticated,
|
||||
close: () => database.close(),
|
||||
});
|
||||
} catch (error) {
|
||||
await database.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function apply(
|
||||
command: ApplicationCommand,
|
||||
verifyOnly: boolean,
|
||||
): Promise<Readonly<LocalDataDirectoryApplicationResult>> {
|
||||
const authority = transformationAuthority(command.options, false);
|
||||
const manifest = verifyTransformationManifestBinding({
|
||||
authority,
|
||||
profile: command.options.profile,
|
||||
projectId: command.options.projectId,
|
||||
sourceStageManifestDigest: command.options.expectedManifestDigest,
|
||||
expectedTransformationDigest: command.options.expectedTransformationDigest,
|
||||
});
|
||||
const authentication = await authenticatedAuthority(command);
|
||||
try {
|
||||
const applied = await applyReviewedLocalDataDirectoryAdoption({
|
||||
databasePath: command.options.sqlite.targetPath,
|
||||
profile: command.options.profile,
|
||||
projectId: command.options.projectId,
|
||||
mutationId: command.options.mutationId,
|
||||
failureAuditEventId: command.options.failureAuditEventId,
|
||||
requestId: command.options.requestId,
|
||||
sourceStageManifestDigest: command.options.expectedManifestDigest,
|
||||
transformationDigest: command.options.expectedTransformationDigest,
|
||||
modelDigest: manifest.model.digest,
|
||||
principal: authentication.authenticated.principal,
|
||||
keyProvider: new LocalSecretKeyringFileProvider(
|
||||
command.options.secretKeyringPath,
|
||||
),
|
||||
observedAtMs: Date.now(),
|
||||
...(command.options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
loadPreparedModel: verifyOnly
|
||||
? () => {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'application receipt does not exist',
|
||||
);
|
||||
}
|
||||
: () => loadPreparedModel(command, authority),
|
||||
confirmAuthenticationAuthority: () =>
|
||||
authentication.authenticated.confirm(),
|
||||
async confirmPreparedAuthority() {
|
||||
await loadPreparedModel(command, authority);
|
||||
},
|
||||
});
|
||||
if (
|
||||
applied.adoption.modelDigest !== manifest.model.digest ||
|
||||
applied.adoption.transformationDigest !== manifest.transformationDigest
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'database application does not match the transformation manifest',
|
||||
);
|
||||
}
|
||||
if (verifyOnly) {
|
||||
const verifyCommand =
|
||||
command as Readonly<VerifyLocalDataDirectoryAdoptionApplicationCommand>;
|
||||
const commit = verifyCommittedTransformationModel({
|
||||
authority,
|
||||
adoption: applied.adoption,
|
||||
expectedReceiptDigest: verifyCommand.options.expectedReceiptDigest,
|
||||
});
|
||||
return result(
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION,
|
||||
'verified',
|
||||
applied.status,
|
||||
applied.adoption,
|
||||
commit,
|
||||
);
|
||||
}
|
||||
const commit = reclaimCommittedTransformationModel({
|
||||
authority,
|
||||
adoption: applied.adoption,
|
||||
});
|
||||
return result(
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION,
|
||||
'committed',
|
||||
applied.status,
|
||||
applied.adoption,
|
||||
commit,
|
||||
);
|
||||
} finally {
|
||||
await authentication.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function applyLocalDataDirectoryAdoption(
|
||||
command: Readonly<ApplyLocalDataDirectoryAdoptionCommand>,
|
||||
): Promise<Readonly<LocalDataDirectoryApplicationResult>> {
|
||||
return apply(command, false);
|
||||
}
|
||||
|
||||
export function verifyLocalDataDirectoryAdoptionApplication(
|
||||
command: Readonly<VerifyLocalDataDirectoryAdoptionApplicationCommand>,
|
||||
): Promise<Readonly<LocalDataDirectoryApplicationResult>> {
|
||||
return apply(command, true);
|
||||
}
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
createLocalDataDirectorySourceNameDigest,
|
||||
type LocalDataDirectoryAdoptionRecord,
|
||||
} from '@qinglong/local-sqlite/data-directory-adoption';
|
||||
|
||||
import { LocalDataDirectoryAdoptionConfigurationError } from '../contract';
|
||||
import {
|
||||
assertPrivateDirectory,
|
||||
sameStat,
|
||||
sortedNames,
|
||||
syncDirectory,
|
||||
} from '../filesystem';
|
||||
import { sha256Text } from '../manifest';
|
||||
import {
|
||||
readStablePrivateUtf8File,
|
||||
writePrivateJson,
|
||||
type TransformationAuthority,
|
||||
} from '../transformation/files';
|
||||
import { verifyTransformationModel } from '../transformation/model';
|
||||
import { verifyTransformationManifestBinding } from '../transformation/manifest';
|
||||
|
||||
export const APPLICATION_COMMIT_NAME = 'commit.json';
|
||||
const COMMIT_INCOMPLETE_NAME = '.commit-incomplete';
|
||||
const RECLAIMING_MODEL_NAME = '.reclaiming-model';
|
||||
const MODEL_NAME = 'model';
|
||||
const MAX_JSON_BYTES = 1024 * 1024;
|
||||
const ZERO_CHUNK = Buffer.alloc(64 * 1024);
|
||||
|
||||
interface LocalDataDirectoryApplicationCommitPayload {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: 'qinglong3-legacy-data-directory-application';
|
||||
readonly state: 'committed';
|
||||
readonly mutationId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly projectIdDigest: string;
|
||||
readonly sourceStageManifestDigest: string;
|
||||
readonly transformationDigest: string;
|
||||
readonly modelDigest: string;
|
||||
readonly publicationDigest: string;
|
||||
readonly receiptDigest: string;
|
||||
readonly secretCount: number;
|
||||
readonly environmentSecretCount: number;
|
||||
readonly sshSecretCount: number;
|
||||
readonly committedAtMs: number;
|
||||
readonly reclamation: Readonly<{
|
||||
modelRemoved: true;
|
||||
plaintextFilesRemoved: true;
|
||||
physicalErasureGuaranteed: false;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalDataDirectoryApplicationCommit
|
||||
extends LocalDataDirectoryApplicationCommitPayload {
|
||||
readonly commitDigest: string;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function expectedCommit(
|
||||
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
|
||||
): Readonly<LocalDataDirectoryApplicationCommit> {
|
||||
const payload: LocalDataDirectoryApplicationCommitPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-data-directory-application',
|
||||
state: 'committed',
|
||||
mutationId: adoption.mutationId,
|
||||
profile: adoption.profile,
|
||||
projectIdDigest: sha256Text(adoption.projectId),
|
||||
sourceStageManifestDigest: adoption.sourceStageManifestDigest,
|
||||
transformationDigest: adoption.transformationDigest,
|
||||
modelDigest: adoption.modelDigest,
|
||||
publicationDigest: adoption.publicationDigest,
|
||||
receiptDigest: adoption.receiptDigest,
|
||||
secretCount: adoption.receipt.secretCount,
|
||||
environmentSecretCount: adoption.receipt.environmentSecretCount,
|
||||
sshSecretCount: adoption.receipt.sshSecretCount,
|
||||
committedAtMs: adoption.committedAtMs,
|
||||
reclamation: Object.freeze({
|
||||
modelRemoved: true,
|
||||
plaintextFilesRemoved: true,
|
||||
physicalErasureGuaranteed: false,
|
||||
}),
|
||||
};
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
commitDigest: sha256Text(JSON.stringify(payload)),
|
||||
});
|
||||
}
|
||||
|
||||
function readJson(filePath: string, uid: number, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readStablePrivateUtf8File(filePath, uid, MAX_JSON_BYTES, label),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDataDirectoryAdoptionConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
`${label} JSON is invalid`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sameJson(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function verifyMarker(
|
||||
root: string,
|
||||
uid: number,
|
||||
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
|
||||
): void {
|
||||
const marker = readJson(
|
||||
path.join(root, COMMIT_INCOMPLETE_NAME),
|
||||
uid,
|
||||
'application recovery marker',
|
||||
);
|
||||
if (
|
||||
!marker ||
|
||||
typeof marker !== 'object' ||
|
||||
Array.isArray(marker) ||
|
||||
!exactKeys(marker, [
|
||||
'kind',
|
||||
'mutationId',
|
||||
'receiptDigest',
|
||||
'schemaVersion',
|
||||
'transformationDigest',
|
||||
])
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'application recovery marker shape is invalid',
|
||||
);
|
||||
}
|
||||
const candidate = marker as Record<string, unknown>;
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
candidate.kind !==
|
||||
'qinglong3-legacy-data-directory-application-incomplete' ||
|
||||
candidate.mutationId !== adoption.mutationId ||
|
||||
candidate.transformationDigest !== adoption.transformationDigest ||
|
||||
candidate.receiptDigest !== adoption.receiptDigest
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'application recovery marker does not match the committed database',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyCommit(
|
||||
authority: Readonly<TransformationAuthority>,
|
||||
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
|
||||
): Readonly<LocalDataDirectoryApplicationCommit> {
|
||||
const actual = readJson(
|
||||
path.join(authority.transformationRoot, APPLICATION_COMMIT_NAME),
|
||||
authority.uid,
|
||||
'application commit',
|
||||
);
|
||||
const expected = expectedCommit(adoption);
|
||||
if (!sameJson(actual, expected)) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'application commit does not match the durable database receipt',
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function assertPreparedMatches(
|
||||
modelRoot: string,
|
||||
authority: Readonly<TransformationAuthority>,
|
||||
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
|
||||
): void {
|
||||
const manifest = verifyTransformationManifestBinding({
|
||||
authority,
|
||||
profile: adoption.profile,
|
||||
projectId: adoption.projectId,
|
||||
sourceStageManifestDigest: adoption.sourceStageManifestDigest,
|
||||
expectedTransformationDigest: adoption.transformationDigest,
|
||||
});
|
||||
const prepared = verifyTransformationModel({
|
||||
modelRoot,
|
||||
uid: authority.uid,
|
||||
projectId: adoption.projectId,
|
||||
profile: adoption.profile,
|
||||
expected: manifest.model,
|
||||
});
|
||||
if (
|
||||
manifest.assessment !== 'ready' ||
|
||||
manifest.model.manualCategories !== 0 ||
|
||||
!sameJson(prepared.model, adoption.model) ||
|
||||
prepared.secrets.length !== adoption.secrets.length ||
|
||||
prepared.secrets.some((secret, index) => {
|
||||
const stored = adoption.secrets[index];
|
||||
return (
|
||||
!stored ||
|
||||
stored.ordinal !== index + 1 ||
|
||||
stored.kind !== secret.kind ||
|
||||
stored.sourceNameDigest !==
|
||||
createLocalDataDirectorySourceNameDigest(
|
||||
secret.kind,
|
||||
secret.sourceName,
|
||||
) ||
|
||||
stored.secretName !== secret.targetName ||
|
||||
stored.valueFile !== secret.valueFile ||
|
||||
stored.valueDigest !== secret.valueDigest
|
||||
);
|
||||
})
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'prepared model does not match the committed database receipt',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function privateEntry(
|
||||
entryPath: string,
|
||||
uid: number,
|
||||
kind: 'file' | 'directory',
|
||||
): fs.BigIntStats {
|
||||
const stat = fs.lstatSync(entryPath, { bigint: true });
|
||||
if (
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== BigInt(uid) ||
|
||||
(stat.mode & 0o777n) !== (kind === 'file' ? 0o600n : 0o700n) ||
|
||||
(kind === 'file'
|
||||
? !stat.isFile() || stat.nlink !== 1n
|
||||
: !stat.isDirectory())
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'reclaiming model identity or mode is invalid',
|
||||
);
|
||||
}
|
||||
return stat;
|
||||
}
|
||||
|
||||
function overwriteAndUnlink(filePath: string, uid: number): void {
|
||||
const expected = privateEntry(filePath, uid, 'file');
|
||||
if (expected.size < 0n || expected.size > BigInt(32 * 1024)) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'reclaiming Secret file size is invalid',
|
||||
);
|
||||
}
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (!sameStat(expected, opened)) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'reclaiming Secret identity changed before overwrite',
|
||||
);
|
||||
}
|
||||
let remaining = Number(opened.size);
|
||||
let offset = 0;
|
||||
while (remaining > 0) {
|
||||
const count = Math.min(remaining, ZERO_CHUNK.length);
|
||||
const written = fs.writeSync(descriptor, ZERO_CHUNK, 0, count, offset);
|
||||
if (written < 1) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'reclaiming Secret overwrite made no progress',
|
||||
);
|
||||
}
|
||||
remaining -= written;
|
||||
offset += written;
|
||||
}
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
function reclaimRenamedModel(
|
||||
root: string,
|
||||
uid: number,
|
||||
adoption: Readonly<LocalDataDirectoryAdoptionRecord>,
|
||||
): void {
|
||||
const modelRoot = path.join(root, RECLAIMING_MODEL_NAME);
|
||||
privateEntry(modelRoot, uid, 'directory');
|
||||
const allowedRootFiles = new Set([
|
||||
'config.json',
|
||||
'keyv.json',
|
||||
'manual-review.json',
|
||||
'secret-imports.json',
|
||||
'ssh.json',
|
||||
'secret-values',
|
||||
]);
|
||||
if (sortedNames(modelRoot).some((name) => !allowedRootFiles.has(name))) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'reclaiming model contains an unexpected entry',
|
||||
);
|
||||
}
|
||||
const secretRoot = path.join(modelRoot, 'secret-values');
|
||||
if (fs.existsSync(secretRoot)) {
|
||||
privateEntry(secretRoot, uid, 'directory');
|
||||
const expectedFiles = new Set(
|
||||
adoption.secrets.map(({ valueFile }) => path.basename(valueFile)),
|
||||
);
|
||||
const actualFiles = sortedNames(secretRoot);
|
||||
if (actualFiles.some((name) => !expectedFiles.has(name))) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'reclaiming Secret directory contains an unexpected entry',
|
||||
);
|
||||
}
|
||||
for (const name of actualFiles) {
|
||||
overwriteAndUnlink(path.join(secretRoot, name), uid);
|
||||
}
|
||||
fs.rmdirSync(secretRoot);
|
||||
syncDirectory(modelRoot);
|
||||
}
|
||||
for (const name of [
|
||||
'config.json',
|
||||
'keyv.json',
|
||||
'ssh.json',
|
||||
'manual-review.json',
|
||||
'secret-imports.json',
|
||||
]) {
|
||||
const filePath = path.join(modelRoot, name);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
privateEntry(filePath, uid, 'file');
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
syncDirectory(modelRoot);
|
||||
if (sortedNames(modelRoot).length !== 0) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'reclaiming model could not be emptied',
|
||||
);
|
||||
}
|
||||
fs.rmdirSync(modelRoot);
|
||||
syncDirectory(root);
|
||||
}
|
||||
|
||||
export function reclaimCommittedTransformationModel(options: {
|
||||
readonly authority: Readonly<TransformationAuthority>;
|
||||
readonly adoption: Readonly<LocalDataDirectoryAdoptionRecord>;
|
||||
}): Readonly<LocalDataDirectoryApplicationCommit> {
|
||||
const { authority, adoption } = options;
|
||||
const root = authority.transformationRoot;
|
||||
const before = assertPrivateDirectory(
|
||||
root,
|
||||
authority.uid,
|
||||
'transformationRoot',
|
||||
);
|
||||
verifyTransformationManifestBinding({
|
||||
authority,
|
||||
profile: adoption.profile,
|
||||
projectId: adoption.projectId,
|
||||
sourceStageManifestDigest: adoption.sourceStageManifestDigest,
|
||||
expectedTransformationDigest: adoption.transformationDigest,
|
||||
});
|
||||
const initialNames = sortedNames(root);
|
||||
const allowedNames = new Set([
|
||||
'manifest.json',
|
||||
APPLICATION_COMMIT_NAME,
|
||||
COMMIT_INCOMPLETE_NAME,
|
||||
MODEL_NAME,
|
||||
RECLAIMING_MODEL_NAME,
|
||||
]);
|
||||
if (
|
||||
initialNames.some((name) => !allowedNames.has(name)) ||
|
||||
!initialNames.includes('manifest.json') ||
|
||||
(initialNames.includes(MODEL_NAME) &&
|
||||
initialNames.includes(RECLAIMING_MODEL_NAME))
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'application recovery root shape is invalid',
|
||||
);
|
||||
}
|
||||
if (!initialNames.includes(COMMIT_INCOMPLETE_NAME)) {
|
||||
if (
|
||||
initialNames.includes(APPLICATION_COMMIT_NAME) &&
|
||||
!initialNames.includes(MODEL_NAME) &&
|
||||
!initialNames.includes(RECLAIMING_MODEL_NAME)
|
||||
) {
|
||||
return verifyCommit(authority, adoption);
|
||||
}
|
||||
writePrivateJson(path.join(root, COMMIT_INCOMPLETE_NAME), {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-data-directory-application-incomplete',
|
||||
mutationId: adoption.mutationId,
|
||||
transformationDigest: adoption.transformationDigest,
|
||||
receiptDigest: adoption.receiptDigest,
|
||||
});
|
||||
syncDirectory(root);
|
||||
}
|
||||
verifyMarker(root, authority.uid, adoption);
|
||||
|
||||
const modelRoot = path.join(root, MODEL_NAME);
|
||||
const reclaimingRoot = path.join(root, RECLAIMING_MODEL_NAME);
|
||||
if (fs.existsSync(modelRoot)) {
|
||||
assertPreparedMatches(modelRoot, authority, adoption);
|
||||
if (fs.existsSync(reclaimingRoot)) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'multiple application model recovery roots exist',
|
||||
);
|
||||
}
|
||||
fs.renameSync(modelRoot, reclaimingRoot);
|
||||
syncDirectory(root);
|
||||
}
|
||||
if (fs.existsSync(reclaimingRoot)) {
|
||||
reclaimRenamedModel(root, authority.uid, adoption);
|
||||
}
|
||||
|
||||
const commitPath = path.join(root, APPLICATION_COMMIT_NAME);
|
||||
if (!fs.existsSync(commitPath)) {
|
||||
writePrivateJson(commitPath, expectedCommit(adoption));
|
||||
syncDirectory(root);
|
||||
}
|
||||
const commit = verifyCommit(authority, adoption);
|
||||
fs.unlinkSync(path.join(root, COMMIT_INCOMPLETE_NAME));
|
||||
syncDirectory(root);
|
||||
if (
|
||||
JSON.stringify(sortedNames(root)) !==
|
||||
JSON.stringify([APPLICATION_COMMIT_NAME, 'manifest.json'].sort())
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'committed transformation root contains unexpected entries',
|
||||
);
|
||||
}
|
||||
const after = fs.lstatSync(root, { bigint: true });
|
||||
if (
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
before.uid !== after.uid ||
|
||||
(after.mode & 0o777n) !== 0o700n
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'transformation root identity changed during application cleanup',
|
||||
);
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
export function verifyCommittedTransformationModel(options: {
|
||||
readonly authority: Readonly<TransformationAuthority>;
|
||||
readonly adoption: Readonly<LocalDataDirectoryAdoptionRecord>;
|
||||
readonly expectedReceiptDigest: string;
|
||||
}): Readonly<LocalDataDirectoryApplicationCommit> {
|
||||
if (
|
||||
options.adoption.receiptDigest !== options.expectedReceiptDigest ||
|
||||
JSON.stringify(sortedNames(options.authority.transformationRoot)) !==
|
||||
JSON.stringify([APPLICATION_COMMIT_NAME, 'manifest.json'].sort())
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'committed transformation evidence is incomplete',
|
||||
);
|
||||
}
|
||||
verifyTransformationManifestBinding({
|
||||
authority: options.authority,
|
||||
profile: options.adoption.profile,
|
||||
projectId: options.adoption.projectId,
|
||||
sourceStageManifestDigest: options.adoption.sourceStageManifestDigest,
|
||||
expectedTransformationDigest: options.adoption.transformationDigest,
|
||||
});
|
||||
return verifyCommit(options.authority, options.adoption);
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import {
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION,
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION,
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION,
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_STAGE_OPERATION,
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION,
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION,
|
||||
normalizeLocalDataDirectoryAdoptionCommand,
|
||||
} from './contract';
|
||||
import {
|
||||
applyLocalDataDirectoryAdoption,
|
||||
verifyLocalDataDirectoryAdoptionApplication,
|
||||
type LocalDataDirectoryApplicationResult,
|
||||
} from './application/application';
|
||||
import {
|
||||
inspectLocalDataDirectoryAdoption,
|
||||
type LocalDataDirectoryAdoptionInspectResult,
|
||||
@@ -23,7 +30,8 @@ import {
|
||||
export type LocalDataDirectoryAdoptionProductCommandResult =
|
||||
| LocalDataDirectoryAdoptionInspectResult
|
||||
| LocalDataDirectoryAdoptionMutationResult
|
||||
| LocalDataDirectoryTransformationResult;
|
||||
| LocalDataDirectoryTransformationResult
|
||||
| LocalDataDirectoryApplicationResult;
|
||||
|
||||
export async function runLocalDataDirectoryAdoptionProductCommand(
|
||||
value: unknown,
|
||||
@@ -41,5 +49,13 @@ export async function runLocalDataDirectoryAdoptionProductCommand(
|
||||
if (command.operation === LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION) {
|
||||
return transformLocalDataDirectoryAdoption(command);
|
||||
}
|
||||
if (command.operation === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION) {
|
||||
return applyLocalDataDirectoryAdoption(command);
|
||||
}
|
||||
if (
|
||||
command.operation === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION
|
||||
) {
|
||||
return verifyLocalDataDirectoryAdoptionApplication(command);
|
||||
}
|
||||
return verifyLocalDataDirectoryAdoptionTransformation(command);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import path from 'node:path';
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export const LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION =
|
||||
'local-data-directory.adoption.inspect' as const;
|
||||
@@ -14,13 +17,19 @@ export const LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION =
|
||||
'local-data-directory.adoption.transform' as const;
|
||||
export const LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION =
|
||||
'local-data-directory.adoption.transform.verify' as const;
|
||||
export const LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION =
|
||||
'local-data-directory.adoption.apply' as const;
|
||||
export const LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION =
|
||||
'local-data-directory.adoption.apply.verify' as const;
|
||||
|
||||
export type LocalDataDirectoryAdoptionOperation =
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_STAGE_OPERATION
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION;
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION
|
||||
| typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION;
|
||||
|
||||
export interface InspectLocalDataDirectoryAdoptionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
@@ -85,12 +94,40 @@ export interface VerifyLocalDataDirectoryAdoptionTransformationCommand {
|
||||
};
|
||||
}
|
||||
|
||||
interface LocalDataDirectoryAdoptionApplicationOptions
|
||||
extends LocalDataDirectoryAdoptionTransformationOptions {
|
||||
readonly expectedTransformationDigest: string;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly secretKeyringPath: string;
|
||||
readonly mutationId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
readonly requestId: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ApplyLocalDataDirectoryAdoptionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION;
|
||||
readonly options: LocalDataDirectoryAdoptionApplicationOptions;
|
||||
}
|
||||
|
||||
export interface VerifyLocalDataDirectoryAdoptionApplicationCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: typeof LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION;
|
||||
readonly options: LocalDataDirectoryAdoptionApplicationOptions & {
|
||||
readonly expectedReceiptDigest: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalDataDirectoryAdoptionCommand =
|
||||
| InspectLocalDataDirectoryAdoptionCommand
|
||||
| StageLocalDataDirectoryAdoptionCommand
|
||||
| VerifyLocalDataDirectoryAdoptionCommand
|
||||
| TransformLocalDataDirectoryAdoptionCommand
|
||||
| VerifyLocalDataDirectoryAdoptionTransformationCommand;
|
||||
| VerifyLocalDataDirectoryAdoptionTransformationCommand
|
||||
| ApplyLocalDataDirectoryAdoptionCommand
|
||||
| VerifyLocalDataDirectoryAdoptionApplicationCommand;
|
||||
|
||||
export class LocalDataDirectoryAdoptionConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_DATA_DIRECTORY_ADOPTION_CONFIGURATION_INVALID';
|
||||
@@ -129,7 +166,19 @@ export function isLocalDataDirectoryAdoptionOperation(
|
||||
value === LOCAL_DATA_DIRECTORY_ADOPTION_STAGE_OPERATION ||
|
||||
value === LOCAL_DATA_DIRECTORY_ADOPTION_VERIFY_OPERATION ||
|
||||
value === LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION ||
|
||||
value === LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION
|
||||
value === LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION ||
|
||||
value === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION ||
|
||||
value === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION
|
||||
);
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return (
|
||||
relative.length > 0 &&
|
||||
relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,6 +268,10 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
|
||||
);
|
||||
}
|
||||
const options = candidate.options as Record<string, unknown>;
|
||||
const application =
|
||||
candidate.operation === LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_OPERATION ||
|
||||
candidate.operation ===
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION;
|
||||
let expectedKeys: readonly string[];
|
||||
if (candidate.operation === LOCAL_DATA_DIRECTORY_ADOPTION_INSPECT_OPERATION) {
|
||||
expectedKeys = ['dataRoot', 'profile'];
|
||||
@@ -245,9 +298,24 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
|
||||
? []
|
||||
: ['projectId', 'transformationRoot']),
|
||||
...(candidate.operation ===
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION || application
|
||||
? ['expectedTransformationDigest']
|
||||
: []),
|
||||
...(application
|
||||
? [
|
||||
'credentialFilePath',
|
||||
'failureAuditEventId',
|
||||
'mutationId',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'requestId',
|
||||
'secretKeyringPath',
|
||||
...(options.busyTimeoutMs === undefined ? [] : ['busyTimeoutMs']),
|
||||
]
|
||||
: []),
|
||||
...(candidate.operation ===
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION
|
||||
? ['expectedReceiptDigest']
|
||||
: []),
|
||||
];
|
||||
}
|
||||
if (
|
||||
@@ -282,7 +350,8 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
|
||||
candidate.operation ===
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_OPERATION ||
|
||||
candidate.operation ===
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION ||
|
||||
application
|
||||
) {
|
||||
if (
|
||||
!normalizedAbsolutePath(options.transformationRoot) ||
|
||||
@@ -294,8 +363,9 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
|
||||
);
|
||||
}
|
||||
if (
|
||||
candidate.operation ===
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION &&
|
||||
(candidate.operation ===
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_TRANSFORM_VERIFY_OPERATION ||
|
||||
application) &&
|
||||
(typeof options.expectedTransformationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(options.expectedTransformationDigest))
|
||||
) {
|
||||
@@ -303,6 +373,47 @@ export function normalizeLocalDataDirectoryAdoptionCommand(
|
||||
'transformation digest is invalid',
|
||||
);
|
||||
}
|
||||
if (application) {
|
||||
for (const key of [
|
||||
'ownerPepperKeyringDirectory',
|
||||
'credentialFilePath',
|
||||
'secretKeyringPath',
|
||||
]) {
|
||||
if (
|
||||
!normalizedAbsolutePath(options[key]) ||
|
||||
!descendant(
|
||||
options.deploymentRoot as string,
|
||||
options[key] as string,
|
||||
)
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'application authority path is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.credentialFilePath === options.secretKeyringPath ||
|
||||
typeof options.mutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(options.mutationId) ||
|
||||
typeof options.failureAuditEventId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(options.failureAuditEventId) ||
|
||||
options.failureAuditEventId === options.mutationId ||
|
||||
typeof options.requestId !== 'string' ||
|
||||
!REQUEST_ID_PATTERN.test(options.requestId) ||
|
||||
(options.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.busyTimeoutMs) ||
|
||||
(options.busyTimeoutMs as number) < 100 ||
|
||||
(options.busyTimeoutMs as number) > 30_000)) ||
|
||||
(candidate.operation ===
|
||||
LOCAL_DATA_DIRECTORY_ADOPTION_APPLY_VERIFY_OPERATION &&
|
||||
(typeof options.expectedReceiptDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(options.expectedReceiptDigest)))
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'application authority binding is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze(value as LocalDataDirectoryAdoptionCommand);
|
||||
|
||||
+51
-2
@@ -13,6 +13,7 @@ import {
|
||||
type LocalDataDirectoryTransformationManifest,
|
||||
type TransformationModelEvidence,
|
||||
type TransformationSourceEvidence,
|
||||
type VerifiedTransformationModel,
|
||||
} from './model';
|
||||
|
||||
export const TRANSFORMATION_MANIFEST_NAME = 'manifest.json';
|
||||
@@ -210,6 +211,54 @@ export function verifyStaticTransformation(options: {
|
||||
readonly sourceStageManifestDigest: string;
|
||||
readonly expectedTransformationDigest: string;
|
||||
}): Readonly<LocalDataDirectoryTransformationManifest> {
|
||||
return loadStaticTransformation(options).manifest;
|
||||
}
|
||||
|
||||
export function verifyTransformationManifestBinding(options: {
|
||||
readonly authority: Readonly<TransformationAuthority>;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly projectId: string;
|
||||
readonly sourceStageManifestDigest: string;
|
||||
readonly expectedTransformationDigest: string;
|
||||
}): Readonly<LocalDataDirectoryTransformationManifest> {
|
||||
const before = assertPrivateDirectory(
|
||||
options.authority.transformationRoot,
|
||||
options.authority.uid,
|
||||
'transformationRoot',
|
||||
);
|
||||
const manifest = readManifest(
|
||||
options.authority.transformationRoot,
|
||||
options.authority.uid,
|
||||
);
|
||||
if (
|
||||
manifest.transformationDigest !== options.expectedTransformationDigest ||
|
||||
manifest.profile !== options.profile ||
|
||||
manifest.projectIdDigest !== sha256Text(options.projectId) ||
|
||||
manifest.sourceStageManifestDigest !== options.sourceStageManifestDigest ||
|
||||
manifest.transformationRootPathDigest !==
|
||||
sha256Text(options.authority.transformationRoot) ||
|
||||
!sameStat(
|
||||
before,
|
||||
fs.lstatSync(options.authority.transformationRoot, { bigint: true }),
|
||||
)
|
||||
) {
|
||||
throw new LocalDataDirectoryAdoptionConfigurationError(
|
||||
'transformation manifest authority binding is invalid',
|
||||
);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function loadStaticTransformation(options: {
|
||||
readonly authority: Readonly<TransformationAuthority>;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly projectId: string;
|
||||
readonly sourceStageManifestDigest: string;
|
||||
readonly expectedTransformationDigest: string;
|
||||
}): Readonly<{
|
||||
manifest: Readonly<LocalDataDirectoryTransformationManifest>;
|
||||
prepared: Readonly<VerifiedTransformationModel>;
|
||||
}> {
|
||||
const before = assertPrivateDirectory(
|
||||
options.authority.transformationRoot,
|
||||
options.authority.uid,
|
||||
@@ -239,7 +288,7 @@ export function verifyStaticTransformation(options: {
|
||||
'transformation manifest authority binding is invalid',
|
||||
);
|
||||
}
|
||||
verifyTransformationModel({
|
||||
const prepared = verifyTransformationModel({
|
||||
modelRoot: path.join(options.authority.transformationRoot, 'model'),
|
||||
uid: options.authority.uid,
|
||||
projectId: options.projectId,
|
||||
@@ -256,5 +305,5 @@ export function verifyStaticTransformation(options: {
|
||||
'transformation root changed during verification',
|
||||
);
|
||||
}
|
||||
return manifest;
|
||||
return Object.freeze({ manifest, prepared });
|
||||
}
|
||||
|
||||
+49
-4
@@ -58,6 +58,22 @@ interface SecretImportEntry {
|
||||
readonly valueDigest: string;
|
||||
}
|
||||
|
||||
export interface VerifiedTransformationSecret extends SecretImportEntry {
|
||||
readonly plaintext: string;
|
||||
}
|
||||
|
||||
export interface VerifiedTransformationModel {
|
||||
readonly model: Readonly<{
|
||||
schema: 'qinglong/legacy-data-directory-applied-model@v1';
|
||||
activation: 'disabled';
|
||||
config: Readonly<Record<string, unknown>>;
|
||||
keyv: Readonly<Record<string, unknown>>;
|
||||
ssh: Readonly<Record<string, unknown>>;
|
||||
manualReview: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
readonly secrets: readonly Readonly<VerifiedTransformationSecret>[];
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
@@ -252,7 +268,7 @@ export function verifyTransformationModel(options: {
|
||||
readonly projectId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly expected: Readonly<TransformationModelEvidence>;
|
||||
}): void {
|
||||
}): Readonly<VerifiedTransformationModel> {
|
||||
if (
|
||||
JSON.stringify(sortedNames(options.modelRoot)) !==
|
||||
JSON.stringify(
|
||||
@@ -270,17 +286,17 @@ export function verifyTransformationModel(options: {
|
||||
'transformation model root contains unexpected entries',
|
||||
);
|
||||
}
|
||||
assertSchemaFile(
|
||||
const config = assertSchemaFile(
|
||||
path.join(options.modelRoot, 'config.json'),
|
||||
options.uid,
|
||||
'qinglong/legacy-config-transformation@v1',
|
||||
);
|
||||
assertSchemaFile(
|
||||
const keyv = assertSchemaFile(
|
||||
path.join(options.modelRoot, 'keyv.json'),
|
||||
options.uid,
|
||||
'qinglong/legacy-keyv-transformation@v1',
|
||||
);
|
||||
assertSchemaFile(
|
||||
const ssh = assertSchemaFile(
|
||||
path.join(options.modelRoot, 'ssh.json'),
|
||||
options.uid,
|
||||
'qinglong/legacy-ssh-transformation@v1',
|
||||
@@ -329,6 +345,7 @@ export function verifyTransformationModel(options: {
|
||||
}
|
||||
const expectedFiles: string[] = [];
|
||||
const targets = new Set<string>();
|
||||
const secrets: VerifiedTransformationSecret[] = [];
|
||||
let environmentSecrets = 0;
|
||||
let sshSecrets = 0;
|
||||
for (const value of candidate.imports) {
|
||||
@@ -353,7 +370,13 @@ export function verifyTransformationModel(options: {
|
||||
if (
|
||||
(entry.kind !== 'environment' && entry.kind !== 'ssh_private_key') ||
|
||||
typeof entry.sourceName !== 'string' ||
|
||||
entry.sourceName.length < 1 ||
|
||||
Buffer.byteLength(entry.sourceName, 'utf8') > 255 ||
|
||||
/[\u0000-\u001f\u007f]/.test(entry.sourceName) ||
|
||||
typeof entry.targetName !== 'string' ||
|
||||
entry.targetName.length < 1 ||
|
||||
Buffer.byteLength(entry.targetName, 'utf8') > 128 ||
|
||||
/[\u0000-\u001f\u007f]/.test(entry.targetName) ||
|
||||
entry.expectedCurrentVersion !== 0 ||
|
||||
typeof entry.valueFile !== 'string' ||
|
||||
!SECRET_FILE_PATTERN.test(entry.valueFile) ||
|
||||
@@ -396,6 +419,17 @@ export function verifyTransformationModel(options: {
|
||||
}
|
||||
if (entry.kind === 'environment') environmentSecrets += 1;
|
||||
else sshSecrets += 1;
|
||||
secrets.push(
|
||||
Object.freeze({
|
||||
kind: entry.kind,
|
||||
sourceName: entry.sourceName,
|
||||
targetName: entry.targetName,
|
||||
expectedCurrentVersion: 0,
|
||||
valueFile: entry.valueFile,
|
||||
valueDigest: entry.valueDigest,
|
||||
plaintext: secretValue.value,
|
||||
}) as Readonly<VerifiedTransformationSecret>,
|
||||
);
|
||||
}
|
||||
expectedFiles.sort();
|
||||
if (
|
||||
@@ -427,4 +461,15 @@ export function verifyTransformationModel(options: {
|
||||
'transformation model no longer matches the manifest',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
model: Object.freeze({
|
||||
schema: 'qinglong/legacy-data-directory-applied-model@v1' as const,
|
||||
activation: 'disabled' as const,
|
||||
config: Object.freeze(config),
|
||||
keyv: Object.freeze(keyv),
|
||||
ssh: Object.freeze(ssh),
|
||||
manualReview: Object.freeze(manual),
|
||||
}),
|
||||
secrets: Object.freeze(secrets),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,19 @@ const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console');
|
||||
const {
|
||||
LocalSecretKeyringFileProvider,
|
||||
decryptLocalSecretEnvelopeToBuffer,
|
||||
provisionLocalSecretKeyring,
|
||||
} = require('@qinglong/local-secret');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
|
||||
const BINARY = path.join(__dirname, '../dist/lifecycle/adoptionCli.js');
|
||||
const DIRECTORY_INSPECT = 'local-data-directory.adoption.inspect';
|
||||
const DIRECTORY_STAGE = 'local-data-directory.adoption.stage';
|
||||
@@ -13,6 +26,18 @@ const DIRECTORY_VERIFY = 'local-data-directory.adoption.verify';
|
||||
const DIRECTORY_TRANSFORM = 'local-data-directory.adoption.transform';
|
||||
const DIRECTORY_TRANSFORM_VERIFY =
|
||||
'local-data-directory.adoption.transform.verify';
|
||||
const DIRECTORY_APPLY = 'local-data-directory.adoption.apply';
|
||||
const DIRECTORY_APPLY_VERIFY = 'local-data-directory.adoption.apply.verify';
|
||||
const APPLICATION_CREDENTIAL_ID = 'data-adoption-owner';
|
||||
const APPLICATION_PEPPER_KEY_ID = 'data-adoption-owner-v1';
|
||||
const APPLICATION_PEPPER = Buffer.alloc(32, 121).toString('base64url');
|
||||
const APPLICATION_CREDENTIAL_SECRET = Buffer.alloc(32, 122).toString(
|
||||
'base64url',
|
||||
);
|
||||
const APPLICATION_TOKEN = formatApiCredentialToken(
|
||||
APPLICATION_CREDENTIAL_ID,
|
||||
APPLICATION_CREDENTIAL_SECRET,
|
||||
);
|
||||
|
||||
function privateDirectory(directoryPath) {
|
||||
fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
|
||||
@@ -25,6 +50,15 @@ function privateFile(filePath, content) {
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
}
|
||||
|
||||
function privatizeTree(root) {
|
||||
fs.chmodSync(root, 0o700);
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) privatizeTree(entryPath);
|
||||
else fs.chmodSync(entryPath, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
function createLegacyDatabase(sourcePath) {
|
||||
const source = new DatabaseSync(sourcePath);
|
||||
source.exec(`
|
||||
@@ -371,6 +405,184 @@ function transformationOptions(value, prepared, staged, projectId) {
|
||||
};
|
||||
}
|
||||
|
||||
async function provisionApplicationAuthority(value, role = 'owner') {
|
||||
const ownerPepperKeyringDirectory = path.join(
|
||||
value.deploymentRoot,
|
||||
'owner-keys',
|
||||
);
|
||||
const credentialFilePath = path.join(
|
||||
value.deploymentRoot,
|
||||
'owner-credential.json',
|
||||
);
|
||||
const secretKeyringPath = path.join(
|
||||
value.deploymentRoot,
|
||||
'local-secret-keyring.json',
|
||||
);
|
||||
privateDirectory(ownerPepperKeyringDirectory);
|
||||
await provisionLocalSecretKeyring(secretKeyringPath);
|
||||
const pepper = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: ownerPepperKeyringDirectory,
|
||||
pepperKeyId: APPLICATION_PEPPER_KEY_ID,
|
||||
randomBytes: () => Buffer.alloc(32, 121),
|
||||
});
|
||||
const now = Date.now();
|
||||
const database = new DatabaseSync(value.targetPath);
|
||||
try {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
||||
"pepper_key_id", "material_digest", "backup_digest", "state",
|
||||
"version", "register_mutation_id", "activate_mutation_id",
|
||||
"registered_at_ms", "activated_at_ms"
|
||||
) VALUES (?, ?, ?, 'active', 2, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
APPLICATION_PEPPER_KEY_ID,
|
||||
pepper.digest,
|
||||
'e'.repeat(64),
|
||||
'd3870000-0000-4000-8000-000000000001',
|
||||
'd3870000-0000-4000-8000-000000000002',
|
||||
now - 2_000,
|
||||
now - 1_500,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
||||
"generation", "mutation_id", "expected_generation",
|
||||
"previous_pepper_key_id", "active_pepper_key_id",
|
||||
"material_digest", "backup_digest", "activated_at_ms"
|
||||
) VALUES (1, ?, 0, NULL, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
'd3870000-0000-4000-8000-000000000002',
|
||||
APPLICATION_PEPPER_KEY_ID,
|
||||
pepper.digest,
|
||||
'e'.repeat(64),
|
||||
now - 1_500,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3IdentitySubjects" (
|
||||
"subject_type", "subject_id", "status", "version",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES ('user', 'data-adoption-owner', 'active', 1, ?, ?)`,
|
||||
)
|
||||
.run(now - 1_000, now - 1_000);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentials" (
|
||||
"credential_id", "version", "state", "subject_type",
|
||||
"subject_id", "secret_digest", "created_at_ms",
|
||||
"not_before_at_ms", "expires_at_ms"
|
||||
) VALUES (?, 1, 'active', 'user', 'data-adoption-owner', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
APPLICATION_CREDENTIAL_ID,
|
||||
apiCredentialSecretDigest(
|
||||
APPLICATION_PEPPER,
|
||||
APPLICATION_CREDENTIAL_ID,
|
||||
APPLICATION_CREDENTIAL_SECRET,
|
||||
),
|
||||
now - 1_000,
|
||||
now - 1_000,
|
||||
now + 10 * 60 * 1_000,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
"credential_id", "credential_version", "pepper_key_id"
|
||||
) VALUES (?, 1, ?)`,
|
||||
)
|
||||
.run(APPLICATION_CREDENTIAL_ID, APPLICATION_PEPPER_KEY_ID);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'data-adoption-owner', 1, 'active', ?,
|
||||
'd387-owner-binding', 'user', 'data-adoption-owner', ?
|
||||
)`,
|
||||
)
|
||||
.run(role, now - 500);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
fs.chmodSync(value.targetPath, 0o600);
|
||||
privateFile(
|
||||
credentialFilePath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-identity-credential-presentation',
|
||||
token: APPLICATION_TOKEN,
|
||||
})}\n`,
|
||||
);
|
||||
return {
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
secretKeyringPath,
|
||||
};
|
||||
}
|
||||
|
||||
function applicationOptions(
|
||||
value,
|
||||
prepared,
|
||||
staged,
|
||||
authority,
|
||||
transformationDigest,
|
||||
) {
|
||||
return {
|
||||
...transformationOptions(value, prepared, staged, 'default'),
|
||||
expectedTransformationDigest: transformationDigest,
|
||||
...authority,
|
||||
mutationId: 'd3870000-0000-4000-8000-000000000010',
|
||||
failureAuditEventId: 'd3870000-0000-4000-8000-000000000011',
|
||||
requestId: 'd387-data-directory-apply',
|
||||
};
|
||||
}
|
||||
|
||||
function applicationDatabaseRows(databasePath) {
|
||||
const database = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
return {
|
||||
adoptions: database
|
||||
.prepare(
|
||||
`SELECT mutation_id AS "mutationId", model_json AS "modelJson",
|
||||
receipt_digest AS "receiptDigest"
|
||||
FROM "QingLong3LegacyDataDirectoryAdoptions"`,
|
||||
)
|
||||
.all(),
|
||||
items: database
|
||||
.prepare(
|
||||
`SELECT secret_name AS "secretName", value_digest AS "valueDigest"
|
||||
FROM "QingLong3LegacyDataDirectoryAdoptionSecrets"
|
||||
ORDER BY ordinal`,
|
||||
)
|
||||
.all(),
|
||||
envelopes: database
|
||||
.prepare(
|
||||
`SELECT secret_name AS "secretName", version, mutation_id AS "mutationId",
|
||||
key_id AS "keyId", algorithm, nonce, ciphertext,
|
||||
auth_tag AS "authTag", created_at_ms AS "createdAtMs"
|
||||
FROM "QingLong3LocalSecretEnvelopes"
|
||||
ORDER BY secret_name`,
|
||||
)
|
||||
.all(),
|
||||
audits: database
|
||||
.prepare(
|
||||
`SELECT operation_id AS "operationId", outcome
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE operation_id IN ('legacy-data.apply', 'secret.create')
|
||||
ORDER BY operation_id, event_id`,
|
||||
)
|
||||
.all(),
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
test('stages only reviewed payloads behind the real SQLite activation fence', (t) => {
|
||||
const value = fixture(t);
|
||||
const prepared = prepare(value);
|
||||
@@ -927,3 +1139,285 @@ test('widened transformation commands fail closed before source access', (t) =>
|
||||
'LOCAL_DATA_DIRECTORY_ADOPTION_CONFIGURATION_INVALID',
|
||||
);
|
||||
});
|
||||
|
||||
test('atomically applies a ready transformation, reclaims plaintext and exactly replays', async (t) => {
|
||||
const value = fixture(t);
|
||||
const secrets = configureTransformationInput(value);
|
||||
const { prepared, staged } = stageForTransformation(value);
|
||||
const authority = await provisionApplicationAuthority(value);
|
||||
const transformed = run(
|
||||
value,
|
||||
'directory-transform-before-apply',
|
||||
DIRECTORY_TRANSFORM,
|
||||
transformationOptions(value, prepared, staged, 'default'),
|
||||
).result;
|
||||
const options = applicationOptions(
|
||||
value,
|
||||
prepared,
|
||||
staged,
|
||||
authority,
|
||||
transformed.evidence.transformationDigest,
|
||||
);
|
||||
const recoveryModel = path.join(value.deploymentRoot, 'recovery-model');
|
||||
fs.cpSync(path.join(value.transformationRoot, 'model'), recoveryModel, {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
privatizeTree(recoveryModel);
|
||||
|
||||
const applied = run(value, 'directory-apply', DIRECTORY_APPLY, options);
|
||||
assert.equal(applied.result.status, 'committed');
|
||||
assert.equal(applied.result.evidence.databaseStatus, 'inserted');
|
||||
assert.equal(applied.result.evidence.secretCount, 2);
|
||||
assert.equal(applied.result.evidence.environmentSecretCount, 1);
|
||||
assert.equal(applied.result.evidence.sshSecretCount, 1);
|
||||
assert.equal(applied.result.evidence.modelReclaimed, true);
|
||||
assert.equal(applied.result.evidence.plaintextFilesRemoved, true);
|
||||
assert.equal(applied.result.evidence.physicalErasureGuaranteed, false);
|
||||
for (const sensitive of [
|
||||
secrets.environmentValue,
|
||||
secrets.sshValue,
|
||||
secrets.authValue,
|
||||
secrets.environmentName,
|
||||
secrets.sshAlias,
|
||||
]) {
|
||||
assert.equal(applied.child.stdout.includes(sensitive), false);
|
||||
}
|
||||
assert.deepEqual(fs.readdirSync(value.transformationRoot).sort(), [
|
||||
'commit.json',
|
||||
'manifest.json',
|
||||
]);
|
||||
const commit = JSON.parse(
|
||||
fs.readFileSync(path.join(value.transformationRoot, 'commit.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(commit.reclamation.modelRemoved, true);
|
||||
assert.equal(commit.reclamation.plaintextFilesRemoved, true);
|
||||
assert.equal(commit.reclamation.physicalErasureGuaranteed, false);
|
||||
assert.equal(commit.commitDigest, applied.result.evidence.commitDigest);
|
||||
|
||||
const rows = applicationDatabaseRows(value.targetPath);
|
||||
assert.equal(rows.adoptions.length, 1);
|
||||
assert.equal(rows.items.length, 2);
|
||||
assert.equal(rows.envelopes.length, 2);
|
||||
assert.deepEqual(
|
||||
rows.audits.map(({ operationId, outcome }) => [operationId, outcome]),
|
||||
[
|
||||
['legacy-data.apply', 'allowed'],
|
||||
['secret.create', 'allowed'],
|
||||
['secret.create', 'allowed'],
|
||||
],
|
||||
);
|
||||
const durableModel = JSON.parse(rows.adoptions[0].modelJson);
|
||||
assert.equal(durableModel.activation, 'disabled');
|
||||
assert.equal(durableModel.config.activation, 'disabled');
|
||||
assert.equal(durableModel.keyv.activation, 'disabled');
|
||||
assert.equal(durableModel.ssh.activation, 'disabled');
|
||||
|
||||
const provider = new LocalSecretKeyringFileProvider(
|
||||
authority.secretKeyringPath,
|
||||
);
|
||||
const plaintexts = [];
|
||||
for (const envelope of rows.envelopes) {
|
||||
const material = await provider.resolve(envelope.keyId);
|
||||
assert.ok(material);
|
||||
const plaintext = decryptLocalSecretEnvelopeToBuffer(
|
||||
{
|
||||
projectId: 'default',
|
||||
name: envelope.secretName,
|
||||
version: envelope.version,
|
||||
mutationId: envelope.mutationId,
|
||||
keyId: envelope.keyId,
|
||||
algorithm: envelope.algorithm,
|
||||
nonce: Buffer.from(envelope.nonce).toString('base64url'),
|
||||
ciphertext: Buffer.from(envelope.ciphertext).toString('base64url'),
|
||||
authTag: Buffer.from(envelope.authTag).toString('base64url'),
|
||||
createdAtMs: envelope.createdAtMs,
|
||||
},
|
||||
material.key,
|
||||
);
|
||||
try {
|
||||
plaintexts.push(plaintext.toString('utf8'));
|
||||
} finally {
|
||||
plaintext.fill(0);
|
||||
material.key.fill(0);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
plaintexts.sort(),
|
||||
[secrets.environmentValue, secrets.sshValue].sort(),
|
||||
);
|
||||
|
||||
const replayed = run(
|
||||
value,
|
||||
'directory-apply-replay',
|
||||
DIRECTORY_APPLY,
|
||||
options,
|
||||
).result;
|
||||
assert.equal(replayed.evidence.databaseStatus, 'existing');
|
||||
assert.equal(
|
||||
replayed.evidence.receiptDigest,
|
||||
applied.result.evidence.receiptDigest,
|
||||
);
|
||||
assert.equal(
|
||||
replayed.evidence.commitDigest,
|
||||
applied.result.evidence.commitDigest,
|
||||
);
|
||||
const verified = run(
|
||||
value,
|
||||
'directory-apply-verify',
|
||||
DIRECTORY_APPLY_VERIFY,
|
||||
{
|
||||
...options,
|
||||
expectedReceiptDigest: applied.result.evidence.receiptDigest,
|
||||
},
|
||||
).result;
|
||||
assert.equal(verified.status, 'verified');
|
||||
assert.equal(verified.evidence.databaseStatus, 'existing');
|
||||
assert.equal(
|
||||
verified.evidence.commitDigest,
|
||||
applied.result.evidence.commitDigest,
|
||||
);
|
||||
|
||||
fs.unlinkSync(path.join(value.transformationRoot, 'commit.json'));
|
||||
fs.renameSync(
|
||||
recoveryModel,
|
||||
path.join(value.transformationRoot, '.reclaiming-model'),
|
||||
);
|
||||
privateFile(
|
||||
path.join(value.transformationRoot, '.commit-incomplete'),
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-data-directory-application-incomplete',
|
||||
mutationId: options.mutationId,
|
||||
transformationDigest: options.expectedTransformationDigest,
|
||||
receiptDigest: applied.result.evidence.receiptDigest,
|
||||
})}\n`,
|
||||
);
|
||||
const recovered = run(
|
||||
value,
|
||||
'directory-apply-recover-renamed-model',
|
||||
DIRECTORY_APPLY,
|
||||
options,
|
||||
).result;
|
||||
assert.equal(recovered.evidence.databaseStatus, 'existing');
|
||||
assert.equal(
|
||||
recovered.evidence.commitDigest,
|
||||
applied.result.evidence.commitDigest,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(value.transformationRoot).sort(), [
|
||||
'commit.json',
|
||||
'manifest.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rolls back every new Secret when one application target already exists', async (t) => {
|
||||
const value = fixture(t);
|
||||
configureTransformationInput(value);
|
||||
const { prepared, staged } = stageForTransformation(value);
|
||||
const authority = await provisionApplicationAuthority(value);
|
||||
const transformed = run(
|
||||
value,
|
||||
'directory-transform-before-conflict',
|
||||
DIRECTORY_TRANSFORM,
|
||||
transformationOptions(value, prepared, staged, 'default'),
|
||||
).result;
|
||||
const imports = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(value.transformationRoot, 'model', 'secret-imports.json'),
|
||||
'utf8',
|
||||
),
|
||||
).imports;
|
||||
assert.equal(imports.length, 2);
|
||||
const conflictingName = imports[1].targetName;
|
||||
const database = new DatabaseSync(value.targetPath);
|
||||
try {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalSecretEnvelopes" (
|
||||
"project_id", "secret_name", "version", "mutation_id", "key_id",
|
||||
"algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms"
|
||||
) VALUES ('default', ?, 1, ?, 'preexisting-v1', 'aes-256-gcm', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
conflictingName,
|
||||
'd3870000-0000-4000-8000-000000000020',
|
||||
Buffer.alloc(12, 1),
|
||||
Buffer.alloc(0),
|
||||
Buffer.alloc(16, 2),
|
||||
Date.now(),
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
const conflict = runRaw(
|
||||
value,
|
||||
'directory-apply-conflict',
|
||||
DIRECTORY_APPLY,
|
||||
applicationOptions(
|
||||
value,
|
||||
prepared,
|
||||
staged,
|
||||
authority,
|
||||
transformed.evidence.transformationDigest,
|
||||
),
|
||||
);
|
||||
assert.equal(conflict.status, 1);
|
||||
assert.equal(conflict.stdout, '');
|
||||
assert.equal(
|
||||
JSON.parse(conflict.stderr).code,
|
||||
'LOCAL_DATA_DIRECTORY_ADOPTION_CONFLICT',
|
||||
);
|
||||
const rows = applicationDatabaseRows(value.targetPath);
|
||||
assert.equal(rows.adoptions.length, 0);
|
||||
assert.equal(rows.items.length, 0);
|
||||
assert.equal(rows.envelopes.length, 1);
|
||||
assert.equal(rows.envelopes[0].secretName, conflictingName);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(value.transformationRoot, 'model')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a non-admin application without publishing or reclaiming the model', async (t) => {
|
||||
const value = fixture(t);
|
||||
configureTransformationInput(value);
|
||||
const { prepared, staged } = stageForTransformation(value);
|
||||
const authority = await provisionApplicationAuthority(value, 'viewer');
|
||||
const transformed = run(
|
||||
value,
|
||||
'directory-transform-before-denial',
|
||||
DIRECTORY_TRANSFORM,
|
||||
transformationOptions(value, prepared, staged, 'default'),
|
||||
).result;
|
||||
const denied = runRaw(
|
||||
value,
|
||||
'directory-apply-denied',
|
||||
DIRECTORY_APPLY,
|
||||
applicationOptions(
|
||||
value,
|
||||
prepared,
|
||||
staged,
|
||||
authority,
|
||||
transformed.evidence.transformationDigest,
|
||||
),
|
||||
);
|
||||
assert.equal(denied.status, 1);
|
||||
assert.equal(denied.stdout, '');
|
||||
assert.equal(
|
||||
JSON.parse(denied.stderr).code,
|
||||
'LOCAL_DATA_DIRECTORY_ADOPTION_APPLICATION_FORBIDDEN',
|
||||
);
|
||||
const rows = applicationDatabaseRows(value.targetPath);
|
||||
assert.equal(rows.adoptions.length, 0);
|
||||
assert.equal(rows.items.length, 0);
|
||||
assert.equal(rows.envelopes.length, 0);
|
||||
assert.deepEqual(
|
||||
rows.audits.map(({ operationId, outcome }) => [operationId, outcome]),
|
||||
[['legacy-data.apply', 'denied']],
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(value.transformationRoot, 'model')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -475,9 +475,9 @@ function composeDockerHarness(
|
||||
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
|
||||
],
|
||||
Labels: {
|
||||
'io.qinglong.local.sqlite-contract-min': '49',
|
||||
'io.qinglong.local.sqlite-contract-max': '49',
|
||||
'io.qinglong.local.sqlite-write-contract': '49',
|
||||
'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',
|
||||
'io.qinglong.local.compose-selection': '1',
|
||||
'io.qinglong.ai': 'excluded',
|
||||
@@ -1172,9 +1172,9 @@ test('preflights exact local image, Compose merge and SQLite capability', async
|
||||
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
|
||||
],
|
||||
Labels: {
|
||||
'io.qinglong.local.sqlite-contract-min': '49',
|
||||
'io.qinglong.local.sqlite-contract-max': '49',
|
||||
'io.qinglong.local.sqlite-write-contract': '49',
|
||||
'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',
|
||||
'io.qinglong.local.compose-selection': '1',
|
||||
'io.qinglong.ai': 'excluded',
|
||||
@@ -1226,7 +1226,7 @@ test('preflights exact local image, Compose merge and SQLite capability', async
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.generation, 1);
|
||||
assert.equal(result.profile, 'edge');
|
||||
assert.equal(result.sqlite.contractVersion, 49);
|
||||
assert.equal(result.sqlite.contractVersion, 50);
|
||||
assert.equal(result.image.architecture, 'arm64');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']);
|
||||
@@ -1326,8 +1326,8 @@ test('applies one Compose generation and exactly replays its health receipt', as
|
||||
assert.equal(mode(receiptPath), 0o600);
|
||||
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
|
||||
assert.deepEqual(receipt.sqlite, {
|
||||
contractVersion: 49,
|
||||
writeContractVersion: 49,
|
||||
contractVersion: 50,
|
||||
writeContractVersion: 50,
|
||||
writeObservation: 'unchanged',
|
||||
backup: null,
|
||||
});
|
||||
@@ -1628,8 +1628,8 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async
|
||||
`${command.request.rolloutId}.sqlite`,
|
||||
);
|
||||
assert.equal(mode(backupPath), 0o600);
|
||||
assert.equal(receipt.sqlite.contractVersion, 49);
|
||||
assert.equal(receipt.sqlite.writeContractVersion, 49);
|
||||
assert.equal(receipt.sqlite.contractVersion, 50);
|
||||
assert.equal(receipt.sqlite.writeContractVersion, 50);
|
||||
assert.equal(receipt.sqlite.writeObservation, 'changed');
|
||||
assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(receipt.sqlite.backup.bytes > 0, true);
|
||||
|
||||
@@ -34,8 +34,8 @@ test('inspects the exact fresh Profile schema without exposing its path', async
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.profile, 'edge');
|
||||
assert.equal(result.storage.contractName, 'local-control-core');
|
||||
assert.equal(result.storage.contractVersion, 49);
|
||||
assert.equal(result.storage.migrationCount, 98);
|
||||
assert.equal(result.storage.contractVersion, 50);
|
||||
assert.equal(result.storage.migrationCount, 100);
|
||||
assert.equal(result.storage.journalMode, 'delete');
|
||||
assert.equal(JSON.stringify(result).includes(state.directory), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user