feat(gitops): migrate legacy auto-apply boolean to source policy

This commit is contained in:
Anso
2026-09-09 00:55:16 -04:00
parent 72fd455346
commit 01079076eb
2 changed files with 179 additions and 2 deletions
+124 -2
View File
@@ -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'));
+55
View File
@@ -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 {