feat(local): bind legacy adoption provenance

This commit is contained in:
whyour
2026-08-23 20:26:01 +08:00
parent 74ae891070
commit 31a9bcbe83
32 changed files with 1437 additions and 81 deletions
@@ -366,9 +366,9 @@ function adoptedDockerHarness(state, options = {}) {
'/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.sqlite-contract-min': '51',
'io.qinglong.local.sqlite-contract-max': '51',
'io.qinglong.local.sqlite-write-contract': '51',
'io.qinglong.local.application-config': '2,3,4',
'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded',
@@ -839,7 +839,7 @@ test('preflights adopted Compose identity mounts and rejects mount drift', async
);
assert.equal(ready.status, 'ready');
assert.equal(ready.profile, 'edge');
assert.equal(ready.sqlite.contractVersion, 50);
assert.equal(ready.sqlite.contractVersion, 51);
await assert.rejects(
preflightLocalDeploymentCompose(composePreflightCommand(state, 1), {
runDocker: adoptedDockerHarness(state, { driftMount: true }).runDocker,
@@ -490,9 +490,9 @@ function composeDockerHarness(
'/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.sqlite-contract-min': '51',
'io.qinglong.local.sqlite-contract-max': '51',
'io.qinglong.local.sqlite-write-contract': '51',
'io.qinglong.local.application-config': '2',
'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded',
@@ -1199,9 +1199,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': '50',
'io.qinglong.local.sqlite-contract-max': '50',
'io.qinglong.local.sqlite-write-contract': '50',
'io.qinglong.local.sqlite-contract-min': '51',
'io.qinglong.local.sqlite-contract-max': '51',
'io.qinglong.local.sqlite-write-contract': '51',
'io.qinglong.local.application-config': '2',
'io.qinglong.local.compose-selection': '1',
'io.qinglong.ai': 'excluded',
@@ -1259,7 +1259,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, 50);
assert.equal(result.sqlite.contractVersion, 51);
assert.equal(result.image.architecture, 'arm64');
assert.equal(calls.length, 2);
assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']);
@@ -1359,8 +1359,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: 50,
writeContractVersion: 50,
contractVersion: 51,
writeContractVersion: 51,
writeObservation: 'unchanged',
backup: null,
});
@@ -1661,8 +1661,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, 50);
assert.equal(receipt.sqlite.writeContractVersion, 50);
assert.equal(receipt.sqlite.contractVersion, 51);
assert.equal(receipt.sqlite.writeContractVersion, 51);
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, 50);
assert.equal(result.storage.migrationCount, 100);
assert.equal(result.storage.contractVersion, 51);
assert.equal(result.storage.migrationCount, 102);
assert.equal(result.storage.journalMode, 'delete');
assert.equal(JSON.stringify(result).includes(state.directory), false);
});
@@ -4,6 +4,10 @@ const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
LegacyAdoptionPublicationDigest,
legacyAdoptionTaskProvenanceDigest,
} = require('@qinglong/local-sqlite/adoption-provenance');
const {
buildLocalReconciliationSecretConfigPlanReceipt,
@@ -78,11 +82,85 @@ function databases() {
audit_event_id TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE "QingLong3TaskDefinitions" (
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
current_revision INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (project_id, task_id)
);
CREATE TABLE "QingLong3TaskDefinitionRevisions" (
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
revision INTEGER NOT NULL,
mutation_id TEXT NOT NULL,
content_digest TEXT NOT NULL,
PRIMARY KEY (project_id, task_id, revision)
);
CREATE TABLE "QingLong3PluginPackageTaskOwnerships" (
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
package_name TEXT NOT NULL,
PRIMARY KEY (project_id, task_id)
);
CREATE TABLE "QingLong3Triggers" (
project_id TEXT NOT NULL,
trigger_id TEXT NOT NULL,
task_id TEXT NOT NULL,
current_revision INTEGER NOT NULL,
PRIMARY KEY (project_id, trigger_id)
);
CREATE TABLE "QingLong3TriggerRevisions" (
project_id TEXT NOT NULL,
trigger_id TEXT NOT NULL,
revision INTEGER NOT NULL,
mutation_id TEXT NOT NULL,
content_digest TEXT NOT NULL,
PRIMARY KEY (project_id, trigger_id, revision)
);
CREATE TABLE "QingLong3LocalTriggerSchedules" (
project_id TEXT NOT NULL,
trigger_id TEXT NOT NULL,
trigger_revision INTEGER NOT NULL,
PRIMARY KEY (project_id, trigger_id)
);
CREATE TABLE "QingLong3LegacyAdoptionTasks" (
adoption_mutation_id TEXT NOT NULL,
row_ordinal INTEGER NOT NULL,
project_id TEXT NOT NULL,
source_digest TEXT NOT NULL,
task_id TEXT NOT NULL,
task_revision INTEGER NOT NULL,
task_mutation_id TEXT NOT NULL,
task_content_digest TEXT NOT NULL,
trigger_count INTEGER NOT NULL,
item_digest TEXT NOT NULL,
PRIMARY KEY (adoption_mutation_id, row_ordinal)
);
CREATE TABLE "QingLong3LegacyAdoptionTriggers" (
adoption_mutation_id TEXT NOT NULL,
row_ordinal INTEGER NOT NULL,
trigger_ordinal INTEGER NOT NULL,
project_id TEXT NOT NULL,
task_id TEXT NOT NULL,
task_revision INTEGER NOT NULL,
trigger_id TEXT NOT NULL,
trigger_revision INTEGER NOT NULL,
trigger_mutation_id TEXT NOT NULL,
trigger_content_digest TEXT NOT NULL,
item_digest TEXT NOT NULL,
PRIMARY KEY (adoption_mutation_id, row_ordinal, trigger_ordinal)
);
`);
return { legacy, target };
}
function insertAutomationAdoption(target, adoptedTaskCount = 1) {
function insertAutomationAdoption(
target,
adoptedTaskCount = 1,
withProvenance = true,
) {
const mutationId = '30000000-0000-4000-8000-000000000003';
target
.prepare(
@@ -106,6 +184,70 @@ function insertAutomationAdoption(target, adoptedTaskCount = 1) {
mutationId,
HEADER.preparedAtMs,
);
if (!withProvenance) return;
const publication = new LegacyAdoptionPublicationDigest(mutationId);
for (let rowOrdinal = 1; rowOrdinal <= adoptedTaskCount; rowOrdinal += 1) {
const taskId = `legacy-cron:${rowOrdinal}`;
const taskMutationId = `31000000-0000-4000-8000-${String(rowOrdinal).padStart(12, '0')}`;
const sourceDigest = String(rowOrdinal % 10).repeat(64);
const taskContentDigest = String((rowOrdinal + 1) % 10).repeat(64);
const payload = {
adoptionMutationId: mutationId,
rowOrdinal,
projectId: HEADER.projectId,
sourceDigest,
taskId,
taskRevision: 1,
taskMutationId,
taskContentDigest,
triggerCount: 0,
};
const itemDigest = legacyAdoptionTaskProvenanceDigest(payload);
target
.prepare(
`INSERT INTO "QingLong3TaskDefinitions" VALUES (?, ?, 1, ?, ?)`
)
.run(
HEADER.projectId,
taskId,
HEADER.preparedAtMs,
HEADER.preparedAtMs,
);
target
.prepare(
`INSERT INTO "QingLong3TaskDefinitionRevisions" VALUES
(?, ?, 1, ?, ?)`
)
.run(HEADER.projectId, taskId, taskMutationId, taskContentDigest);
target
.prepare(
`INSERT INTO "QingLong3LegacyAdoptionTasks" VALUES
(?, ?, ?, ?, ?, 1, ?, ?, 0, ?)`
)
.run(
mutationId,
rowOrdinal,
HEADER.projectId,
sourceDigest,
taskId,
taskMutationId,
taskContentDigest,
itemDigest,
);
publication.appendTask({
rowOrdinal,
sourceDigest,
taskContentDigest,
itemDigest,
});
}
target
.prepare(
`UPDATE "QingLong3LegacyAdoptions"
SET publication_digest = ?
WHERE mutation_id = ?`,
)
.run(publication.digest(), mutationId);
}
function writePlan(
@@ -167,6 +309,10 @@ test('writes a content-free Env plan with separate active and disabled candidate
assert.equal(result.footer.targetConflictCount, 0);
assert.equal(result.footer.automationAdoptionRecordCount, 1);
assert.equal(result.footer.adoptedLegacyTaskCount, 1);
assert.equal(result.footer.adoptedLegacyTriggerCount, 0);
assert.equal(result.footer.adoptionProvenanceTaskCount, 1);
assert.equal(result.footer.adoptionProvenanceTriggerCount, 0);
assert.equal(result.footer.automationAdoptionProvenanceState, 'complete');
assert.match(result.footer.automationAdoptionSetDigest, /^[0-9a-f]{64}$/);
const candidates = records.filter((record) =>
record.kind.endsWith('-candidate'),
@@ -218,6 +364,60 @@ test('writes a content-free Env plan with separate active and disabled candidate
}),
/receipt drifted/,
);
const tamperedPayload = {
adoptionMutationId: '30000000-0000-4000-8000-000000000003',
rowOrdinal: 1,
projectId: HEADER.projectId,
sourceDigest: '9'.repeat(64),
taskId: 'legacy-cron:1',
taskRevision: 1,
taskMutationId: '31000000-0000-4000-8000-000000000001',
taskContentDigest: '2'.repeat(64),
triggerCount: 0,
};
target
.prepare(
`UPDATE "QingLong3LegacyAdoptionTasks"
SET source_digest = ?, item_digest = ?
WHERE adoption_mutation_id = ? AND row_ordinal = 1`,
)
.run(
tamperedPayload.sourceDigest,
legacyAdoptionTaskProvenanceDigest(tamperedPayload),
tamperedPayload.adoptionMutationId,
);
const resealedItem = writePlan(t, legacy, target);
assert.equal(resealedItem.result.footer.outcome, 'manual_required');
assert.equal(
resealedItem.result.footer.automationAdoptionProvenanceState,
'drifted',
);
const originalPayload = { ...tamperedPayload, sourceDigest: '1'.repeat(64) };
target
.prepare(
`UPDATE "QingLong3LegacyAdoptionTasks"
SET source_digest = ?, item_digest = ?
WHERE adoption_mutation_id = ? AND row_ordinal = 1`,
)
.run(
originalPayload.sourceDigest,
legacyAdoptionTaskProvenanceDigest(originalPayload),
originalPayload.adoptionMutationId,
);
target.exec(
`UPDATE "QingLong3TaskDefinitions"
SET current_revision = 2
WHERE project_id = 'project-1' AND task_id = 'legacy-cron:1'`,
);
const drifted = writePlan(t, legacy, target);
assert.equal(drifted.result.footer.outcome, 'manual_required');
assert.equal(
drifted.result.footer.automationAdoptionProvenanceState,
'drifted',
);
});
test('captures a target Secret collision without reading plaintext', (t) => {
@@ -310,6 +510,23 @@ test('keeps active Env and historical Configs manual without adoption authority'
assert.equal(withConfigs.result.footer.unadaptedLegacyConfigCount, 1);
});
test('keeps pre-provenance Automation adoption records manual', (t) => {
const { legacy, target } = databases();
t.after(() => legacy.close());
t.after(() => target.close());
legacy.exec(
`INSERT INTO "Envs" VALUES
(1, 'TOKEN', 'private-value', 0, 1, 0, '2026-01-01')`,
);
insertAutomationAdoption(target, 1, false);
const planned = writePlan(t, legacy, target);
assert.equal(planned.result.footer.outcome, 'manual_required');
assert.equal(planned.result.footer.automationAdoptionProvenanceState, 'missing');
assert.equal(planned.result.footer.adoptionProvenanceTaskCount, 0);
assert.equal(planned.serialized.includes('private-value'), false);
});
test('fails closed before exceeding the plan byte budget', (t) => {
const { legacy, target } = databases();
t.after(() => legacy.close());