Files
supabase/apps/studio/components/interfaces/ConfigDrift/github-config-drift.ts
Ivan Vasilov e10f7cc808 feat: Add a config drift page in Studio (#48906)
## New Features
- Initial work for showing configuration drift in Studio
- This only works for Github-connected projects and it'll show a banner
if the project state differs from the git-tracked `config.toml`
- Currently behind a feature-flag `ConfigDrift`, enabled on local and
staging.
- There might be drift shown without changing any setting, this is
work-in-progress.

<img width="1217" height="1195" alt="Screenshot 2026-08-19 at 23 12 10"
src="https://github.com/user-attachments/assets/fb0b18d8-1a93-4595-85cc-e8b8a3462847"
/>

## How to test
1. Connect a project to a Github repo
2. Resync the branch on `/dashboard/project/_/branches`. This will
trigger deployment of the `config.toml` on your project
3. Change some settings (I recommend
`dashboard/project/_/auth/providers`
4. A banner should appear on all project pages with a link 


## Tests
- Added coverage for configuration conversion, normalization, matching,
drift detection, and unmanaged settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-25 13:56:40 +02:00

142 lines
4.3 KiB
TypeScript

import {
CONFIG_SECTIONS,
getConfigValue,
getFieldDefinition,
getSectionFieldEntries,
isSecretConfigField,
type ConfigSection,
} from './github-config-field-registry'
import { gitHubConfigTomlSchema, type GitHubConfigToml } from './github-config.types'
// This file should be temporary and will be removed once we publish a "@supabase/config" package.
type GitHubConfigFieldStatus = 'unmanaged' | 'managed' | 'drifted'
interface GitHubConfigFieldState {
status: GitHubConfigFieldStatus
configPath?: string
settingHref?: (projectRef: string) => string
githubValue?: unknown
}
export interface GitHubConfigDriftField {
section: ConfigSection
configPath: string
settingHref: (projectRef: string) => string
dashboardValue: unknown
githubValue: unknown
}
export interface UnmanagedConfigField {
section: ConfigSection
configPath: string
dashboardValue: unknown
}
interface GitHubConfigDriftSummary {
managedCount: number
driftedFields: GitHubConfigDriftField[]
unmanagedFields: UnmanagedConfigField[]
}
function getConfigFieldState({
configPath,
dashboardConfig,
githubConfig,
}: {
configPath: string
dashboardConfig: GitHubConfigToml
githubConfig?: GitHubConfigToml
}): GitHubConfigFieldState {
const definition = getFieldDefinition(configPath)
if (!definition || !githubConfig || isSecretConfigField(definition.configPath)) {
return { status: 'unmanaged' }
}
const normalizedDashboardValue = getConfigValue(dashboardConfig, definition.configPath)
let githubValue = getConfigValue(githubConfig, definition.configPath)
if (githubValue === undefined) {
const isCodeOwned = getConfigValue(githubConfig, 'config_source') === 'code'
if (!isCodeOwned || definition.hostedDefault === undefined) return { status: 'unmanaged' }
const normalizedDefaultValue =
definition.normalizeGithubValue?.(definition.hostedDefault) ?? definition.hostedDefault
if (valuesMatch(normalizedDashboardValue, normalizedDefaultValue)) {
return { status: 'unmanaged' }
}
githubValue = definition.hostedDefault
}
const normalizedGithubValue = definition.normalizeGithubValue?.(githubValue) ?? githubValue
return {
status: valuesMatch(normalizedDashboardValue, normalizedGithubValue) ? 'managed' : 'drifted',
configPath: definition.configPath,
settingHref: definition.settingHref,
githubValue,
}
}
export function getConfigDriftSummary({
dashboardConfig,
githubConfig,
}: {
dashboardConfig?: GitHubConfigToml
githubConfig?: GitHubConfigToml
}): GitHubConfigDriftSummary {
if (!dashboardConfig || !githubConfig) {
return { managedCount: 0, driftedFields: [], unmanagedFields: [] }
}
const githubConfigResult = gitHubConfigTomlSchema.safeParse(githubConfig)
const dashboardConfigResult = gitHubConfigTomlSchema.safeParse(dashboardConfig)
if (!githubConfigResult.success || !dashboardConfigResult.success) {
return { managedCount: 0, driftedFields: [], unmanagedFields: [] }
}
const cleanedGithubConfig = githubConfigResult.data
const cleanedDashboardConfig = dashboardConfigResult.data
let managedCount = 0
const driftedFields: GitHubConfigDriftField[] = []
const unmanagedFields: UnmanagedConfigField[] = []
for (const section of CONFIG_SECTIONS) {
const sectionConfig = cleanedDashboardConfig[section]
if (!sectionConfig) continue
for (const { configPath, rawValue } of getSectionFieldEntries(section, sectionConfig)) {
if (isSecretConfigField(configPath)) continue
const state = getConfigFieldState({
configPath,
dashboardConfig: cleanedDashboardConfig,
githubConfig: cleanedGithubConfig,
})
if (state.status === 'managed') {
managedCount += 1
continue
}
if (state.status === 'unmanaged' || !state.configPath || !state.settingHref) {
unmanagedFields.push({ section, configPath, dashboardValue: rawValue })
continue
}
driftedFields.push({
section,
configPath: state.configPath,
settingHref: state.settingHref,
dashboardValue: rawValue,
githubValue: state.githubValue,
})
}
}
return { managedCount, driftedFields, unmanagedFields }
}
function valuesMatch(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}