Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ Useful `diff` flags:
| `--allow-destructive` | Also apply drops and deletes |
| `--apply-posture` | Also apply the target-only checks' fixes |
| `--no-transaction` | Apply fix by fix, keeping what works |
| `--no-snapshot` | Read each query on its own. By default each database is read as of one instant, so a change made while the scan runs is seen by all of it or none; as with `pg_dump`, what it reads stays locked until the scan ends |
| `--json` / `--ci` | Machine-readable output; CI annotations and exit codes |

## Applying fixes safely
Expand Down Expand Up @@ -197,7 +198,9 @@ the output.
- **Targets.** Restore runs as one transaction and expects an empty database.
`--force` clears the snapshot's own schemas first (never Supabase's) and
puts back the triggers and policies outside them that depended on what it
cleared.
cleared. A restore or apply that looks likely to need more locks than the
target allows in one transaction says so before it starts;
`--no-transaction` commits as it goes instead.
- **Plain PostgreSQL.** Restoring into plain PostgreSQL works. What can't
exist there is skipped and named: a foreign key to `auth.users`, a webhook,
an extension the server doesn't ship, and everything that needs it.
Expand Down
55 changes: 47 additions & 8 deletions packages/cli/src/checks/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,25 @@ export class ExtensionsCheck extends Check {
}

