mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
d94e586af3
* feat(blueprints): require confirmed rollout preview before reconcile Persist place/remove approval with an intent fingerprint and transition matrix so Apply, Retry, ticks, and pin cannot mutate the fleet until the operator confirms the current blast radius. Preview surfaces requirements, health, and informational in-flight rows without executing them. * fix(blueprints): silence unused retry nodeId lint error * test(blueprints): harden approval gate coverage and preview clarity Add real reconcileOne place/remove fan-out and STALE_GUARD regressions, surface reachability in the rollout dialog, align warning totals, and document the fail-closed upgrade pause. * test(blueprints): cover legacy approval schema migration Seed a pre-approval database with an enabled Blueprint and live deployment, run production DatabaseService startup, and assert pending null auth columns plus a fail-closed reconcile gate. * test(blueprints): clarify legacy approval migration fixture Extract seed/boot helpers so the migration regression reads as a linear upgrade path without changing assertions. * fix(blueprints): report apply outcomes and gate manual withdraw Return per-node reconcile outcomes from Confirm Apply, block create preview on unmanaged same-name stacks, and require an approved remove outcome for every manual withdraw or evict. * fix(blueprints): scope withdraw approval to destructive eviction Require remove approval only for snapshot/evict confirms and evict_blocked rows. Keep plain stateless standard withdraw as an immediate stop, and update withdraw-route tests to seed remove approval when needed.
211 lines
7.9 KiB
TypeScript
211 lines
7.9 KiB
TypeScript
/**
|
|
* Fail-closed approval columns on blueprints.
|
|
* Seeds a pre-approval schema with an enabled Blueprint (and live placement),
|
|
* then opens production DatabaseService startup so migrateAddBlueprintApproval runs.
|
|
*/
|
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import Database from 'better-sqlite3';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
|
|
const LEGACY_BLUEPRINT = 'legacy-web';
|
|
|
|
const APPROVAL_COLUMNS = [
|
|
'approval_status',
|
|
'approved_intent_fingerprint',
|
|
'approved_blast_json',
|
|
'approved_at',
|
|
'approved_by',
|
|
] as const;
|
|
|
|
const ABSENT_BEFORE_MIGRATION = ['approval_status', 'approved_blast_json'] as const;
|
|
|
|
function resetDatabaseSingleton(): void {
|
|
const holder = DatabaseService as unknown as { instance?: DatabaseService };
|
|
const existing = holder.instance;
|
|
if (existing) {
|
|
try {
|
|
existing.getDb().close();
|
|
} catch {
|
|
// already closed
|
|
}
|
|
holder.instance = undefined;
|
|
}
|
|
}
|
|
|
|
type ApprovalRow = {
|
|
approval_status: string | null;
|
|
approved_intent_fingerprint: string | null;
|
|
approved_blast_json: string | null;
|
|
approved_at: number | null;
|
|
approved_by: string | null;
|
|
enabled: number;
|
|
};
|
|
|
|
function blueprintColumnNames(db: Database.Database): string[] {
|
|
return (db.prepare('PRAGMA table_info(blueprints)').all() as Array<{ name: string }>).map(c => c.name);
|
|
}
|
|
|
|
function readApprovalRow(db: Database.Database, name: string): ApprovalRow {
|
|
return db.prepare(
|
|
`SELECT approval_status, approved_intent_fingerprint, approved_blast_json,
|
|
approved_at, approved_by, enabled
|
|
FROM blueprints WHERE name = ?`,
|
|
).get(name) as ApprovalRow;
|
|
}
|
|
|
|
function expectPendingNullAuth(row: ApprovalRow): void {
|
|
expect(row.approval_status).toBe('pending');
|
|
expect(row.approved_intent_fingerprint).toBeNull();
|
|
expect(row.approved_blast_json).toBeNull();
|
|
expect(row.approved_at).toBeNull();
|
|
expect(row.approved_by).toBeNull();
|
|
expect(row.enabled).toBe(1);
|
|
}
|
|
|
|
function seedLegacyPreApprovalSchema(dbPath: string, now: number): string[] {
|
|
const seed = new Database(dbPath);
|
|
try {
|
|
// Legacy schema: no approval_* columns and no pinned_node_id.
|
|
// Include a matching node + active deployment so a wrongly approved
|
|
// migration would have something to mutate on reconcile.
|
|
seed.exec(`
|
|
CREATE TABLE nodes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
type TEXT NOT NULL DEFAULT 'local',
|
|
compose_dir TEXT NOT NULL DEFAULT '/app/compose',
|
|
is_default INTEGER DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'unknown',
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
INSERT INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
|
|
VALUES (1, 'legacy-local', 'local', '/tmp/compose', 1, 'online', ${now});
|
|
|
|
CREATE TABLE blueprints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
description TEXT,
|
|
compose_content TEXT NOT NULL,
|
|
selector_json TEXT NOT NULL,
|
|
drift_mode TEXT NOT NULL DEFAULT 'suggest',
|
|
classification TEXT NOT NULL DEFAULT 'unknown',
|
|
classification_reasons TEXT,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
revision INTEGER NOT NULL DEFAULT 1,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
created_by TEXT
|
|
);
|
|
INSERT INTO blueprints (
|
|
name, description, compose_content, selector_json, drift_mode,
|
|
classification, classification_reasons, enabled, revision,
|
|
created_at, updated_at, created_by
|
|
) VALUES (
|
|
'${LEGACY_BLUEPRINT}',
|
|
NULL,
|
|
'services:\n app:\n image: nginx\n',
|
|
'{"type":"nodes","ids":[1]}',
|
|
'observe',
|
|
'stateless',
|
|
'[]',
|
|
1,
|
|
1,
|
|
${now},
|
|
${now},
|
|
'admin'
|
|
);
|
|
|
|
CREATE TABLE blueprint_deployments (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
blueprint_id INTEGER NOT NULL,
|
|
node_id INTEGER NOT NULL,
|
|
status TEXT NOT NULL,
|
|
applied_revision INTEGER,
|
|
last_deployed_at INTEGER,
|
|
last_checked_at INTEGER,
|
|
last_drift_at INTEGER,
|
|
drift_summary TEXT,
|
|
last_error TEXT,
|
|
UNIQUE(blueprint_id, node_id)
|
|
);
|
|
INSERT INTO blueprint_deployments
|
|
(blueprint_id, node_id, status, applied_revision, last_deployed_at)
|
|
VALUES (1, 1, 'active', 1, ${now});
|
|
`);
|
|
return blueprintColumnNames(seed);
|
|
} finally {
|
|
seed.close();
|
|
}
|
|
}
|
|
|
|
function bootDatabaseService(scratchDir: string): DatabaseService {
|
|
process.env.DATA_DIR = scratchDir;
|
|
process.env.COMPOSE_DIR = path.join(scratchDir, 'compose');
|
|
fs.mkdirSync(process.env.COMPOSE_DIR, { recursive: true });
|
|
resetDatabaseSingleton();
|
|
return DatabaseService.getInstance();
|
|
}
|
|
|
|
describe('blueprint approval column migration', () => {
|
|
let scratchDir: string | null = null;
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
resetDatabaseSingleton();
|
|
if (scratchDir) {
|
|
try {
|
|
fs.rmSync(scratchDir, { recursive: true, force: true });
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
scratchDir = null;
|
|
}
|
|
});
|
|
|
|
it('migrates a pre-approval enabled blueprint to pending with no authorization material', async () => {
|
|
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-bp-approval-mig-'));
|
|
const now = Date.now();
|
|
const colNamesBefore = seedLegacyPreApprovalSchema(path.join(scratchDir, 'sencho.db'), now);
|
|
for (const absent of ABSENT_BEFORE_MIGRATION) {
|
|
expect(colNamesBefore).not.toContain(absent);
|
|
}
|
|
|
|
const db = bootDatabaseService(scratchDir);
|
|
|
|
for (const required of APPROVAL_COLUMNS) {
|
|
expect(blueprintColumnNames(db.getDb())).toContain(required);
|
|
}
|
|
|
|
// Assert raw SQLite values (not parseBlueprint coercion).
|
|
expectPendingNullAuth(readApprovalRow(db.getDb(), LEGACY_BLUEPRINT));
|
|
|
|
const row = db.getBlueprintByName(LEGACY_BLUEPRINT);
|
|
expect(row).toBeTruthy();
|
|
expect(row!.id).toBe(1);
|
|
expect(db.getNodes().some(n => n.id === 1)).toBe(true);
|
|
expect(db.listDeployments(1).some(d => d.node_id === 1 && d.status === 'active')).toBe(true);
|
|
|
|
const { BlueprintService } = await import('../services/BlueprintService');
|
|
const { BlueprintReconciler } = await import('../services/BlueprintReconciler');
|
|
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
|
|
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
|
|
const upsertSpy = vi.spyOn(db, 'upsertDeployment');
|
|
|
|
await BlueprintReconciler.getInstance().reconcileOne(row!.id);
|
|
|
|
expect(deploySpy).not.toHaveBeenCalled();
|
|
expect(withdrawSpy).not.toHaveBeenCalled();
|
|
expect(upsertSpy).not.toHaveBeenCalled();
|
|
|
|
// Idempotent reopen keeps a single set of columns and pending row state.
|
|
resetDatabaseSingleton();
|
|
process.env.DATA_DIR = scratchDir;
|
|
const db2 = DatabaseService.getInstance();
|
|
expect(blueprintColumnNames(db2.getDb()).filter(name => name === 'approval_status')).toHaveLength(1);
|
|
expectPendingNullAuth(readApprovalRow(db2.getDb(), LEGACY_BLUEPRINT));
|
|
});
|
|
});
|