@everystack/cli 0.4.69 → 0.4.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -1
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +133 -9
- package/src/cli/apply-lane.ts +102 -0
- package/src/cli/apply-refusals.ts +83 -0
- package/src/cli/backup-resolve.ts +31 -0
- package/src/cli/backup.ts +104 -0
- package/src/cli/commands/db-apply.ts +318 -180
- package/src/cli/commands/db-plan.ts +21 -10
- package/src/cli/commands/db-reconcile.ts +2 -0
- package/src/cli/commands/db-swap.ts +27 -54
- package/src/cli/commands/db.ts +142 -2
- package/src/cli/derived-apply.ts +21 -3
- package/src/cli/edge-plan.ts +14 -0
- package/src/cli/git-descent.ts +526 -143
- package/src/cli/index.ts +1 -1
- package/src/cli/local-rollback.ts +146 -0
- package/src/cli/prompt.ts +18 -0
- package/src/cli/rollback-point.ts +141 -0
- package/src/cli/schema-fingerprint.ts +60 -0
- package/src/cli/stage-rollback-deps.ts +70 -0
- package/src/cli/state-apply.ts +41 -3
- package/src/cli/swap-snapshot.ts +18 -1
|
@@ -50,7 +50,8 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
|
50
50
|
import { readStageStateTwice } from '../stage-read-consistency';
|
|
51
51
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
52
52
|
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan';
|
|
53
|
-
import {
|
|
53
|
+
import { proveDescent } from '../git-descent';
|
|
54
|
+
import { readDescentLedger } from '../apply-execute';
|
|
54
55
|
import { planBackfills, readBackfillLog } from '../backfill';
|
|
55
56
|
import { readSqlDirIfPresent } from './db-sync';
|
|
56
57
|
import { currentGitRef } from '../state-apply';
|
|
@@ -277,25 +278,35 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
277
278
|
// The fast-forward rule, warn-only at mint time (db:apply enforces it):
|
|
278
279
|
// a stale checkout mints a legal-looking plan whose edge REVERTS merged
|
|
279
280
|
// work — say so on the review surface, where a human still reads it.
|
|
281
|
+
//
|
|
282
|
+
// Ledger-first, SYMMETRIC with db:apply — it must warn about exactly what apply will do.
|
|
283
|
+
// The old whole-history recompile warned "rebase first, will refuse" for
|
|
284
|
+
// the very strand apply now PROVES via the ledger and applies, telling the operator the
|
|
285
|
+
// opposite of what happens. proveDescent over the same ledger apply reads keeps them agreed.
|
|
280
286
|
if (plan.statements.length > 0) {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// verdict cannot be forgotten.
|
|
287
|
+
const ledger = await readDescentLedger(session);
|
|
288
|
+
const verdict = await proveDescent(modelsPath, { snapshot, contract }, ledger, {});
|
|
289
|
+
// A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
|
|
290
|
+
// did not name, so a new one would mint a plan with no warning and then be refused at
|
|
291
|
+
// apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
|
|
286
292
|
switch (verdict.status) {
|
|
287
|
-
case '
|
|
293
|
+
case 'proven':
|
|
288
294
|
case 'fresh-target':
|
|
289
295
|
case 'no-git':
|
|
290
296
|
break;
|
|
291
|
-
case '
|
|
297
|
+
case 'revert':
|
|
292
298
|
case 'drift':
|
|
293
299
|
warn(`descent: ${verdict.reason}`);
|
|
294
300
|
warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
|
|
295
301
|
break;
|
|
296
|
-
case '
|
|
302
|
+
case 'unknown-commit':
|
|
303
|
+
// NOT "rebase first" — the checkout is fine, it is just missing the deploying commit.
|
|
304
|
+
warn(`descent: ${verdict.reason}`);
|
|
305
|
+
warn('db:apply from this checkout will refuse this plan until you fetch that commit — fetch, then re-mint.');
|
|
306
|
+
break;
|
|
307
|
+
case 'unverifiable':
|
|
297
308
|
// NOT "rebase first" — nothing is wrong with the checkout or the database. The
|
|
298
|
-
// search
|
|
309
|
+
// search could not read an ancestor state, so the rule is unproven either way.
|
|
299
310
|
warn(`descent: ${verdict.reason}`);
|
|
300
311
|
warn('db:apply from this checkout will refuse this plan until that search completes — re-mint and try again.');
|
|
301
312
|
break;
|
|
@@ -647,6 +647,8 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
647
647
|
info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply credential-free over the direct lane: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
|
|
648
648
|
} else if (/timed out|timeout|task timed out/i.test(String(result.error))) {
|
|
649
649
|
info('A large derived rebuild (dozens of objects) can exceed the ops-Lambda 900-second clock. Re-run credential-free with an unbounded clock: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct.');
|
|
650
|
+
} else if (/must be owner of/i.test(String(result.error))) {
|
|
651
|
+
info('This is a brownfield object owned by the master (the old tool created it), which the least-privilege operator cannot replace. Run `everystack db:provision --stage ' + (flags.stage ?? '<stage>') + '` first: it ADOPTS pre-existing objects to their declared owners (--confirm for SECURITY DEFINER), so the operator can then manage them. No hand-written ALTER … OWNER.');
|
|
650
652
|
}
|
|
651
653
|
process.exit(1);
|
|
652
654
|
}
|
|
@@ -40,12 +40,11 @@ import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-r
|
|
|
40
40
|
import { resolveOperatorUrlViaStage } from '../direct-venue';
|
|
41
41
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease';
|
|
42
42
|
import { resolveConfig, opsFunction, type CliConfig } from '../config';
|
|
43
|
-
import { invokeAction, presignGet
|
|
44
|
-
import { keyForArtifactId, metaKey
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import { runStageBackup, stageBackupDeps } from '../stage-backup';
|
|
43
|
+
import { invokeAction, presignGet } from '../aws';
|
|
44
|
+
import { keyForArtifactId, metaKey } from '../backup';
|
|
45
|
+
import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
|
|
46
|
+
import { confirmRollbackPoint } from '../rollback-point';
|
|
47
|
+
import { stageConfirmDeps } from '../stage-rollback-deps';
|
|
49
48
|
import { pgEnvFromUrl, pgKeepaliveConninfo } from './db';
|
|
50
49
|
import { step, success, fail, warn, info } from '../output';
|
|
51
50
|
|
|
@@ -448,58 +447,32 @@ async function takePreSwapSnapshot(
|
|
|
448
447
|
plan: Exclude<ReturnType<typeof decideSnapshotMode>, { mode: 'refuse' }>,
|
|
449
448
|
ctx: { stage?: string; region?: string; opsFn?: string },
|
|
450
449
|
): Promise<void> {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
step(`Snapshotting the instance before the swap (RDS physical snapshot of ${plan.instanceId})...`);
|
|
463
|
-
const snapshotId = rdsSnapshotIdentifier(`${ctx.stage ?? 'swap'}-swap`, utcStamp(new Date()));
|
|
464
|
-
const region = ctx.region!;
|
|
465
|
-
const { id } = await confirmPhysicalSnapshot({
|
|
466
|
-
create: (sid) => createRdsSnapshot(region, plan.instanceId, sid),
|
|
467
|
-
describe: () => describeRdsSnapshots(region, plan.instanceId),
|
|
450
|
+
// The confirm ceremony is now shared with db:apply (rollback-point.ts) — one definition of
|
|
451
|
+
// "no destructive statement before a durable rollback point", two verbs. Swap keeps only its
|
|
452
|
+
// OWN wording: the restore-from advice, and the "swap was NOT applied, live is untouched"
|
|
453
|
+
// framing on a refusal.
|
|
454
|
+
const result = await confirmRollbackPoint(plan, {
|
|
455
|
+
...stageConfirmDeps({
|
|
456
|
+
region: ctx.region!,
|
|
457
|
+
instanceId: plan.mode === 'physical' ? plan.instanceId : undefined,
|
|
458
|
+
opsFn: ctx.opsFn!,
|
|
459
|
+
stage: ctx.stage,
|
|
460
|
+
verb: 'swap',
|
|
468
461
|
log: (m) => info(m),
|
|
469
|
-
|
|
470
|
-
now: () => Date.now(),
|
|
471
|
-
}, { instanceId: plan.instanceId, snapshotId });
|
|
472
|
-
info(`rollback point CONFIRMED: RDS snapshot ${id} is available — restore the instance from it if this swap goes wrong.`);
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Logical: dispatch the Task and WAIT. The dispatch returning is not the backup existing — that
|
|
477
|
-
// conflation is what let a pg_dump run concurrently with the restore it was supposed to precede.
|
|
478
|
-
step('Snapshotting the stage before the swap (db:backup — waiting for the dump to finish)...');
|
|
479
|
-
// The shared sandwich. A rollback point taken across a concurrent migration holds a schema that
|
|
480
|
-
// matches neither endpoint — restoring from it would not return the stage to where it was, which
|
|
481
|
-
// is the one thing a pre-swap snapshot has to guarantee. An unproven artifact fails the swap.
|
|
482
|
-
const backup = await runStageBackup({
|
|
483
|
-
...stageBackupDeps({ region: ctx.region! }, ctx.opsFn!, ctx.stage, {
|
|
484
|
-
onStep: step,
|
|
485
|
-
onDispatch: (d: { id: string; runId: string }) => info(`backup ${d.id} dispatched (run ${d.runId}) — waiting for the task to stop before the restore starts.`),
|
|
462
|
+
step,
|
|
486
463
|
}),
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
// different advice. The shared helper takes injected deps for exactly this.
|
|
490
|
-
awaitTask: async (ids) => {
|
|
491
|
-
const poll = await pollTaskUntilStopped(ctx.region!, ctx.opsFn!, { runId: ids.runId, taskArn: ids.taskArn });
|
|
492
|
-
const verdict = interpretBackupPoll(poll, { runId: ids.runId, id: ids.id });
|
|
493
|
-
if (!verdict.ok) return verdict;
|
|
494
|
-
// Carry the stamp through, or this caller silently opts out of the image-skew check.
|
|
495
|
-
return { ok: true, stampedFingerprint: (poll as any).status?.result?.fingerprint as string | undefined };
|
|
496
|
-
},
|
|
464
|
+
log: (m) => info(m),
|
|
465
|
+
warn: (m) => warn(m),
|
|
497
466
|
});
|
|
498
|
-
|
|
499
|
-
|
|
467
|
+
|
|
468
|
+
if (!result.ok) {
|
|
469
|
+
throw new Error(`the pre-swap rollback point could not be confirmed, so the swap was NOT applied and live is untouched: ${result.reason}`);
|
|
500
470
|
}
|
|
501
|
-
|
|
502
|
-
|
|
471
|
+
// Swap-specific restore advice on top of the shared confirmation.
|
|
472
|
+
const p = result.point;
|
|
473
|
+
if (p.mode === 'physical') info(`restore the instance from RDS snapshot ${p.ref} if this swap goes wrong.`);
|
|
474
|
+
else if (p.mode === 'logical') info(`restore with db:restore --from ${p.ref} --confirm if this swap goes wrong.`);
|
|
475
|
+
else if (p.mode === 'none') warn('the retiring schema is dropped once verify passes — there is no rollback point.');
|
|
503
476
|
}
|
|
504
477
|
|
|
505
478
|
export async function dbSwapCommand(flags: Record<string, string>): Promise<void> {
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -304,14 +304,35 @@ export function provisionPreservedIsRecoverable(args: {
|
|
|
304
304
|
* same seam as pipeline-loader). */
|
|
305
305
|
export interface ServerProvision {
|
|
306
306
|
runProvision(
|
|
307
|
-
payload: {
|
|
307
|
+
payload: {
|
|
308
|
+
authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean;
|
|
309
|
+
declaredOwners?: Array<{ owner: string; schema: string }>;
|
|
310
|
+
declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
|
|
311
|
+
confirm?: boolean;
|
|
312
|
+
},
|
|
308
313
|
deps: {
|
|
309
314
|
execute: (sql: string) => Promise<unknown>;
|
|
310
315
|
connection?: { host: string; port?: number | string; database: string } | null;
|
|
311
316
|
connectProbe?: (url: string) => Promise<void>;
|
|
312
317
|
roleHasLogin?: (role: string) => Promise<boolean>;
|
|
318
|
+
query?: (sql: string) => Promise<any[]>;
|
|
319
|
+
master?: { execute: (sql: string) => Promise<unknown>; query: (sql: string) => Promise<any[]> } | null;
|
|
313
320
|
},
|
|
314
321
|
): Promise<any>;
|
|
322
|
+
/** ENACT + ADOPT, run SEPARATELY and AFTER the secret is written (the lockout-safe ordering). */
|
|
323
|
+
convergeOwnership(
|
|
324
|
+
payload: {
|
|
325
|
+
declaredOwners?: Array<{ owner: string; schema: string }>;
|
|
326
|
+
declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
|
|
327
|
+
confirm?: boolean;
|
|
328
|
+
adminRole?: string;
|
|
329
|
+
},
|
|
330
|
+
deps: {
|
|
331
|
+
execute: (sql: string) => Promise<unknown>;
|
|
332
|
+
query?: (sql: string) => Promise<any[]>;
|
|
333
|
+
master?: { execute: (sql: string) => Promise<unknown>; query: (sql: string) => Promise<any[]> } | null;
|
|
334
|
+
},
|
|
335
|
+
): Promise<{ ownerEnactment?: { enacted: number; refusals: string[] }; ownerAdoption?: { reassigned: number; manifest: Array<{ identity: string; kind: string; secdef: boolean; from: string; to: string }>; findings: Array<{ severity: string; identity: string; detail: string }>; refusals: string[] } }>;
|
|
315
336
|
}
|
|
316
337
|
|
|
317
338
|
/**
|
|
@@ -450,6 +471,39 @@ export function declaredSearchPath(schemas: string[]): string[] {
|
|
|
450
471
|
return nonPublic.length ? [...nonPublic, 'public'] : [];
|
|
451
472
|
}
|
|
452
473
|
|
|
474
|
+
/** Kinds ADOPT can reassign — triggers have no owner, 'sql' escape-hatch objects are opaque. */
|
|
475
|
+
const ADOPTABLE_KINDS = new Set(['function', 'view', 'materialized view']);
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Read the declared ownership out of the compiled derived source: the unique (owner, schema) pairs
|
|
479
|
+
* to ENACT, and the function/view/matview objects to ADOPT. `secdef` is read from the object's own
|
|
480
|
+
* CREATE (the descriptor compiler emits SECURITY DEFINER into it), so the confirm gate sees which
|
|
481
|
+
* reassignments change runs-as. Objects without a declared owner are left alone (reconcile creates
|
|
482
|
+
* them owned by the operator, no convergence needed).
|
|
483
|
+
*/
|
|
484
|
+
export function extractDeclaredOwnership(objects: Array<{ kind: string; schema: string; name: string; identity: string; sql: string; owner?: string }>): {
|
|
485
|
+
declaredOwners: Array<{ owner: string; schema: string }>;
|
|
486
|
+
declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
|
|
487
|
+
} {
|
|
488
|
+
const seen = new Set<string>();
|
|
489
|
+
const declaredOwners: Array<{ owner: string; schema: string }> = [];
|
|
490
|
+
const declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
|
|
491
|
+
for (const o of objects) {
|
|
492
|
+
if (!o.owner) continue;
|
|
493
|
+
const key = `${o.owner}\t${o.schema}`;
|
|
494
|
+
if (!seen.has(key)) { seen.add(key); declaredOwners.push({ owner: o.owner, schema: o.schema }); }
|
|
495
|
+
if (ADOPTABLE_KINDS.has(o.kind)) {
|
|
496
|
+
declaredObjects.push({
|
|
497
|
+
identity: o.identity, schema: o.schema, name: o.name,
|
|
498
|
+
kind: o.kind as 'function' | 'view' | 'materialized view',
|
|
499
|
+
secdef: /SECURITY\s+DEFINER/i.test(o.sql),
|
|
500
|
+
currentOwner: '', declaredOwner: o.owner,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return { declaredOwners, declaredObjects };
|
|
505
|
+
}
|
|
506
|
+
|
|
453
507
|
/**
|
|
454
508
|
* Every schema the API connection must reach — the union of the TABLE schemas (the models
|
|
455
509
|
* barrel) and the DERIVED-object schemas (views/matviews declared via
|
|
@@ -838,6 +892,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
838
892
|
// authenticator flip doesn't go dark on bare-ref matview reads. Best-effort: a modelless or
|
|
839
893
|
// all-public app sets no path (unchanged behavior).
|
|
840
894
|
let declaredSchemas: string[] = [];
|
|
895
|
+
// Declared ownership (brownfield convergence) — the (owner, schema) pairs to ENACT and the
|
|
896
|
+
// function/view/matview objects to ADOPT, read from the same barrel as the schema set.
|
|
897
|
+
let declaredOwners: Array<{ owner: string; schema: string }> = [];
|
|
898
|
+
let declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
|
|
841
899
|
try {
|
|
842
900
|
const { resolveModelsPath } = await import('../models-path');
|
|
843
901
|
const { loadModels } = await import('./db-generate');
|
|
@@ -846,7 +904,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
846
904
|
try {
|
|
847
905
|
const { loadDeclaredDerived } = await import('../declared-derived');
|
|
848
906
|
const declared = await loadDeclaredDerived(flags.models);
|
|
849
|
-
if (declared)
|
|
907
|
+
if (declared) {
|
|
908
|
+
derivedObjects = declared.objects;
|
|
909
|
+
({ declaredOwners, declaredObjects } = extractDeclaredOwnership(declared.objects));
|
|
910
|
+
}
|
|
850
911
|
} catch {
|
|
851
912
|
// No derived layer resolvable — the table schemas still resolve correctly.
|
|
852
913
|
}
|
|
@@ -856,6 +917,8 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
856
917
|
}
|
|
857
918
|
const searchPath = declaredSearchPath(declaredSchemas);
|
|
858
919
|
if (searchPath.length) info(` Declared search_path (set as the role default via ALTER ROLE): ${searchPath.join(', ')}`);
|
|
920
|
+
const confirm = !!flags.confirm;
|
|
921
|
+
const ownershipPayload = { declaredOwners, declaredObjects, confirm };
|
|
859
922
|
|
|
860
923
|
// Generate BOTH login passwords and keep them in memory only — they are written straight
|
|
861
924
|
// into the secret store and are NEVER printed, logged, or returned to a human.
|
|
@@ -870,6 +933,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
870
933
|
const { createUrlRunner } = await import('../db-source');
|
|
871
934
|
const { runner, end } = await createUrlRunner(url);
|
|
872
935
|
try {
|
|
936
|
+
// Credentials ONLY — ownership convergence runs separately, AFTER the secret is written.
|
|
873
937
|
return await runProvision({ authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] }, {
|
|
874
938
|
execute: async (statement) => runner(statement),
|
|
875
939
|
connection: parseUrlConnection(url),
|
|
@@ -950,6 +1014,61 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
950
1014
|
success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${adminMsg}`);
|
|
951
1015
|
}
|
|
952
1016
|
|
|
1017
|
+
// Ownership convergence runs AFTER the secret write (below) — never before. Declared here so the
|
|
1018
|
+
// final exit status can see it.
|
|
1019
|
+
let ownershipIncomplete = false;
|
|
1020
|
+
|
|
1021
|
+
// ENACT + ADOPT, factored out because it must run on BOTH exit paths: the normal one (after the
|
|
1022
|
+
// secret write) AND the additive-only NO-OP path (credentials unchanged, so no secret is written —
|
|
1023
|
+
// but the owners in the model may have changed and still need converging). Running it only on the
|
|
1024
|
+
// normal path made the whole feature a no-op on a re-provision, the common brownfield case: the
|
|
1025
|
+
// stage is already provisioned, the operator adds `owner:` declarations (or re-runs the
|
|
1026
|
+
// review-then-`--confirm` flow), and nothing converged. Credentials are already saved on both
|
|
1027
|
+
// paths by the time this runs, so it can strand nothing.
|
|
1028
|
+
const runConvergence = async (): Promise<void> => {
|
|
1029
|
+
if (!declaredOwners.length && !declaredObjects.length) return;
|
|
1030
|
+
const ownerPayload = { ...ownershipPayload, adminRole: result.adminRole };
|
|
1031
|
+
let owned: any = {};
|
|
1032
|
+
try {
|
|
1033
|
+
if (directUrl) {
|
|
1034
|
+
// The direct venue is ONE privileged connection = operator read AND master; ADOPT's live
|
|
1035
|
+
// master_can_act probe gates it (an under-privileged direct connection refuses precisely).
|
|
1036
|
+
const { convergeOwnership } = await loadServerProvision();
|
|
1037
|
+
const { createUrlRunner } = await import('../db-source');
|
|
1038
|
+
const { runner, end } = await createUrlRunner(directUrl);
|
|
1039
|
+
try {
|
|
1040
|
+
owned = await convergeOwnership(ownerPayload, {
|
|
1041
|
+
execute: async (s) => runner(s),
|
|
1042
|
+
query: async (s) => (await runner(s)) as any[],
|
|
1043
|
+
master: { execute: async (s) => runner(s), query: async (s) => (await runner(s)) as any[] },
|
|
1044
|
+
});
|
|
1045
|
+
} finally {
|
|
1046
|
+
await end?.();
|
|
1047
|
+
}
|
|
1048
|
+
} else {
|
|
1049
|
+
owned = await invokeAction(config.region, opsFunction(config), 'db:converge-ownership', ownerPayload);
|
|
1050
|
+
}
|
|
1051
|
+
} catch (err: any) {
|
|
1052
|
+
warn(`Ownership convergence failed (credentials are already saved — nothing stranded): ${err.message}`);
|
|
1053
|
+
ownershipIncomplete = true;
|
|
1054
|
+
}
|
|
1055
|
+
const enact = owned.ownerEnactment;
|
|
1056
|
+
if (enact) {
|
|
1057
|
+
if (enact.enacted) success(`Owner enactment: ${enact.enacted} grant(s) applied.`);
|
|
1058
|
+
for (const r of enact.refusals) { warn(`Owner enactment: ${r}`); ownershipIncomplete = true; }
|
|
1059
|
+
}
|
|
1060
|
+
const adopt = owned.ownerAdoption;
|
|
1061
|
+
if (adopt) {
|
|
1062
|
+
if (adopt.manifest?.length) {
|
|
1063
|
+
info('Ownership adoption — objects reassigned:');
|
|
1064
|
+
for (const m of adopt.manifest) info(` ${m.identity} [${m.kind}${m.secdef ? ', SECURITY DEFINER' : ''}] ${m.from} → ${m.to}`);
|
|
1065
|
+
}
|
|
1066
|
+
for (const f of adopt.findings ?? []) warn(` audit (${f.severity}): ${f.identity} — ${f.detail}`);
|
|
1067
|
+
if (adopt.reassigned) success(`Ownership adoption: ${adopt.reassigned} object(s) reassigned (one transaction).`);
|
|
1068
|
+
for (const r of adopt.refusals ?? []) { warn(`Ownership adoption: ${r}`); ownershipIncomplete = true; }
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
|
|
953
1072
|
// Additive-only: if the login roles already existed as live logins, provision changed no
|
|
954
1073
|
// credential — so there is nothing to write and no host to resolve. Never touch the secret
|
|
955
1074
|
// store on a no-op re-provision (the footgun that reset a live password then locked the stage out).
|
|
@@ -966,6 +1085,13 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
966
1085
|
});
|
|
967
1086
|
if (recoverable.ok) {
|
|
968
1087
|
success(`Roles already provisioned — passwords and secrets left unchanged. Verify the live credential with \`everystack db:doctor --stage ${flags.stage}\`; run \`everystack db:provision --rotate\` to change credentials.`);
|
|
1088
|
+
// Credentials are a no-op, but declared ownership may still need converging — this is the
|
|
1089
|
+
// re-provision path (add owners, or the review-then---confirm retry). Run it, then exit.
|
|
1090
|
+
await runConvergence();
|
|
1091
|
+
if (ownershipIncomplete) {
|
|
1092
|
+
warn('Ownership was not fully converged — see the refusals above. db:reconcile will refuse until they are resolved.');
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
969
1095
|
return;
|
|
970
1096
|
}
|
|
971
1097
|
fail(recoverable.reason);
|
|
@@ -1028,6 +1154,12 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1028
1154
|
success(`Secrets written for stage "${flags.stage}". No credential was displayed.`);
|
|
1029
1155
|
for (const note of plan.notes) info(` ${note}`);
|
|
1030
1156
|
for (const warning of plan.warnings) info(` ⚠ ${warning}`);
|
|
1157
|
+
|
|
1158
|
+
// OWNERSHIP CONVERGENCE — strictly AFTER the secret write. The credential is now safely in the
|
|
1159
|
+
// store, so a stall or a hard kill in ENACT/ADOPT can strand nothing: the worst case is an
|
|
1160
|
+
// un-converged owner, exactly the state db:reconcile refuses on (and we exit non-zero for it).
|
|
1161
|
+
await runConvergence();
|
|
1162
|
+
|
|
1031
1163
|
console.log('');
|
|
1032
1164
|
info('Finish the wiring in sst.config.ts (one-time):');
|
|
1033
1165
|
info(' API — PRECONDITION: your handler must escalate per request (pgSettings + SET ROLE /');
|
|
@@ -1044,6 +1176,14 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1044
1176
|
info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
|
|
1045
1177
|
}
|
|
1046
1178
|
info(`Verify: everystack db:doctor --stage ${flags.stage}`);
|
|
1179
|
+
|
|
1180
|
+
// Credentials are done; ownership was left un-converged. Exit non-zero so CI and the operator act
|
|
1181
|
+
// on the refusals above (run the named SQL as master, or re-run with --confirm). db:reconcile
|
|
1182
|
+
// would refuse on exactly this state, so this is a real, actionable failure — not a warning to skip.
|
|
1183
|
+
if (ownershipIncomplete) {
|
|
1184
|
+
warn('Ownership was not fully converged — see the refusals above. db:reconcile will refuse until they are resolved.');
|
|
1185
|
+
process.exit(1);
|
|
1186
|
+
}
|
|
1047
1187
|
}
|
|
1048
1188
|
|
|
1049
1189
|
/** Render a doctor report — identical output whichever venue produced it. */
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -59,6 +59,15 @@ export const ENSURE_RECONCILER_SQL: string[] = [
|
|
|
59
59
|
duration_ms integer,
|
|
60
60
|
outcome text NOT NULL
|
|
61
61
|
)`,
|
|
62
|
+
// The descent witness columns (apply-legibility Phase 1). Idempotent — legacy databases
|
|
63
|
+
// upgrade in place; their old rows read fp_version = NULL (descent skips them) and
|
|
64
|
+
// dirty = false (harmless, since a NULL fp_version already skips them).
|
|
65
|
+
// - fp_version: the FINGERPRINT_VERSION a row's to_fingerprint was computed under, so the
|
|
66
|
+
// fast-forward proof can recompute the live side under the ROW'S canon across a bump.
|
|
67
|
+
// - dirty: the apply ran from a worktree whose models differed from HEAD, so its git_ref
|
|
68
|
+
// does not declare what was applied — descent must ignore the row (like git_ref = NULL).
|
|
69
|
+
`ALTER TABLE everystack.schema_log ADD COLUMN IF NOT EXISTS fp_version integer`,
|
|
70
|
+
`ALTER TABLE everystack.schema_log ADD COLUMN IF NOT EXISTS dirty boolean NOT NULL DEFAULT false`,
|
|
62
71
|
];
|
|
63
72
|
|
|
64
73
|
export interface RenderedReconcile {
|
|
@@ -399,7 +408,10 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
399
408
|
if (!missing.size && !unassumable.size && !noCreate.length) return null;
|
|
400
409
|
|
|
401
410
|
const lines: string[] = [
|
|
402
|
-
`declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied
|
|
411
|
+
`declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied.`,
|
|
412
|
+
`The one-command fix is \`everystack db:provision\`: it ENACTS these grants (and ADOPTS any`,
|
|
413
|
+
`pre-existing objects owned by the master) from the declared model, so you never hand-write them.`,
|
|
414
|
+
`The exact statements, if you must run them by hand:`,
|
|
403
415
|
];
|
|
404
416
|
for (const owner of [...missing].sort()) {
|
|
405
417
|
lines.push(
|
|
@@ -555,12 +567,18 @@ export interface SchemaLogEntry {
|
|
|
555
567
|
durationMs?: number;
|
|
556
568
|
fromFingerprint?: string | null;
|
|
557
569
|
toFingerprint?: string | null;
|
|
570
|
+
/** The FINGERPRINT_VERSION the fingerprints were computed under — the descent witness's canon. */
|
|
571
|
+
fpVersion?: number | null;
|
|
572
|
+
/** The apply ran from a dirty worktree (models differed from HEAD) — descent ignores such a row. */
|
|
573
|
+
dirty?: boolean;
|
|
558
574
|
}
|
|
559
575
|
|
|
560
576
|
export function renderSchemaLogInsert(entry: SchemaLogEntry): string {
|
|
561
577
|
const duration = entry.durationMs === undefined ? 'NULL' : String(Math.round(entry.durationMs));
|
|
562
|
-
|
|
563
|
-
|
|
578
|
+
const fpVersion = entry.fpVersion == null ? 'NULL' : String(Math.floor(entry.fpVersion));
|
|
579
|
+
const dirty = entry.dirty ? 'true' : 'false';
|
|
580
|
+
return `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, from_fingerprint, to_fingerprint, sql, duration_ms, outcome, fp_version, dirty)
|
|
581
|
+
VALUES (${escapeLiteral(entry.kind)}, ${nullable(entry.actor)}, ${nullable(entry.gitRef)}, ${nullable(entry.planRef)}, ${nullable(entry.fromFingerprint)}, ${nullable(entry.toFingerprint)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)}, ${fpVersion}, ${dirty})
|
|
564
582
|
RETURNING id`;
|
|
565
583
|
}
|
|
566
584
|
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -37,6 +37,20 @@ import { compileTableRenames, compileTableMoves } from './schema-compile';
|
|
|
37
37
|
|
|
38
38
|
export const PLAN_VERSION = 2;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The db:apply WIRE CONTRACT between the CLI and the ops Lambda — distinct from PLAN_VERSION,
|
|
42
|
+
* which versions the plan ARTIFACT. This versions the invoke PAYLOAD (its keys) and the ops
|
|
43
|
+
* Lambda's enforcement of them: the descent/authority/snapshot verdicts the CLI computes and the
|
|
44
|
+
* gates the server runs from them. PLAN_VERSION can hold while this moves — a payload key added or
|
|
45
|
+
* a hook's meaning changed leaves the plan file identical but breaks a mismatched peer silently.
|
|
46
|
+
*
|
|
47
|
+
* BUMP RULE: increment on ANY change to the db:apply payload keys, the set of gates the server
|
|
48
|
+
* runs, or a gate's semantics. Both packages import THIS binding (the server from the cli it
|
|
49
|
+
* bundles), so a version skew is a hard, exact-equality refusal in both directions — never a
|
|
50
|
+
* silently dropped gate.
|
|
51
|
+
*/
|
|
52
|
+
export const APPLY_CONTRACT_VERSION = 1;
|
|
53
|
+
|
|
40
54
|
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
|
|
41
55
|
export interface PlanClassification {
|
|
42
56
|
/** Statements that positively ADD. Counted from a matcher, never `total - destructive` —
|