async scan(ctx: CheckContext): Promise<DriftIssue[]> {
const [source, target] = await Promise.all([
const [source, target, reachable] = await Promise.all([
this.fetchExtensions(ctx.source.dbUrl),
this.fetchExtensions(ctx.target.dbUrl),
this.fetchUpdates(ctx.target.dbUrl),
])
const ignored = new Set(ctx.config.ignoreExtensions ?? [])
return diffExtensions(source, target.filter(e => !ignored.has(e.name) || source.some(s => s.name === e.name)))
return diffExtensions(source, target.filter(e => !ignored.has(e.name) || source.some(s => s.name === e.name)), reachable)
}

/** The versions each installed extension can be updated to on this server. */
private async fetchUpdates(dbUrl: string): Promise<Map<string, Set<string>> | undefined> {
try {
const rows = await this.queryFn(dbUrl, UPDATES_SQL) as unknown as Array<{ name: string; version: string }>
const out = new Map<string, Set<string>>()
for (const r of rows) out.set(r.name, (out.get(r.name) ?? new Set()).add(r.version))
return out
} catch {
return undefined
}
}

private async fetchExtensions(dbUrl: string): Promise<PgExtension[]> {
Expand All @@ -40,7 +53,20 @@ const EXTENSIONS_SQL = `
ORDER BY e.extname
`

export function diffExtensions(source: PgExtension[], target: PgExtension[]): DriftIssue[] {
const UPDATES_SQL = `
SELECT e.extname AS name, p.target AS version
FROM pg_extension e
CROSS JOIN LATERAL pg_extension_update_paths(e.extname) p
WHERE p.source = e.extversion AND p.path IS NOT NULL
`

/**
* `reachable`: the versions each of the target's extensions can be updated
* to there. A version mismatch gets its ALTER EXTENSION only when the
* source's is one of them: PostgreSQL has no way down a version, and a
* server may not ship the one asked for, and either failed the whole apply.
*/
export function diffExtensions(source: PgExtension[], target: PgExtension[], reachable?: Map<string, Set<string>>): DriftIssue[] {
const issues: DriftIssue[] = []
const sourceMap = new Map(source.map(e => [e.name, e]))
const targetMap = new Map(target.map(e => [e.name, e]))
Expand All @@ -56,7 +82,15 @@ export function diffExtensions(source: PgExtension[], target: PgExtension[]): Dr
description: `Extension "${name}" (v${ext.version}) is enabled in source (schema: ${ext.schema}) but not in target.`,
sourceValue: ext,
sql: {
up: `CREATE EXTENSION IF NOT EXISTS ${quoteName(name)}${ext.schema !== 'public' ? ` SCHEMA ${quoteName(ext.schema)}` : ''};`,
// Into the schema it is in on the source, which plain PostgreSQL
// may not have β€” Supabase's `extensions`. Made first; if the server
// does not ship the extension, the fix is left out with it. A
// system schema (pg_cron's pg_catalog) is always there, and cannot
// be made.
up: ext.schema === 'public'
? `CREATE EXTENSION IF NOT EXISTS ${quoteName(name)};`
: `${ext.schema.startsWith('pg_') ? '' : `CREATE SCHEMA IF NOT EXISTS ${quoteName(ext.schema)};\n`}`
+ `CREATE EXTENSION IF NOT EXISTS ${quoteName(name)} SCHEMA ${quoteName(ext.schema)};`,
down: `DROP EXTENSION IF EXISTS ${quoteName(name)};`,
},
})
Expand All @@ -83,6 +117,7 @@ export function diffExtensions(source: PgExtension[], target: PgExtension[]): Dr
if (!tgtExt) continue

if (srcExt.version !== tgtExt.version) {
const updatable = !reachable || reachable.get(name)?.has(srcExt.version)
issues.push({
id: `ext-version-${name}`,
check: 'extensions',
Expand All @@ -91,10 +126,14 @@ export function diffExtensions(source: PgExtension[], target: PgExtension[]): Dr
description: `Extension "${name}" is v${srcExt.version} in source but v${tgtExt.version} in target.`,
sourceValue: srcExt,
targetValue: tgtExt,
sql: {
up: `ALTER EXTENSION ${quoteName(name)} UPDATE TO ${quoteLiteral(srcExt.version)};`,
down: `ALTER EXTENSION ${quoteName(name)} UPDATE TO ${quoteLiteral(tgtExt.version)};`,
},
...(updatable
? {
sql: {
up: `ALTER EXTENSION ${quoteName(name)} UPDATE TO ${quoteLiteral(srcExt.version)};`,
down: `ALTER EXTENSION ${quoteName(name)} UPDATE TO ${quoteLiteral(tgtExt.version)};`,
},
}
: { manualOnly: `the target cannot go from v${tgtExt.version} to v${srcExt.version}: its server has no update path there (an extension cannot be moved down a version)` }),
})
}
}
Expand Down
56 changes: 48 additions & 8 deletions packages/cli/src/checks/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ interface PgRole {
* syntax; a sequence and a routine each need their own keyword, and a
* routine its argument types, since overloads share a name.
*/
type ObjectKind = 'table' | 'sequence' | 'function' | 'procedure'
type ObjectKind = 'table' | 'sequence' | 'function' | 'procedure' | 'schema'

interface RoleGrant {
grantee: string
table_schema: string
/** The object's name; for a routine, without its arguments. */
/** The object's name; for a routine, without its arguments; empty for a schema. */
table_name: string
privilege_type: string
is_grantable: boolean
Expand Down Expand Up @@ -135,6 +135,34 @@ const grantsSql = (ignoreSchemas: string[]) => `
ORDER BY 1, 2, 3, 4
`

/**
* Grants on schemas, read from `nspacl`: `USAGE` on a schema of its own is
* what lets `authenticated` reach the tables in it through the Data API at
* all. A schema an extension made is the extension's.
*/
const schemaGrantsSql = (ignoreSchemas: string[]) => `
SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee,
n.nspname AS table_schema, '' AS table_name,
a.privilege_type, a.is_grantable, 'schema' AS object_kind
FROM pg_namespace n
CROSS JOIN LATERAL aclexplode(n.nspacl) a
WHERE n.nspacl IS NOT NULL
AND a.grantee <> n.nspowner
AND n.nspname ${grantSchemaFilter(ignoreSchemas)}
AND n.nspname NOT LIKE 'pg\\_%' AND n.nspname <> 'information_schema'
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.classid = 'pg_namespace'::regclass AND d.objid = n.oid AND d.deptype = 'e')
AND (a.grantee = 0 OR pg_get_userbyid(a.grantee) NOT IN (${quoted(PLATFORM_ROLES)}))
AND (a.grantee = 0 OR pg_get_userbyid(a.grantee) NOT LIKE 'pg_%')
ORDER BY 1, 2, 4
`

/** Schemas, as objects whose grants go with them. */
const schemasSql = (ignoreSchemas: string[]) => `
SELECT nspname AS table_schema, '' AS table_name, 'schema' AS object_kind
FROM pg_namespace
WHERE nspname ${grantSchemaFilter(ignoreSchemas)}
`

/**
* Column-level grants, read from `attacl`.
*
Expand Down Expand Up @@ -241,6 +269,7 @@ type DefaultGrant = {

/** The object a grant or relation names, with its kind and a routine's arguments. */
function objectKey(o: { table_schema: string; table_name: string; object_kind?: ObjectKind; args?: string | null }): string {
if (o.object_kind === 'schema') return `schema:${o.table_schema}`
const routine = o.object_kind === 'function' || o.object_kind === 'procedure'
return `${routine ? 'routine' : o.object_kind ?? 'table'}:${o.table_schema}.${o.table_name}${routine ? `(${o.args ?? ''})` : ''}`
}
Expand Down Expand Up @@ -351,6 +380,7 @@ export class RolesCheck extends Check {
sourceRoles, targetRoles, sourceGrants, targetGrants, sourceColumns, targetColumns,
sourceVersion, targetVersion, sourceRelations, targetRelations, targetDefaults,
sourceRoutineGrants, targetRoutineGrants, sourceRoutines, targetRoutines,
sourceSchemaGrants, targetSchemaGrants, sourceSchemas,
] = await Promise.all([
this.queryFn(ctx.source.dbUrl, ROLES_SQL) as unknown as Promise<PgRole[]>,
this.queryFn(ctx.target.dbUrl, ROLES_SQL) as unknown as Promise<PgRole[]>,
Expand All @@ -367,16 +397,19 @@ export class RolesCheck extends Check {
this.queryFn(ctx.target.dbUrl, functionGrantsSql(ignore)) as unknown as Promise<RoleGrant[]>,
this.queryFn(ctx.source.dbUrl, routinesSql(ignore)) as unknown as Promise<Relation[]>,
this.queryFn(ctx.target.dbUrl, routinesSql(ignore)) as unknown as Promise<Relation[]>,
this.queryFn(ctx.source.dbUrl, schemaGrantsSql(ignore)) as unknown as Promise<RoleGrant[]>,
this.queryFn(ctx.target.dbUrl, schemaGrantsSql(ignore)) as unknown as Promise<RoleGrant[]>,
this.queryFn(ctx.source.dbUrl, schemasSql(ignore)) as unknown as Promise<Relation[]>,
])
const sourceObjects = [...(sourceRelations ?? []), ...(sourceRoutines ?? [])]
const sourceObjects = [...(sourceRelations ?? []), ...(sourceRoutines ?? []), ...(sourceSchemas ?? [])]
const targetObjects = [...(targetRelations ?? []), ...(targetRoutines ?? [])]
const sourceAll = [...(sourceGrants ?? []), ...(sourceRoutineGrants ?? [])]
// A routine or sequence only the target has is the schema check's finding,
// and its grants go with it. Every routine grants EXECUTE to PUBLIC unless
const sourceAll = [...(sourceGrants ?? []), ...(sourceRoutineGrants ?? []), ...(sourceSchemaGrants ?? [])]
// A routine, sequence or schema only the target has is the schema check's
// finding, and its grants go with it. Every routine grants EXECUTE to PUBLIC unless
// told otherwise, so each one present on the target alone was reported
// again as an extra grant.
const inSource = new Set(sourceObjects.map(objectKey))
const targetAll = [...(targetGrants ?? []), ...(targetRoutineGrants ?? [])]
const targetAll = [...(targetGrants ?? []), ...(targetRoutineGrants ?? []), ...(targetSchemaGrants ?? [])]
.filter(g => !g.object_kind || g.object_kind === 'table' || inSource.has(objectKey(g)))
const comparable = comparablePrivileges(sourceVersion, targetVersion)
const byDefault = grantedByDefault(targetObjects, targetDefaults ?? [])
Expand Down Expand Up @@ -500,6 +533,7 @@ function grantKey(g: RoleGrant): string {
function objectSql(g: RoleGrant): string {
const name = `${quoteName(g.table_schema)}.${quoteName(g.table_name)}`
switch (g.object_kind) {
case 'schema': return `SCHEMA ${quoteName(g.table_schema)}`
case 'sequence': return `SEQUENCE ${name}`
case 'function': return `FUNCTION ${name}(${g.args ?? ''})`
case 'procedure': return `PROCEDURE ${name}(${g.args ?? ''})`
Expand All @@ -511,6 +545,7 @@ function objectSql(g: RoleGrant): string {
function describeObject(g: RoleGrant): string {
const name = `${g.table_schema}.${g.table_name}`
switch (g.object_kind) {
case 'schema': return `schema ${g.table_schema}`
case 'sequence': return `sequence ${name}`
case 'function': return `function ${name}(${g.args ?? ''})`
case 'procedure': return `procedure ${name}(${g.args ?? ''})`
Expand Down Expand Up @@ -548,7 +583,12 @@ const revokeSql = (g: RoleGrant) => `REVOKE ${grantTarget(g)} FROM ${granteeSql(
*/
export function comparablePrivileges(sourceVersion: number, targetVersion: number): (g: RoleGrant) => boolean {
const both = Math.min(sourceVersion || Infinity, targetVersion || Infinity)
return g => g.privilege_type !== 'MAINTAIN' || both >= 170000
// PostgreSQL 15 stopped granting CREATE on `public` to everyone. Across that
// line the difference is each version's default, and copying the old one
// into a newer server would undo its hardening.
const acrossFifteen = Boolean(sourceVersion && targetVersion) && (sourceVersion < 150000) !== (targetVersion < 150000)
return g => (g.privilege_type !== 'MAINTAIN' || both >= 170000)
&& !(acrossFifteen && g.object_kind === 'schema' && g.table_schema === 'public' && g.grantee === 'PUBLIC' && g.privilege_type === 'CREATE')
}

/** Grants that differ the same way, on one table (or column) to one role, as one finding. */
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ export default class Diff extends BaseCommand {
description: 'With --apply, print the fixes in the order they would run without executing any of them',
default: false,
}),
'no-snapshot': Flags.boolean({
description: 'Read each query on its own, rather than each database as of one instant (which holds what it reads locked until the scan ends, as pg_dump does)',
default: false,
}),
'no-transaction': Flags.boolean({
description: 'With --apply, run each fix on its own instead of rolling the whole set back on the first failure',
aliases: ['continue-on-error'],
Expand Down Expand Up @@ -241,12 +245,13 @@ export default class Diff extends BaseCommand {
*/
private renderApplyResult(result: PromoteResult, dryRun: boolean): void {
const verb = dryRun ? 'Would apply' : 'Applied'
for (const warning of result.warnings ?? []) this.log(`${warn(warning)}\n`)
if (result.applied.length > 0) {
const heading = `${verb} ${result.applied.length} fix(es)${dryRun ? ', in this order' : ''}:`
this.log(dryRun ? bold(heading) : ok(heading))
result.applied.forEach((stmt, i) => {
const glyph = dryRun ? dim(`${i + 1}.`) : ok('βœ“')
this.log(` ${glyph} ${dim(`[${stmt.check}]`)} ${stmt.issueId}`)
this.log(` ${glyph} ${dim(`[${stmt.check}]`)} ${stmt.issueId}${stmt.note ? dim(` β€” ${stmt.note}`) : ''}`)
// The SQL itself only in a dry run: it is the thing being reviewed,
// and printing it after the fact would just repeat --detail.
if (dryRun && stmt.sql) this.log(` ${dim(stmt.sql.replace(/\s*\n\s*/g, ' '))}`)
Expand Down Expand Up @@ -395,7 +400,7 @@ export default class Diff extends BaseCommand {
// the flag that performs one (issue #71).
if (flags.apply || flags['dry-run']) {
const onProgress = makeProgress()
const scanResult = await scan(registry, { config, checks, skip, tableFilter, onProgress, onDetail: makeDetail() })
const scanResult = await scan(registry, { config, checks, skip, tableFilter, onProgress, onDetail: makeDetail(), snapshot: !flags['no-snapshot'] })
this.setCheckSummaries(scanResult.checks.map(c => ({
check: c.check,
status: c.status,
Expand Down Expand Up @@ -522,6 +527,7 @@ export default class Diff extends BaseCommand {

const result = await promote({
dbUrl: targetEnv.dbUrl,
sourceUrl: config.environments[config.source!]?.dbUrl,
scanResult,
checks,
dryRun,
Expand Down Expand Up @@ -558,7 +564,7 @@ export default class Diff extends BaseCommand {

// ── Scan mode (summary, detail, CI, or JSON) ────────────────────────────
const onProgress = makeProgress()
const result = await scan(registry, { config, checks, skip, tableFilter, onProgress, onDetail: makeDetail() })
const result = await scan(registry, { config, checks, skip, tableFilter, onProgress, onDetail: makeDetail(), snapshot: !flags['no-snapshot'] })
this.setCheckSummaries(result.checks.map(c => ({
check: c.check,
status: c.status,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ export default class Restore extends BaseCommand {
return
}

for (const warning of result.warnings ?? []) this.log(`⚠ ${warning}\n`)

if (result.applied.length > 0) {
this.log(`βœ… Applied ${result.applied.length} operation(s):`)
for (const op of result.applied.slice(0, 20)) {
Expand Down
Loading
Loading