mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(local): publish secret config application atomically
This commit is contained in:
@@ -25,7 +25,7 @@ test('authentication projection opens the target read-only without journal or fi
|
||||
try {
|
||||
assert.equal(database.profile, 'edge');
|
||||
assert.equal(database.readiness.contractName, 'local-control-core');
|
||||
assert.equal(database.readiness.contractVersion, 51);
|
||||
assert.equal(database.readiness.contractVersion, 52);
|
||||
assert.equal(await database.apiCredentials.resolve('absent'), null);
|
||||
assert.equal(await database.ownerPepper.resolveKey('absent'), null);
|
||||
} finally {
|
||||
|
||||
@@ -152,9 +152,11 @@ test('creates a reviewed edge database and opens runtime only after readiness',
|
||||
'0100-capability-v50',
|
||||
'0101-legacy-adoption-provenance',
|
||||
'0102-capability-v51',
|
||||
'0103-secret-config-applications',
|
||||
'0104-capability-v52',
|
||||
]);
|
||||
assert.equal(migrated.readiness.contractName, 'local-control-core');
|
||||
assert.equal(migrated.readiness.contractVersion, 51);
|
||||
assert.equal(migrated.readiness.contractVersion, 52);
|
||||
assert.equal(migrated.readiness.journalMode, 'delete');
|
||||
assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600);
|
||||
|
||||
@@ -600,8 +602,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy
|
||||
.get(),
|
||||
},
|
||||
{
|
||||
contract_version: 51,
|
||||
migration_id: '0101-legacy-adoption-provenance',
|
||||
contract_version: 52,
|
||||
migration_id: '0103-secret-config-applications',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
@@ -788,19 +790,19 @@ test('excludes reviewed optional feature tables while preserving unknown table d
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 85);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 89);
|
||||
client.exec(
|
||||
'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)',
|
||||
);
|
||||
client.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 85);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 89);
|
||||
|
||||
const unknownClient = new DatabaseSync(databasePath);
|
||||
unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)');
|
||||
unknownClient.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 86);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 90);
|
||||
|
||||
const triggerClient = new DatabaseSync(databasePath);
|
||||
triggerClient.exec(`
|
||||
|
||||
@@ -156,7 +156,7 @@ test('atomically admits one generation-bound Workflow Run and exactly replays it
|
||||
},
|
||||
{ runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 51);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 52);
|
||||
});
|
||||
|
||||
test('runs an optional authorization guard inside new and replay transactions', async (t) => {
|
||||
@@ -288,7 +288,7 @@ test('exactly replays immutable admission after the Workflow StepRun advances',
|
||||
},
|
||||
{ status: 'running', version: 5, eventSequence: 5 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 51);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 52);
|
||||
});
|
||||
|
||||
test('fails closed before writing when the exact installation is not active', async (t) => {
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it'
|
||||
stepAttemptCount: 0,
|
||||
},
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 51);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 52);
|
||||
});
|
||||
|
||||
test('bounds candidate paging before SQL and fences cancellation', async (t) => {
|
||||
|
||||
@@ -40,9 +40,9 @@ test('creates and exactly replays a reviewed rollout backup', async (t) => {
|
||||
await migrateLocalSqlitePath(state);
|
||||
const prepared = await createLocalSqliteRolloutBackup(state);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.contractVersion, 51);
|
||||
assert.equal(prepared.writeContractVersion, 51);
|
||||
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 51);
|
||||
assert.equal(prepared.contractVersion, 52);
|
||||
assert.equal(prepared.writeContractVersion, 52);
|
||||
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 52);
|
||||
assert.match(prepared.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(prepared.bytes > 0, true);
|
||||
assert.equal(prepared.pageCount > 0, true);
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const { migrateLocalSqlitePath } = require('../dist/migration/migration');
|
||||
const {
|
||||
openLocalSqliteAdoptionDatabase,
|
||||
} = require('@qinglong/local-sqlite/adoption');
|
||||
const {
|
||||
LocalSecretConfigApplicationConflictError,
|
||||
openLocalSqliteSecretConfigApplicationDatabase,
|
||||
} = require('@qinglong/local-sqlite/secret-config-application');
|
||||
|
||||
const SUBJECT = Object.freeze({ type: 'user', id: 'local-owner' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
const ADOPTION_MUTATION = '12345678-1234-4123-8123-123456789abc';
|
||||
const APPLICATION_MUTATION = '87654321-4321-4123-8123-cba987654321';
|
||||
const SECRET_MUTATION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
||||
|
||||
function digest(character) {
|
||||
return character.repeat(64);
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-secret-config-application-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'qinglong3.sqlite');
|
||||
}
|
||||
|
||||
async function preparedDatabase(t) {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client
|
||||
.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', 'local-owner', 1, 'active', 'owner',
|
||||
'secret-config-owner-binding', 'user', 'local-owner', 1)`,
|
||||
)
|
||||
.run();
|
||||
client.close();
|
||||
|
||||
const adoption = await openLocalSqliteAdoptionDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
await adoption.publisher.publish({
|
||||
mutationId: ADOPTION_MUTATION,
|
||||
decisionId: '019f7200-0000-7000-8000-000000000001',
|
||||
projectId: 'default',
|
||||
profile: 'edge',
|
||||
planDigest: digest('1'),
|
||||
inventoryDigest: digest('2'),
|
||||
decisionDigest: digest('3'),
|
||||
receiptDigest: digest('4'),
|
||||
authorizationFileDigest: digest('5'),
|
||||
rowCount: 1,
|
||||
skippedCount: 0,
|
||||
subject: SUBJECT,
|
||||
fence: FENCE,
|
||||
audit: {
|
||||
eventId: ADOPTION_MUTATION,
|
||||
requestId: 'automation-adoption',
|
||||
operationId: 'task.adopt',
|
||||
projectId: 'default',
|
||||
subject: SUBJECT,
|
||||
authenticationId: 'local-console:review',
|
||||
outcome: 'allowed',
|
||||
reasons: ['project_role_allowed'],
|
||||
fence: FENCE,
|
||||
occurredAtMs: 100,
|
||||
},
|
||||
candidates: [
|
||||
{
|
||||
rowOrdinal: 1,
|
||||
sourceDigest: digest('6'),
|
||||
task: {
|
||||
taskId: 'legacy-cron:1',
|
||||
name: 'Legacy Task',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['legacy'] },
|
||||
},
|
||||
},
|
||||
labels: { source: 'legacy-adoption' },
|
||||
enabled: true,
|
||||
},
|
||||
triggers: [
|
||||
{
|
||||
triggerId: 'legacy-cron:1:cron:1',
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: '0 0 * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
confirmExternalAuthority() {},
|
||||
createdAtMs: 100,
|
||||
});
|
||||
await adoption.close();
|
||||
return databasePath;
|
||||
}
|
||||
|
||||
function applicationCommand(confirmExternalAuthority = () => {}) {
|
||||
return {
|
||||
mutationId: APPLICATION_MUTATION,
|
||||
projectId: 'default',
|
||||
profile: 'edge',
|
||||
secretConfigPlanDigest: digest('7'),
|
||||
decisionDigest: digest('8'),
|
||||
candidateSetDigest: digest('9'),
|
||||
automationAdoptionSetDigest: digest('a'),
|
||||
subject: SUBJECT,
|
||||
fence: FENCE,
|
||||
audit: {
|
||||
eventId: APPLICATION_MUTATION,
|
||||
requestId: 'secret-config-application',
|
||||
operationId: 'secret-config.apply',
|
||||
projectId: 'default',
|
||||
subject: SUBJECT,
|
||||
authenticationId: 'local-console:review',
|
||||
outcome: 'allowed',
|
||||
reasons: ['project_role_allowed'],
|
||||
fence: FENCE,
|
||||
occurredAtMs: 200,
|
||||
},
|
||||
secrets: [
|
||||
{
|
||||
ordinal: 1,
|
||||
disposition: 'active_binding',
|
||||
candidateDigest: digest('b'),
|
||||
sourceSetDigest: digest('c'),
|
||||
environmentName: 'LEGACY_TOKEN',
|
||||
envelope: {
|
||||
projectId: 'default',
|
||||
name: 'legacy-db-env-bbbbbbbbbbbbbbbb',
|
||||
version: 1,
|
||||
mutationId: SECRET_MUTATION,
|
||||
keyId: 'active-key',
|
||||
algorithm: 'aes-256-gcm',
|
||||
nonce: Buffer.alloc(12, 1).toString('base64url'),
|
||||
ciphertext: Buffer.from('ciphertext').toString('base64url'),
|
||||
authTag: Buffer.alloc(16, 2).toString('base64url'),
|
||||
createdAtMs: 200,
|
||||
},
|
||||
audit: {
|
||||
eventId: SECRET_MUTATION,
|
||||
requestId: 'secret-config-application',
|
||||
operationId: 'secret.create',
|
||||
projectId: 'default',
|
||||
subject: SUBJECT,
|
||||
authenticationId: 'local-console:review',
|
||||
outcome: 'allowed',
|
||||
reasons: ['project_role_allowed'],
|
||||
fence: FENCE,
|
||||
occurredAtMs: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
appliedAtMs: 200,
|
||||
confirmExternalAuthority,
|
||||
};
|
||||
}
|
||||
|
||||
test('atomically binds Secret, Task, dispatch, Trigger, schedule and replay ledger', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const database = await openLocalSqliteSecretConfigApplicationDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
const command = applicationCommand();
|
||||
const inserted = await database.publisher.publish(command);
|
||||
assert.equal(inserted.status, 'inserted');
|
||||
assert.equal(inserted.application.receipt.activeBindingCount, 1);
|
||||
assert.equal(inserted.application.receipt.taskCount, 1);
|
||||
assert.equal(inserted.application.receipt.triggerCount, 1);
|
||||
assert.equal((await database.publisher.publish(command)).status, 'existing');
|
||||
await database.close();
|
||||
|
||||
const client = new DatabaseSync(databasePath, { readOnly: true });
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`,
|
||||
)
|
||||
.get().revision,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT "current_revision" AS revision FROM "QingLong3Triggers" WHERE "trigger_id" = 'legacy-cron:1:cron:1'`,
|
||||
)
|
||||
.get().revision,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT "trigger_revision" AS revision FROM "QingLong3LocalTriggerSchedules" WHERE "trigger_id" = 'legacy-cron:1:cron:1'`,
|
||||
)
|
||||
.get().revision,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "QingLong3LocalSecretEnvelopes" WHERE "secret_name" = 'legacy-db-env-bbbbbbbbbbbbbbbb'`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "QingLong3LocalTaskExecutionRevisions" WHERE "task_id" = 'legacy-cron:1' AND "task_revision" LIKE 'qltd:v1:2:%'`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('rolls every database mutation back when the commit authority changes', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const database = await openLocalSqliteSecretConfigApplicationDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
let checks = 0;
|
||||
await assert.rejects(
|
||||
database.publisher.publish(
|
||||
applicationCommand(() => {
|
||||
checks += 1;
|
||||
if (checks === 2) throw new Error('instance head drifted');
|
||||
}),
|
||||
),
|
||||
/unavailable/,
|
||||
);
|
||||
await database.close();
|
||||
const client = new DatabaseSync(databasePath, { readOnly: true });
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "QingLong3SecretConfigApplications"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`,
|
||||
)
|
||||
.get().revision,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "QingLong3LocalSecretEnvelopes" WHERE "secret_name" = 'legacy-db-env-bbbbbbbbbbbbbbbb'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('fails closed on occupied Secret without advancing Task or Trigger heads', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalSecretEnvelopes" ("project_id", "secret_name", "version", "mutation_id", "key_id", "algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms") VALUES ('default', 'legacy-db-env-bbbbbbbbbbbbbbbb', 1, '11111111-2222-4333-8444-555555555555', 'other-key', 'aes-256-gcm', ?, ?, ?, 150)`,
|
||||
)
|
||||
.run(Buffer.alloc(12), Buffer.from('occupied'), Buffer.alloc(16));
|
||||
client.close();
|
||||
const database = await openLocalSqliteSecretConfigApplicationDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
await assert.rejects(
|
||||
database.publisher.publish(applicationCommand()),
|
||||
LocalSecretConfigApplicationConflictError,
|
||||
);
|
||||
await database.close();
|
||||
const inspected = new DatabaseSync(databasePath, { readOnly: true });
|
||||
assert.equal(
|
||||
inspected
|
||||
.prepare(
|
||||
`SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`,
|
||||
)
|
||||
.get().revision,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
inspected
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "QingLong3SecretConfigApplications"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
inspected.close();
|
||||
});
|
||||
|
||||
test('rejects incomplete Trigger provenance and rolls the streamed transaction back', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.prepare(`DELETE FROM "QingLong3LegacyAdoptionTriggers"`).run();
|
||||
client.close();
|
||||
const database = await openLocalSqliteSecretConfigApplicationDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
await assert.rejects(
|
||||
database.publisher.publish(applicationCommand()),
|
||||
LocalSecretConfigApplicationConflictError,
|
||||
);
|
||||
await database.close();
|
||||
const inspected = new DatabaseSync(databasePath, { readOnly: true });
|
||||
assert.equal(
|
||||
inspected
|
||||
.prepare(
|
||||
`SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`,
|
||||
)
|
||||
.get().revision,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
inspected
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "QingLong3SecretConfigApplicationTasks"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
inspected.close();
|
||||
});
|
||||
|
||||
test('rejects replay after the durable Trigger schedule drifts', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const database = await openLocalSqliteSecretConfigApplicationDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
const command = applicationCommand();
|
||||
await database.publisher.publish(command);
|
||||
await database.close();
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3LocalTriggerSchedules" SET "trigger_revision" = 1 WHERE "trigger_id" = 'legacy-cron:1:cron:1'`,
|
||||
)
|
||||
.run();
|
||||
client.close();
|
||||
const reopened = await openLocalSqliteSecretConfigApplicationDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
await assert.rejects(
|
||||
reopened.publisher.publish(command),
|
||||
LocalSecretConfigApplicationConflictError,
|
||||
);
|
||||
await reopened.close();
|
||||
});
|
||||
|
||||
test('rejects empty applications and Secret timestamps outside the application instant', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const database = await openLocalSqliteSecretConfigApplicationDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
assert.throws(
|
||||
() => database.publisher.publish({ ...applicationCommand(), secrets: [] }),
|
||||
LocalSecretConfigApplicationConflictError,
|
||||
);
|
||||
const command = applicationCommand();
|
||||
command.secrets[0].envelope.createdAtMs = command.appliedAtMs - 1;
|
||||
assert.throws(
|
||||
() => database.publisher.publish(command),
|
||||
LocalSecretConfigApplicationConflictError,
|
||||
);
|
||||
await database.close();
|
||||
});
|
||||
Reference in New Issue
Block a user