mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(local): bind legacy adoption provenance
This commit is contained in:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user