mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-10 01:15:55 +00:00
feat(gitops): migrate legacy auto-apply boolean to source policy
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { isHubOnlyPath } from '../helpers/proxyExemptPaths';
|
||||
import { GitOpsStore, emptyTargetRow } from '../services/gitops/store';
|
||||
@@ -38,7 +38,7 @@ describe('gitops schema', () => {
|
||||
const version = db.prepare(
|
||||
"SELECT value FROM global_settings WHERE key = 'gitops_schema_version'",
|
||||
).get() as { value: string };
|
||||
expect(version.value).toBe('1');
|
||||
expect(version.value).toBe('2');
|
||||
const recoveryCols = new Set(
|
||||
(db.pragma('table_info(stack_update_recovery_generations)') as Array<{ name: string }>).map((c) => c.name),
|
||||
);
|
||||
@@ -268,6 +268,128 @@ describe('gitops schema', () => {
|
||||
expect(populated?.source_policy_evidence_json).toBe('{"policy":"manual"}');
|
||||
});
|
||||
|
||||
describe('migrateGitOpsSourcePolicy', () => {
|
||||
// The migration is private like its siblings; tests reach it through the
|
||||
// same cast the git-source migrations use.
|
||||
let db: import('../services/DatabaseService').DatabaseService;
|
||||
let store: import('../services/gitops/store').GitOpsStore;
|
||||
let migrate: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
db = DatabaseService.getInstance();
|
||||
});
|
||||
|
||||
const forcePolicy = (id: string, policy: string): void => {
|
||||
db.getDb().prepare(
|
||||
"UPDATE gitops_applications SET source_policy = ? WHERE id = ?",
|
||||
).run(policy, id);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const { GitOpsStore } = await import('../services/gitops/store');
|
||||
store = GitOpsStore.getInstance();
|
||||
migrate = (db as unknown as { migrateGitOpsSourcePolicy: () => void }).migrateGitOpsSourcePolicy.bind(db);
|
||||
// Two sources: one with the legacy boolean off, one with it on. Each gets
|
||||
// a live direct application whose policy is forced to 'manual' so the
|
||||
// test cannot depend on what buildDirectApplicationRow defaults to.
|
||||
db.upsertGitSource({
|
||||
stack_name: 'mig-off',
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
compose_paths: ['compose.yaml'],
|
||||
context_dir: null,
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null,
|
||||
ssh_host_key_fingerprint: null, encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
last_applied_content_hash: null,
|
||||
pending_commit_sha: null,
|
||||
pending_compose_content: null,
|
||||
pending_env_content: null,
|
||||
pending_fetched_at: null,
|
||||
last_debounce_at: null,
|
||||
});
|
||||
db.upsertGitSource({
|
||||
stack_name: 'mig-on',
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
compose_path: 'compose.yaml',
|
||||
compose_paths: ['compose.yaml'],
|
||||
context_dir: null,
|
||||
sync_env: false,
|
||||
env_path: null,
|
||||
auth_type: 'none',
|
||||
encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null,
|
||||
ssh_host_key_fingerprint: null, encrypted_ca_bundle: null,
|
||||
auto_apply_on_webhook: true,
|
||||
auto_deploy_on_apply: false,
|
||||
last_applied_commit_sha: null,
|
||||
last_applied_content_hash: null,
|
||||
pending_commit_sha: null,
|
||||
pending_compose_content: null,
|
||||
pending_env_content: null,
|
||||
pending_fetched_at: null,
|
||||
last_debounce_at: null,
|
||||
});
|
||||
store.insertApplication(directApp('app-mig-off', 'mig-off'));
|
||||
store.insertApplication(directApp('app-mig-on', 'mig-on'));
|
||||
store.insertApplication(directApp('app-mig-orphan', 'mig-orphan'));
|
||||
forcePolicy('app-mig-off', 'manual');
|
||||
forcePolicy('app-mig-on', 'manual');
|
||||
forcePolicy('app-mig-orphan', 'manual');
|
||||
// The constructor migration ran while the test DB was provisioned, so
|
||||
// reset to the pre-migration state this describe block simulates.
|
||||
db.updateGlobalSetting('gitops_schema_version', '1');
|
||||
});
|
||||
|
||||
it('converts auto_apply_on_webhook 0 to review and 1 to automatic', () => {
|
||||
migrate();
|
||||
expect(store.getApplication('app-mig-off')?.source_policy).toBe('review');
|
||||
expect(store.getApplication('app-mig-on')?.source_policy).toBe('automatic');
|
||||
});
|
||||
|
||||
it('leaves rows without a matching git source untouched', () => {
|
||||
migrate();
|
||||
expect(store.getApplication('app-mig-orphan')?.source_policy).toBe('manual');
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
migrate();
|
||||
migrate();
|
||||
expect(store.getApplication('app-mig-off')?.source_policy).toBe('review');
|
||||
expect(store.getApplication('app-mig-on')?.source_policy).toBe('automatic');
|
||||
});
|
||||
|
||||
it('does not run when gitops_schema_version is already 2', () => {
|
||||
db.updateGlobalSetting('gitops_schema_version', '2');
|
||||
forcePolicy('app-mig-on', 'manual');
|
||||
migrate();
|
||||
expect(store.getApplication('app-mig-on')?.source_policy).toBe('manual');
|
||||
});
|
||||
|
||||
it('seeds gitops_poll_interval_mins to 0', () => {
|
||||
expect(db.getGitOpsPollIntervalMins()).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to 0 and warns on an invalid stored value', () => {
|
||||
db.updateGlobalSetting('gitops_poll_interval_mins', 'not-a-number');
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
expect(db.getGitOpsPollIntervalMins()).toBe(0);
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
db.updateGlobalSetting('gitops_poll_interval_mins', '0');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults controller-owned columns to manual, off, and zero on a fresh application', async () => {
|
||||
const store = GitOpsStore.getInstance();
|
||||
store.insertApplication(directApp('app-ctrl', 'ctrl-web'));
|
||||
|
||||
@@ -1192,6 +1192,7 @@ export class DatabaseService {
|
||||
this.migrateGitOpsCreateCheckpointSshDeployKey();
|
||||
this.migrateNodeUpdateSkips();
|
||||
this.migrateStackAlertServiceScope();
|
||||
this.migrateGitOpsSourcePolicy();
|
||||
|
||||
// Reset the cache once at end of constructor in case any migration
|
||||
// populated it via getGlobalSettings() and a subsequent migration
|
||||
@@ -2156,6 +2157,8 @@ export class DatabaseService {
|
||||
// it off in Settings > Users.
|
||||
stmt.run('session_sliding_refresh', '1');
|
||||
stmt.run('gitops_schema_version', '1');
|
||||
// Global GitOps polling starts off after an upgrade; operators opt in.
|
||||
stmt.run('gitops_poll_interval_mins', '0');
|
||||
// SSO role sync defaults off: admin-set roles persist across SSO sign-ins;
|
||||
// operators who want IdP group membership to drive roles opt in via Settings > SSO.
|
||||
stmt.run('sso_role_sync', '0');
|
||||
@@ -2764,6 +2767,37 @@ stmt.run('gitops_schema_version', '1');
|
||||
this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy auto-apply boolean to source policy. Webhooks always fetched before
|
||||
* consulting the boolean, so a `0` row was review-only, not manual: every
|
||||
* row with a git source converts (0 to review, 1 to automatic). Interval
|
||||
* columns stay NULL so an upgrade starts no unattended polling; global
|
||||
* polling seeds off separately. Gated on gitops_schema_version and idempotent.
|
||||
*/
|
||||
private migrateGitOpsSourcePolicy(): void {
|
||||
if (this.getGlobalSettingFresh('gitops_schema_version') === '2') return;
|
||||
try {
|
||||
this.db.transaction(() => {
|
||||
this.db.prepare(`
|
||||
UPDATE gitops_applications
|
||||
SET source_policy = (
|
||||
SELECT CASE WHEN s.auto_apply_on_webhook = 1 THEN 'automatic' ELSE 'review' END
|
||||
FROM stack_git_sources s
|
||||
WHERE s.stack_name = gitops_applications.stack_name
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM stack_git_sources s
|
||||
WHERE s.stack_name = gitops_applications.stack_name
|
||||
)
|
||||
`).run();
|
||||
this.updateGlobalSetting('gitops_schema_version', '2');
|
||||
})();
|
||||
} catch (e) {
|
||||
console.error('[DatabaseService] gitops source policy migration failed:', (e as Error).message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk-based deploy-gate inputs. The defaults preserve existing rows as
|
||||
* severity-only (block_on_severity=1, KEV/fixable off); new policies set
|
||||
@@ -4503,6 +4537,27 @@ stmt.run('gitops_schema_version', '1');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global unattended poll interval in minutes; 0 disables polling entirely
|
||||
* (the safe default, since 0 also means off when set). Per-source
|
||||
* poll_interval_secs overrides this when non-null. Invalid or missing
|
||||
* values fall back to 0: automation defaults off on a read failure.
|
||||
*/
|
||||
public getGitOpsPollIntervalMins(): number {
|
||||
try {
|
||||
const raw = this.getGlobalSettings()['gitops_poll_interval_mins'];
|
||||
const parsed = parseInt(String(raw ?? '0'), 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
console.warn(`[DatabaseService] invalid gitops_poll_interval_mins "${String(raw)}"; treating as 0 (off)`);
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.warn('[DatabaseService] gitops_poll_interval_mins read failed; treating as 0 (off):', (e as Error).message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Total generations retained per stack, current included (0 = unlimited). */
|
||||
public getRecoveryMaxGenerations(): number {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user