@everystack/cli 0.3.22 → 0.3.25
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/README.md +5 -1
- package/package.json +9 -4
- package/src/cli/apply-authority.ts +150 -0
- package/src/cli/backfill.ts +231 -0
- package/src/cli/branch-db.ts +269 -0
- package/src/cli/commands/db-apply.ts +117 -2
- package/src/cli/commands/db-approvers.ts +71 -0
- package/src/cli/commands/db-backfill.ts +127 -0
- package/src/cli/commands/db-branch.ts +220 -0
- package/src/cli/commands/db-check.ts +16 -58
- package/src/cli/commands/db-plan.ts +21 -0
- package/src/cli/commands/db-sync.ts +26 -0
- package/src/cli/db-build.ts +161 -0
- package/src/cli/derived-source.ts +3 -2
- package/src/cli/edge-plan.ts +35 -8
- package/src/cli/index.ts +20 -1
- package/src/cli/model-api.ts +7 -0
- package/src/cli/schema-source.ts +5 -0
- package/src/cli/state-apply.ts +30 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branch-db — per-branch dev databases from a models-built template
|
|
3
|
+
* (brick 5-remainder of the migration-authority design, decision 14).
|
|
4
|
+
*
|
|
5
|
+
* The original wound: one shared dev database, poisoned by every branch
|
|
6
|
+
* switch. The cure: the dev template `<base>_tpl` is built FROM THE
|
|
7
|
+
* DECLARED STATE plus seed-as-code — deterministic, PII-free, refreshed by
|
|
8
|
+
* an explicit `db:template:refresh`, never a data copy (real data enters
|
|
9
|
+
* branch testing only through stage forks, brick 10) — and each branch
|
|
10
|
+
* gets its own database, minted `CREATE DATABASE … TEMPLATE`, keyed on the
|
|
11
|
+
* git branch. `db:sync` then evolves it with the checkout. Branch
|
|
12
|
+
* databases are disposable by construction: never long-lived, never
|
|
13
|
+
* poisoned.
|
|
14
|
+
*
|
|
15
|
+
* Names are deterministic lowercase identifiers; branches that clean to
|
|
16
|
+
* the same text (`feat/foo` vs `feat_foo`) get a disambiguating content
|
|
17
|
+
* hash. The raw branch name travels as a database COMMENT, so listing and
|
|
18
|
+
* pruning map databases back to branches without guessing — a database
|
|
19
|
+
* whose comment is gone is never pruned.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { execSync } from 'node:child_process';
|
|
24
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
25
|
+
import type { QueryRunner } from './authz-contract.js';
|
|
26
|
+
import { escapeLiteral } from './derived-apply.js';
|
|
27
|
+
import { createUrlRunner } from './db-source.js';
|
|
28
|
+
import {
|
|
29
|
+
withDatabase,
|
|
30
|
+
createDatabase,
|
|
31
|
+
dropDatabase,
|
|
32
|
+
buildIntoDatabase,
|
|
33
|
+
type BuildOptions,
|
|
34
|
+
} from './db-build.js';
|
|
35
|
+
|
|
36
|
+
export { withDatabase } from './db-build.js';
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Naming (pure).
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const FAMILY_SUFFIX = /(_tpl|_br_.*|_eph_.*)$/;
|
|
43
|
+
|
|
44
|
+
/** The family's base name, from ANY member's URL (base, template, branch, ephemeral). */
|
|
45
|
+
export function baseNameFromUrl(url: string): string {
|
|
46
|
+
const m = url.match(/\/([^/?]+)(\?|$)/);
|
|
47
|
+
if (!m) throw new Error('the connection URL names no database — add the path segment (postgres://…/mydb)');
|
|
48
|
+
return m[1].replace(FAMILY_SUFFIX, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function templateName(base: string): string {
|
|
52
|
+
return `${base}_tpl`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The branch database's name: deterministic, collision-free, ≤63 chars.
|
|
57
|
+
* A branch that IS already a clean identifier passes through readably;
|
|
58
|
+
* anything cleaned (case, slashes, unicode) or truncated carries a 6-char
|
|
59
|
+
* content hash so `feat/foo` and `feat_foo` never collide.
|
|
60
|
+
*/
|
|
61
|
+
export function branchDatabaseName(base: string, branch: string): string {
|
|
62
|
+
const prefix = `${base}_br_`;
|
|
63
|
+
const clean = branch.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
64
|
+
const budget = 63 - prefix.length;
|
|
65
|
+
if (clean === branch && clean.length > 0 && clean.length <= budget) return `${prefix}${clean}`;
|
|
66
|
+
const hash = createHash('sha256').update(branch).digest('hex').slice(0, 6);
|
|
67
|
+
const body = clean.slice(0, Math.max(0, budget - 7));
|
|
68
|
+
return `${prefix}${body ? `${body}_` : ''}${hash}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Comments — the branch mapping and the template provenance (pure).
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const BRANCH_PREFIX = 'everystack branch: ';
|
|
76
|
+
|
|
77
|
+
export function branchComment(branch: string): string {
|
|
78
|
+
return `${BRANCH_PREFIX}${branch}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseBranchComment(comment: string | null): string | null {
|
|
82
|
+
if (!comment || !comment.startsWith(BRANCH_PREFIX)) return null;
|
|
83
|
+
return comment.slice(BRANCH_PREFIX.length);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function templateComment(fingerprint: string, gitRef: string | null, builtAt: string): string {
|
|
87
|
+
return `everystack template fp=${fingerprint} git=${gitRef ?? 'none'} built=${builtAt}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseTemplateComment(
|
|
91
|
+
comment: string | null,
|
|
92
|
+
): { fingerprint: string; gitRef: string | null; builtAt: string } | null {
|
|
93
|
+
const m = comment?.match(/^everystack template fp=(\S+) git=(\S+) built=(\S+)$/);
|
|
94
|
+
if (!m) return null;
|
|
95
|
+
return { fingerprint: m[1], gitRef: m[2] === 'none' ? null : m[2], builtAt: m[3] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Pruning (pure).
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
export interface BranchDatabase {
|
|
103
|
+
database: string;
|
|
104
|
+
/** From the database comment; null when the mapping is lost — never pruned. */
|
|
105
|
+
branch: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Databases whose branch no longer exists — and ONLY those. Unknown = kept. */
|
|
109
|
+
export function prunableBranchDatabases(
|
|
110
|
+
databases: BranchDatabase[],
|
|
111
|
+
liveBranches: string[],
|
|
112
|
+
): BranchDatabase[] {
|
|
113
|
+
const live = new Set(liveBranches);
|
|
114
|
+
return databases.filter((d) => d.branch !== null && !live.has(d.branch));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Git (thin).
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export function currentBranch(cwd?: string): string | null {
|
|
122
|
+
try {
|
|
123
|
+
const out = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
124
|
+
cwd, stdio: ['ignore', 'pipe', 'ignore'],
|
|
125
|
+
}).toString().trim();
|
|
126
|
+
return out === 'HEAD' ? null : out; // detached HEAD names no branch
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function localBranches(cwd?: string): string[] {
|
|
133
|
+
try {
|
|
134
|
+
return execSync('git for-each-ref --format=%(refname:short) refs/heads', {
|
|
135
|
+
cwd, stdio: ['ignore', 'pipe', 'ignore'],
|
|
136
|
+
}).toString().trim().split('\n').filter(Boolean);
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Orchestration.
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
async function withAdmin<T>(adminUrl: string, fn: (runner: QueryRunner) => Promise<T>): Promise<T> {
|
|
147
|
+
const { runner, end } = await createUrlRunner(adminUrl);
|
|
148
|
+
try {
|
|
149
|
+
return await fn(runner);
|
|
150
|
+
} finally {
|
|
151
|
+
await end?.();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function databaseExists(runner: QueryRunner, name: string): Promise<boolean> {
|
|
156
|
+
const rows = await runner(`SELECT 1 FROM pg_database WHERE datname = ${escapeLiteral(name)}`);
|
|
157
|
+
return rows.length > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function setComment(runner: QueryRunner, database: string, comment: string): Promise<void> {
|
|
161
|
+
await runner(`COMMENT ON DATABASE "${database}" IS ${escapeLiteral(comment)}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface TemplateRefreshOptions extends BuildOptions {
|
|
165
|
+
/** Runs against the freshly built template (the app's own seed, injected by the shell). */
|
|
166
|
+
seed?: ((templateUrl: string) => Promise<void>) | null;
|
|
167
|
+
/** Injectable for tests. Default: now, ISO. */
|
|
168
|
+
builtAt?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface TemplateRefreshResult {
|
|
172
|
+
database: string;
|
|
173
|
+
url: string;
|
|
174
|
+
fingerprint: string;
|
|
175
|
+
report: string[];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* (Re)build the dev template from the declared state + seed. All or
|
|
180
|
+
* nothing: a template that fails to build (or seed) is DROPPED — a broken
|
|
181
|
+
* template silently minting broken branch databases is the one outcome
|
|
182
|
+
* this must never produce.
|
|
183
|
+
*/
|
|
184
|
+
export async function executeTemplateRefresh(
|
|
185
|
+
adminUrl: string,
|
|
186
|
+
models: ModelDescriptor[],
|
|
187
|
+
options: TemplateRefreshOptions = {},
|
|
188
|
+
): Promise<TemplateRefreshResult> {
|
|
189
|
+
const base = baseNameFromUrl(adminUrl);
|
|
190
|
+
const template = templateName(base);
|
|
191
|
+
await dropDatabase(adminUrl, template);
|
|
192
|
+
await createDatabase(adminUrl, template);
|
|
193
|
+
const url = withDatabase(adminUrl, template);
|
|
194
|
+
try {
|
|
195
|
+
const built = await buildIntoDatabase(url, models, options);
|
|
196
|
+
if (!(built.converged && built.fingerprintMatch)) {
|
|
197
|
+
throw new Error(`the template did not land on the declared state:\n${built.report.join('\n')}`);
|
|
198
|
+
}
|
|
199
|
+
if (options.seed) await options.seed(url);
|
|
200
|
+
await withAdmin(adminUrl, (runner) =>
|
|
201
|
+
setComment(runner, template, templateComment(
|
|
202
|
+
built.fingerprint,
|
|
203
|
+
options.gitRef ?? null,
|
|
204
|
+
options.builtAt ?? new Date().toISOString(),
|
|
205
|
+
)),
|
|
206
|
+
);
|
|
207
|
+
return { database: template, url, fingerprint: built.fingerprint, report: built.report };
|
|
208
|
+
} catch (err) {
|
|
209
|
+
await dropDatabase(adminUrl, template).catch(() => {});
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type EnsureBranchResult =
|
|
215
|
+
| { status: 'created' | 'exists'; database: string; url: string; template: string }
|
|
216
|
+
| { status: 'no-template'; database: string; url: string; template: string };
|
|
217
|
+
|
|
218
|
+
/** Mint (or find) the current branch's database from the template. */
|
|
219
|
+
export async function ensureBranchDatabase(adminUrl: string, branch: string): Promise<EnsureBranchResult> {
|
|
220
|
+
const base = baseNameFromUrl(adminUrl);
|
|
221
|
+
const template = templateName(base);
|
|
222
|
+
const database = branchDatabaseName(base, branch);
|
|
223
|
+
const url = withDatabase(adminUrl, database);
|
|
224
|
+
|
|
225
|
+
const exists = await withAdmin(adminUrl, (runner) => databaseExists(runner, database));
|
|
226
|
+
if (exists) return { status: 'exists', database, url, template };
|
|
227
|
+
|
|
228
|
+
const templateExists = await withAdmin(adminUrl, (runner) => databaseExists(runner, template));
|
|
229
|
+
if (!templateExists) return { status: 'no-template', database, url, template };
|
|
230
|
+
|
|
231
|
+
await createDatabase(adminUrl, database, template);
|
|
232
|
+
await withAdmin(adminUrl, (runner) => setComment(runner, database, branchComment(branch)));
|
|
233
|
+
return { status: 'created', database, url, template };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Every branch database in the family, with its branch (from the comment). */
|
|
237
|
+
export async function listBranchDatabases(adminUrl: string): Promise<BranchDatabase[]> {
|
|
238
|
+
const base = baseNameFromUrl(adminUrl);
|
|
239
|
+
return withAdmin(adminUrl, async (runner) => {
|
|
240
|
+
const rows = await runner(
|
|
241
|
+
`SELECT datname, shobj_description(oid, 'pg_database') AS comment
|
|
242
|
+
FROM pg_database WHERE datname LIKE ${escapeLiteral(`${base}\\_br\\_%`)} ORDER BY datname`,
|
|
243
|
+
);
|
|
244
|
+
return rows.map((r: any) => ({ database: r.datname, branch: parseBranchComment(r.comment) }));
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The template's provenance, or null when no template exists. */
|
|
249
|
+
export async function readTemplateProvenance(
|
|
250
|
+
adminUrl: string,
|
|
251
|
+
): Promise<{ fingerprint: string; gitRef: string | null; builtAt: string } | null> {
|
|
252
|
+
const base = baseNameFromUrl(adminUrl);
|
|
253
|
+
return withAdmin(adminUrl, async (runner) => {
|
|
254
|
+
const rows = await runner(
|
|
255
|
+
`SELECT shobj_description(oid, 'pg_database') AS comment
|
|
256
|
+
FROM pg_database WHERE datname = ${escapeLiteral(templateName(base))}`,
|
|
257
|
+
);
|
|
258
|
+
if (rows.length === 0) return null;
|
|
259
|
+
return parseTemplateComment(rows[0].comment);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Drop a branch database — refuses anything outside the `_br_` family. */
|
|
264
|
+
export async function dropBranchDatabase(adminUrl: string, database: string): Promise<void> {
|
|
265
|
+
if (!/_br_/.test(database)) {
|
|
266
|
+
throw new Error(`${database} is not a branch database — this path only drops the _br_ family.`);
|
|
267
|
+
}
|
|
268
|
+
await dropDatabase(adminUrl, database);
|
|
269
|
+
}
|
|
@@ -27,11 +27,14 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
|
|
|
27
27
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
28
28
|
import { fingerprintLive } from '../schema-fingerprint.js';
|
|
29
29
|
import { applyGeneratedStatements, currentGitRef } from '../state-apply.js';
|
|
30
|
-
import { renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
|
|
30
|
+
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
|
|
31
31
|
import { checkPlanPrecondition, planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
|
|
32
32
|
import { verifyDescent, type LiveState } from '../git-descent.js';
|
|
33
|
+
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity } from '../apply-authority.js';
|
|
33
34
|
import { resolveModelsPath } from '../models-path.js';
|
|
34
35
|
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
36
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
37
|
+
import { invokeAction } from '../aws.js';
|
|
35
38
|
import { step, success, fail, info, warn } from '../output.js';
|
|
36
39
|
|
|
37
40
|
// ---------------------------------------------------------------------------
|
|
@@ -54,17 +57,63 @@ export interface ApplyPlanOptions {
|
|
|
54
57
|
contract: AuthzContract;
|
|
55
58
|
fingerprint: string;
|
|
56
59
|
}) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
60
|
+
/**
|
|
61
|
+
* Destructive authority (brick 9, decisions 11+12): called only when the
|
|
62
|
+
* plan is destructive, after the lock and descent checks pass. Return
|
|
63
|
+
* `ok: false` to refuse — the caller is not in the stage's declared
|
|
64
|
+
* approver set. Omitted = no authority gate (non-staged targets keep the
|
|
65
|
+
* ceremony the command shell enforces).
|
|
66
|
+
*/
|
|
67
|
+
verifyAuthority?: () => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
68
|
+
/**
|
|
69
|
+
* Runs after every verification passes and BEFORE the edge executes, only
|
|
70
|
+
* for destructive plans — the auto-snapshot seam ("losing data should be
|
|
71
|
+
* hard": confirmed, approved, snapshotted, in that order). A throw here
|
|
72
|
+
* aborts the apply with nothing executed.
|
|
73
|
+
*/
|
|
74
|
+
beforeDestructive?: () => Promise<void>;
|
|
57
75
|
/** Injectable clock for tests. */
|
|
58
76
|
now?: () => number;
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
export interface ApplyPlanResult {
|
|
62
|
-
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'verify-failed';
|
|
80
|
+
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
|
|
63
81
|
liveFingerprint: string;
|
|
64
82
|
reason?: string;
|
|
65
83
|
logId?: number;
|
|
66
84
|
}
|
|
67
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Decision 7, made whole: every database records every refusal, not just
|
|
88
|
+
* every apply. Best-effort by construction — a memoir INSERT that cannot be
|
|
89
|
+
* written must never mask the refusal itself (a target that rejects the
|
|
90
|
+
* bookkeeping write would have failed the apply's ENSURE identically).
|
|
91
|
+
*/
|
|
92
|
+
async function recordRefusal(
|
|
93
|
+
runner: QueryRunner,
|
|
94
|
+
plan: EdgePlan,
|
|
95
|
+
liveFingerprint: string,
|
|
96
|
+
gate: string,
|
|
97
|
+
reason: string,
|
|
98
|
+
options: ApplyPlanOptions,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
await runner(ENSURE_RECONCILER_SQL.join(';\n'));
|
|
102
|
+
await runner(renderSchemaLogInsert({
|
|
103
|
+
kind: 'state refusal',
|
|
104
|
+
outcome: 'refused',
|
|
105
|
+
actor: options.actor ?? null,
|
|
106
|
+
gitRef: options.gitRef ?? plan.gitRef,
|
|
107
|
+
planRef: planHash(plan),
|
|
108
|
+
fromFingerprint: liveFingerprint,
|
|
109
|
+
toFingerprint: plan.toFingerprint,
|
|
110
|
+
sql: `-- REFUSED (${gate}): ${reason}\n${plan.statements.join('\n;\n')}`,
|
|
111
|
+
}));
|
|
112
|
+
} catch {
|
|
113
|
+
// Swallowed deliberately — see the docblock.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
68
117
|
/** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
|
|
69
118
|
export async function executeApplyPlan(
|
|
70
119
|
runner: QueryRunner,
|
|
@@ -80,16 +129,30 @@ export async function executeApplyPlan(
|
|
|
80
129
|
}
|
|
81
130
|
const pre = checkPlanPrecondition(plan, live);
|
|
82
131
|
if (!pre.ok) {
|
|
132
|
+
await recordRefusal(runner, plan, live, 'concurrency lock', pre.reason, options);
|
|
83
133
|
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
84
134
|
}
|
|
85
135
|
|
|
86
136
|
if (options.verifyDescent) {
|
|
87
137
|
const descent = await options.verifyDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live });
|
|
88
138
|
if (!descent.ok) {
|
|
139
|
+
await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
|
|
89
140
|
return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
|
|
90
141
|
}
|
|
91
142
|
}
|
|
92
143
|
|
|
144
|
+
if (plan.destructive > 0 && options.verifyAuthority) {
|
|
145
|
+
const authority = await options.verifyAuthority();
|
|
146
|
+
if (!authority.ok) {
|
|
147
|
+
await recordRefusal(runner, plan, live, 'destructive authority', authority.reason, options);
|
|
148
|
+
return { status: 'authority-refused', liveFingerprint: live, reason: authority.reason };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (plan.destructive > 0 && options.beforeDestructive) {
|
|
153
|
+
await options.beforeDestructive();
|
|
154
|
+
}
|
|
155
|
+
|
|
93
156
|
const result = await applyGeneratedStatements(runner, plan.statements, {
|
|
94
157
|
actor: options.actor,
|
|
95
158
|
gitRef: options.gitRef ?? plan.gitRef,
|
|
@@ -170,6 +233,52 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
170
233
|
}
|
|
171
234
|
}
|
|
172
235
|
|
|
236
|
+
// Losing data should be hard (decision 11): a destructive plan applies
|
|
237
|
+
// only confirmed (--confirm, always), snapshotted (automatic with --stage,
|
|
238
|
+
// named over a bare connection), and — when the stage declares an approver
|
|
239
|
+
// set — identity-approved (decision 12).
|
|
240
|
+
const isDestructive = plan.destructive > 0;
|
|
241
|
+
let verifyAuthority: (() => Promise<{ ok: true } | { ok: false; reason: string }>) | undefined;
|
|
242
|
+
let beforeDestructive: (() => Promise<void>) | undefined;
|
|
243
|
+
if (isDestructive) {
|
|
244
|
+
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
245
|
+
if (flags.confirm !== 'true') {
|
|
246
|
+
fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
if (flags.stage) {
|
|
250
|
+
const config = await resolveConfig(flags.stage);
|
|
251
|
+
const { parseAppName } = await import('../discover.js');
|
|
252
|
+
const appName = await parseAppName();
|
|
253
|
+
verifyAuthority = async () => {
|
|
254
|
+
step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
|
|
255
|
+
const approvers = await readApproverParam(config.region, appName, flags.stage);
|
|
256
|
+
const identity = approvers === null ? null : await resolveCallerIdentity(config.region);
|
|
257
|
+
const verdict = checkDestructiveAuthority({ approvers, identity });
|
|
258
|
+
if (!verdict.ok) return verdict;
|
|
259
|
+
if (verdict.reason === 'approved') {
|
|
260
|
+
info(`authority: ${identity} is a declared approver.`);
|
|
261
|
+
} else {
|
|
262
|
+
warn('authority: no approver set declared for this stage — proceeding on ceremony alone. Declare one: everystack db:approvers --set <arn-or-name,...> --stage ' + flags.stage);
|
|
263
|
+
}
|
|
264
|
+
return { ok: true };
|
|
265
|
+
};
|
|
266
|
+
beforeDestructive = async () => {
|
|
267
|
+
step('Destructive apply — taking a snapshot first (db:backup)...');
|
|
268
|
+
const result: any = await invokeAction(config.region, opsFunction(config), 'db:backup', { stage: flags.stage });
|
|
269
|
+
if (result?.error) {
|
|
270
|
+
throw new Error(`auto-snapshot failed, so the destructive plan was NOT applied: ${result.error}`);
|
|
271
|
+
}
|
|
272
|
+
info(`snapshot on record: ${result?.id ?? 'backup complete'} — restore with db:restore --from ${result?.id ?? '<id>'} --confirm.`);
|
|
273
|
+
};
|
|
274
|
+
} else if (!flags['snapshot-ref']) {
|
|
275
|
+
fail(`This plan is DESTRUCTIVE (${shape}) and the target is a bare connection — the apply cannot take the snapshot itself. Take one (db:snapshot / db:backup / pg_dump) and name it: --snapshot-ref <ref>. Or pass --stage and the apply snapshots automatically, with the stage's approver set enforced.`);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
} else {
|
|
278
|
+
warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + snapshot ${flags['snapshot-ref']}.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
173
282
|
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
174
283
|
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
175
284
|
|
|
@@ -182,6 +291,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
182
291
|
const result = await executeApplyPlan(runner, plan, {
|
|
183
292
|
actor: process.env.USER ?? null,
|
|
184
293
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
294
|
+
...(verifyAuthority ? { verifyAuthority } : {}),
|
|
295
|
+
...(beforeDestructive ? { beforeDestructive } : {}),
|
|
185
296
|
...(forceDescent === undefined ? {
|
|
186
297
|
verifyDescent: async (live: LiveState & { fingerprint: string }) => {
|
|
187
298
|
step('Descent: searching git for the commit that declares the target\'s state...');
|
|
@@ -216,6 +327,10 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
216
327
|
fail(`REFUSED (fast-forward rule): ${result.reason}`);
|
|
217
328
|
process.exit(1);
|
|
218
329
|
break;
|
|
330
|
+
case 'authority-refused':
|
|
331
|
+
fail(`REFUSED (destructive authority): ${result.reason}`);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
break;
|
|
219
334
|
case 'verify-failed':
|
|
220
335
|
fail(`Verify FAILED: ${result.reason}`);
|
|
221
336
|
process.exit(1);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:approvers` — declare who can destroy (brick 9, decision 12).
|
|
3
|
+
*
|
|
4
|
+
* db:approvers --stage <name> # show the declared set
|
|
5
|
+
* db:approvers --stage <name> --set "cto,arn:..." # declare / replace
|
|
6
|
+
* db:approvers --stage <name> --set '' # declare EMPTY: destructive applies disabled
|
|
7
|
+
* db:approvers --stage <name> --remove # undeclare: back to ceremony-only
|
|
8
|
+
*
|
|
9
|
+
* The set lives stage-side (an SSM parameter in the stage's namespace) so a
|
|
10
|
+
* checkout edit can't reach it; lock it down further with IAM by denying
|
|
11
|
+
* `ssm:PutParameter` on `/everystack/<app>/<stage>/db-approvers` to
|
|
12
|
+
* non-admins — the same posture as the rest of the stage's parameters.
|
|
13
|
+
* Entries are exact ARNs or bare names matched against the caller's ARN
|
|
14
|
+
* path segments (`ty` matches `user/ty` and an assumed-role session `ty`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { resolveConfig } from '../config.js';
|
|
18
|
+
import {
|
|
19
|
+
approverParamName,
|
|
20
|
+
parseApproverValue,
|
|
21
|
+
readApproverParam,
|
|
22
|
+
writeApproverParam,
|
|
23
|
+
removeApproverParam,
|
|
24
|
+
} from '../apply-authority.js';
|
|
25
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
26
|
+
|
|
27
|
+
export async function dbApproversCommand(flags: Record<string, string>): Promise<void> {
|
|
28
|
+
if (!flags.stage) {
|
|
29
|
+
fail('db:approvers needs --stage <name> — the approver set is per-stage configuration.');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
step('Resolving deployed config...');
|
|
34
|
+
const config = await resolveConfig(flags.stage);
|
|
35
|
+
const { parseAppName } = await import('../discover.js');
|
|
36
|
+
const appName = await parseAppName();
|
|
37
|
+
const paramName = approverParamName(appName, flags.stage);
|
|
38
|
+
|
|
39
|
+
if (flags.remove === 'true') {
|
|
40
|
+
await removeApproverParam(config.region, appName, flags.stage);
|
|
41
|
+
success(`Removed ${paramName} — the stage is back to the undeclared posture (destructive applies gated by ceremony only).`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (flags.set !== undefined) {
|
|
46
|
+
if (flags.set === 'true') {
|
|
47
|
+
fail("--set needs a value: a comma-separated list of ARNs or names (--set '' declares an EMPTY set, disabling destructive applies).");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const approvers = parseApproverValue(flags.set);
|
|
51
|
+
await writeApproverParam(config.region, appName, flags.stage, approvers);
|
|
52
|
+
if (approvers.length === 0) {
|
|
53
|
+
warn(`Declared an EMPTY approver set at ${paramName} — destructive applies are now DISABLED on ${flags.stage}.`);
|
|
54
|
+
} else {
|
|
55
|
+
success(`Declared ${approvers.length} approver(s) at ${paramName}: ${approvers.join(', ')}`);
|
|
56
|
+
info('Destructive db:apply runs against this stage now require one of these identities (STS-verified).');
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const approvers = await readApproverParam(config.region, appName, flags.stage);
|
|
62
|
+
if (approvers === null) {
|
|
63
|
+
info(`No approver set declared (${paramName} absent) — destructive applies are gated by ceremony only (--confirm + snapshot).`);
|
|
64
|
+
info("Declare one: everystack db:approvers --set 'cto,arn:aws:iam::…:user/…' --stage " + flags.stage);
|
|
65
|
+
} else if (approvers.length === 0) {
|
|
66
|
+
warn(`The approver set is declared EMPTY — destructive applies are DISABLED on ${flags.stage}.`);
|
|
67
|
+
} else {
|
|
68
|
+
info(`Destructive-approver set for ${flags.stage} (${approvers.length}):`);
|
|
69
|
+
for (const a of approvers) info(` - ${a}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:backfill` — the one-shot data-job lane (brick 8).
|
|
3
|
+
*
|
|
4
|
+
* db:backfill [--database-url <url>] [--dir db/backfills] [--json]
|
|
5
|
+
* db:backfill --apply # run the pending jobs, in order
|
|
6
|
+
* db:backfill --mark-applied <file.sql> # record without running (adopt hatch)
|
|
7
|
+
*
|
|
8
|
+
* Plan first, always: applied (by content identity — renames and comment
|
|
9
|
+
* edits are no-ops), pending (in file-name order, with jobs-lane advisories
|
|
10
|
+
* for unbounded passes), and blocked (a file name that already ran here in
|
|
11
|
+
* a different form — one-shot jobs are immutable; the fix is named). Apply
|
|
12
|
+
* runs each pending job as its own transaction and records it in
|
|
13
|
+
* `everystack.backfill_log`; a failure rolls back alone, is recorded, and
|
|
14
|
+
* stops the run. Backfills NEVER run as a side effect of a schema verb —
|
|
15
|
+
* db:sync and db:plan only surface the pending count.
|
|
16
|
+
*
|
|
17
|
+
* Needs a direct connection (--database-url / DATABASE_URL): the lane
|
|
18
|
+
* reads and writes its own per-database record.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { QueryRunner } from '../authz-contract.js';
|
|
22
|
+
import {
|
|
23
|
+
planBackfills,
|
|
24
|
+
readBackfillLog,
|
|
25
|
+
executeBackfills,
|
|
26
|
+
markBackfillApplied,
|
|
27
|
+
type BackfillPlan,
|
|
28
|
+
} from '../backfill.js';
|
|
29
|
+
import { currentGitRef } from '../state-apply.js';
|
|
30
|
+
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
31
|
+
import { readSqlDirIfPresent } from './db-sync.js';
|
|
32
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
33
|
+
|
|
34
|
+
const DEFAULT_DIR = 'db/backfills';
|
|
35
|
+
|
|
36
|
+
export function buildBackfillReport(plan: BackfillPlan): string[] {
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
lines.push(`= ${plan.applied.length} applied (by content identity)`);
|
|
39
|
+
for (const b of plan.blocked) lines.push(`! BLOCKED ${b.file}: ${b.reason}`);
|
|
40
|
+
if (plan.pending.length === 0) {
|
|
41
|
+
lines.push('= nothing pending');
|
|
42
|
+
} else {
|
|
43
|
+
lines.push(`~ ${plan.pending.length} pending, in order:`);
|
|
44
|
+
for (const p of plan.pending) {
|
|
45
|
+
lines.push(`~ ${p.file}`);
|
|
46
|
+
for (const a of p.advisories) lines.push(`· advisory: ${a}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function dbBackfillCommand(flags: Record<string, string>): Promise<void> {
|
|
53
|
+
const dir = flags.dir || DEFAULT_DIR;
|
|
54
|
+
const files = await readSqlDirIfPresent(dir);
|
|
55
|
+
if (files === null) {
|
|
56
|
+
info(`No ${dir}/ directory — the backfill lane is not declared. One-shot data jobs live there as *.sql files.`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let dbSource: DbSource;
|
|
61
|
+
try {
|
|
62
|
+
dbSource = resolveDbSource(flags);
|
|
63
|
+
} catch (err: any) {
|
|
64
|
+
fail(err.message);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
if (dbSource.kind !== 'url') {
|
|
68
|
+
fail('db:backfill needs a direct connection (--database-url or DATABASE_URL) — the lane reads and writes its per-database record.');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
73
|
+
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
74
|
+
try {
|
|
75
|
+
const opts = { actor: process.env.USER ?? null, gitRef: currentGitRef() };
|
|
76
|
+
|
|
77
|
+
if (flags['mark-applied'] !== undefined) {
|
|
78
|
+
const name = flags['mark-applied'];
|
|
79
|
+
const file = files.find((f) => f.file.endsWith(`/${name}`) || f.file === name);
|
|
80
|
+
if (name === 'true' || !file) {
|
|
81
|
+
fail(`--mark-applied needs a file from ${dir}/ (got ${name === 'true' ? 'nothing' : name}).`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
const identity = await markBackfillApplied(runner as QueryRunner, file, opts);
|
|
85
|
+
success(`Recorded ${file.file} as applied WITHOUT running it (identity ${identity.slice(0, 12)}).`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const plan = planBackfills(files, await readBackfillLog(runner));
|
|
90
|
+
for (const line of buildBackfillReport(plan)) info(line);
|
|
91
|
+
|
|
92
|
+
if (flags.json === 'true') {
|
|
93
|
+
console.log(JSON.stringify({
|
|
94
|
+
applied: plan.applied,
|
|
95
|
+
pending: plan.pending.map(({ sql: _sql, ...rest }) => rest),
|
|
96
|
+
blocked: plan.blocked,
|
|
97
|
+
}, null, 2));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (flags.apply !== 'true') {
|
|
101
|
+
if (plan.pending.length > 0) info(`Run them: everystack db:backfill --apply`);
|
|
102
|
+
if (plan.blocked.length > 0) process.exit(1);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (plan.pending.length === 0) {
|
|
107
|
+
success('Nothing to run.');
|
|
108
|
+
if (plan.blocked.length > 0) process.exit(1);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
step(`Running ${plan.pending.length} backfill(s), each as its own transaction...`);
|
|
113
|
+
const result = await executeBackfills(runner, plan, opts);
|
|
114
|
+
for (const ran of result.ran) info(`~ ${ran.file} applied in ${ran.durationMs}ms`);
|
|
115
|
+
if (result.failed) {
|
|
116
|
+
fail(`${result.failed.file} FAILED and rolled back (recorded; it stays pending for the retry): ${result.failed.error}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
if (plan.blocked.length > 0) {
|
|
120
|
+
fail('Pending jobs ran, but blocked file(s) remain above — fix them (new file name, or --mark-applied).');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
success(`${result.ran.length} backfill(s) applied and recorded in everystack.backfill_log.`);
|
|
124
|
+
} finally {
|
|
125
|
+
await end?.();
|
|
126
|
+
}
|
|
127
|
+
}
|