diff --git a/backend/src/__tests__/gitops-schema.test.ts b/backend/src/__tests__/gitops-schema.test.ts index 11dbcd45..c4183ee7 100644 --- a/backend/src/__tests__/gitops-schema.test.ts +++ b/backend/src/__tests__/gitops-schema.test.ts @@ -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')); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index c406e6b0..0012ba31 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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 {