@everystack/cli 0.3.21 → 0.3.23
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 +3 -1
- package/package.json +9 -4
- package/src/cli/apply-authority.ts +150 -0
- package/src/cli/commands/db-apply.ts +183 -5
- package/src/cli/commands/db-approvers.ts +71 -0
- package/src/cli/commands/db-check.ts +306 -0
- package/src/cli/commands/db-plan.ts +12 -0
- package/src/cli/commands/db-sync.ts +6 -1
- package/src/cli/edge-plan.ts +76 -34
- package/src/cli/git-descent.ts +251 -0
- package/src/cli/index.ts +11 -1
- package/src/cli/schema-source.ts +5 -0
- package/src/cli/state-apply.ts +30 -0
package/README.md
CHANGED
|
@@ -220,7 +220,9 @@ everystack db:reconcile # deploy the derived layer (functions/vi
|
|
|
220
220
|
everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
|
|
221
221
|
everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
|
|
222
222
|
everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
|
|
223
|
-
everystack db:plan | db:apply # mint a reviewable plan against a target, apply it fingerprint-verified at both ends
|
|
223
|
+
everystack db:plan | db:apply # mint a reviewable plan against a target, apply it fingerprint-verified at both ends + the fast-forward rule (checkout must descend from the commit declaring the target's state); destructive plans: --confirm + snapshot + approver set when declared
|
|
224
|
+
everystack db:check # the CI gate per PR: merged declared state composes + artifacts match regeneration (+ ephemeral from-scratch build w/ a scratch PG)
|
|
225
|
+
everystack db:approvers # declare who can DESTROY: the stage's destructive-approver set (SSM, STS-verified at apply)
|
|
224
226
|
|
|
225
227
|
# Security
|
|
226
228
|
everystack db:doctor # is the DB least-privilege + RLS-subject?
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.23",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"structured-headers": "1.0.1",
|
|
79
79
|
"tsx": "4.21.0",
|
|
80
80
|
"typescript": "5.9.3",
|
|
81
|
-
"@everystack/model": "0.3.
|
|
81
|
+
"@everystack/model": "0.3.6"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
|
84
84
|
"@everystack/server": ">=0.1.0",
|
|
@@ -93,7 +93,8 @@
|
|
|
93
93
|
"expo-updates": "55.0.21",
|
|
94
94
|
"postgres": "3.4.9",
|
|
95
95
|
"react": "19.2.0",
|
|
96
|
-
"react-native": "0.83.6"
|
|
96
|
+
"react-native": "0.83.6",
|
|
97
|
+
"@aws-sdk/client-sts": "3.1053.0"
|
|
97
98
|
},
|
|
98
99
|
"peerDependenciesMeta": {
|
|
99
100
|
"postgres": {
|
|
@@ -131,6 +132,9 @@
|
|
|
131
132
|
},
|
|
132
133
|
"@aws-sdk/s3-request-presigner": {
|
|
133
134
|
"optional": true
|
|
135
|
+
},
|
|
136
|
+
"@aws-sdk/client-sts": {
|
|
137
|
+
"optional": true
|
|
134
138
|
}
|
|
135
139
|
},
|
|
136
140
|
"devDependencies": {
|
|
@@ -148,7 +152,8 @@
|
|
|
148
152
|
"drizzle-orm": "0.41.0",
|
|
149
153
|
"jest": "29.7.0",
|
|
150
154
|
"react": "19.2.0",
|
|
151
|
-
"ts-jest": "29.4.9"
|
|
155
|
+
"ts-jest": "29.4.9",
|
|
156
|
+
"@aws-sdk/client-sts": "3.1053.0"
|
|
152
157
|
},
|
|
153
158
|
"scripts": {
|
|
154
159
|
"test": "jest",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apply-authority — destructive applies are role-gated to a declared
|
|
3
|
+
* approver set, enforced against the caller's REAL identity (brick 9 of the
|
|
4
|
+
* migration-authority design, decisions 11 + 12).
|
|
5
|
+
*
|
|
6
|
+
* "CTO drops tables; everyone else creates functions and views." The
|
|
7
|
+
* approver set lives STAGE-SIDE — an SSM parameter in the stage's namespace,
|
|
8
|
+
* admin-writable only — never in the repo, where a checkout edit would
|
|
9
|
+
* defeat it (a repo-declared list is a convention wearing a gate's clothes).
|
|
10
|
+
* The chokepoint resolves the caller's real identity via STS — the same
|
|
11
|
+
* pattern as the `db:psql` break-glass, where the IAM read IS the gate.
|
|
12
|
+
*
|
|
13
|
+
* The gate engages on DECLARATION: a stage that never declared approvers
|
|
14
|
+
* keeps the ceremony (explicit confirmation + snapshot, always) but is not
|
|
15
|
+
* identity-gated — the migration-friendly posture, stated honestly. A
|
|
16
|
+
* declared-but-EMPTY set means "no one": destructive applies are disabled
|
|
17
|
+
* on that stage until the set says otherwise.
|
|
18
|
+
*
|
|
19
|
+
* The refusal is the two-person flow's handoff: it names who CAN approve,
|
|
20
|
+
* and `plan_ref` already pins the exact reviewed artifact — a dev mints the
|
|
21
|
+
* destructive plan, an approver applies that same file.
|
|
22
|
+
*
|
|
23
|
+
* The decision core is pure; the AWS edge (SSM + STS) is thin and injected
|
|
24
|
+
* by the command shell.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The SSM parameter holding the approver set — a JSON array of entries. */
|
|
28
|
+
export function approverParamName(appName: string, stage: string): string {
|
|
29
|
+
return `/everystack/${appName}/${stage}/db-approvers`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Does an approver entry match a caller ARN? Exact ARN match, or a match on
|
|
34
|
+
* any path segment after the first slash — so the set can hold plain names:
|
|
35
|
+
* `ty` matches `arn:aws:iam::123:user/ty` and the session name in
|
|
36
|
+
* `arn:aws:sts::123:assumed-role/Admin/ty`. Segment (not suffix) matching:
|
|
37
|
+
* `ty` never matches `user/notty`.
|
|
38
|
+
*/
|
|
39
|
+
export function approverMatches(entry: string, identityArn: string): boolean {
|
|
40
|
+
if (entry === identityArn) return true;
|
|
41
|
+
const segments = identityArn.split('/').slice(1);
|
|
42
|
+
return segments.includes(entry);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AuthorityInputs {
|
|
46
|
+
/** The declared approver set; null = the stage never declared one. */
|
|
47
|
+
approvers: string[] | null;
|
|
48
|
+
/** The caller's resolved identity (STS ARN); null = unresolvable. */
|
|
49
|
+
identity: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type AuthorityVerdict =
|
|
53
|
+
| { ok: true; reason: 'approved' | 'undeclared' }
|
|
54
|
+
| { ok: false; reason: string };
|
|
55
|
+
|
|
56
|
+
/** The pure decision core for a DESTRUCTIVE apply. Non-destructive plans never reach it. */
|
|
57
|
+
export function checkDestructiveAuthority({ approvers, identity }: AuthorityInputs): AuthorityVerdict {
|
|
58
|
+
if (approvers === null) return { ok: true, reason: 'undeclared' };
|
|
59
|
+
if (approvers.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason:
|
|
63
|
+
'the declared approver set is EMPTY — destructive applies are disabled on this stage. ' +
|
|
64
|
+
'Declare approvers with `everystack db:approvers --set <arn-or-name,...>` (admin-writable parameter).',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (identity === null) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reason:
|
|
71
|
+
'this stage declares a destructive-approver set, so the apply must be identity-verified — ' +
|
|
72
|
+
'and your AWS identity could not be resolved (credentials?). Fix the credentials or hand the plan to an approver.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (approvers.some((entry) => approverMatches(entry, identity))) {
|
|
76
|
+
return { ok: true, reason: 'approved' };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason:
|
|
81
|
+
`you are ${identity}, and the declared approvers are: ${approvers.join(', ')}. ` +
|
|
82
|
+
'Hand the plan file to an approver — they review and apply the exact same artifact (its plan_ref pins it).',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// The thin AWS edge (lazy imports, injected by the command shell).
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/** Read the approver set from SSM. Null when the parameter does not exist. */
|
|
91
|
+
export async function readApproverParam(region: string, appName: string, stage: string): Promise<string[] | null> {
|
|
92
|
+
const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
93
|
+
const client = new SSMClient({ region });
|
|
94
|
+
try {
|
|
95
|
+
const result = await client.send(new GetParameterCommand({ Name: approverParamName(appName, stage) }));
|
|
96
|
+
return parseApproverValue(result.Parameter?.Value ?? '');
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
if (err?.name === 'ParameterNotFound') return null;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The stored form is a JSON array; a bare comma-separated string is accepted on read. */
|
|
104
|
+
export function parseApproverValue(value: string): string[] {
|
|
105
|
+
const trimmed = value.trim();
|
|
106
|
+
if (trimmed === '') return [];
|
|
107
|
+
if (trimmed.startsWith('[')) {
|
|
108
|
+
const parsed = JSON.parse(trimmed);
|
|
109
|
+
if (!Array.isArray(parsed) || parsed.some((e) => typeof e !== 'string')) {
|
|
110
|
+
throw new Error('db-approvers parameter must be a JSON array of strings');
|
|
111
|
+
}
|
|
112
|
+
return parsed.map((e: string) => e.trim()).filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
return trimmed.split(',').map((e) => e.trim()).filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Declare (or replace) the approver set. Stored as a JSON array. */
|
|
118
|
+
export async function writeApproverParam(region: string, appName: string, stage: string, approvers: string[]): Promise<void> {
|
|
119
|
+
const { SSMClient, PutParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
120
|
+
const client = new SSMClient({ region });
|
|
121
|
+
await client.send(new PutParameterCommand({
|
|
122
|
+
Name: approverParamName(appName, stage),
|
|
123
|
+
Value: JSON.stringify(approvers),
|
|
124
|
+
Type: 'String',
|
|
125
|
+
Overwrite: true,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Remove the declaration entirely — the stage returns to the undeclared (ceremony-only) posture. */
|
|
130
|
+
export async function removeApproverParam(region: string, appName: string, stage: string): Promise<void> {
|
|
131
|
+
const { SSMClient, DeleteParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
132
|
+
const client = new SSMClient({ region });
|
|
133
|
+
try {
|
|
134
|
+
await client.send(new DeleteParameterCommand({ Name: approverParamName(appName, stage) }));
|
|
135
|
+
} catch (err: any) {
|
|
136
|
+
if (err?.name !== 'ParameterNotFound') throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The caller's real identity — the STS ARN. Null when credentials can't answer. */
|
|
141
|
+
export async function resolveCallerIdentity(region: string): Promise<string | null> {
|
|
142
|
+
const { STSClient, GetCallerIdentityCommand } = await import('@aws-sdk/client-sts');
|
|
143
|
+
const client = new STSClient({ region });
|
|
144
|
+
try {
|
|
145
|
+
const result = await client.send(new GetCallerIdentityCommand({}));
|
|
146
|
+
return result.Arn ?? null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -22,15 +22,20 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import fs from 'node:fs/promises';
|
|
25
|
-
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
26
|
-
import { introspectSchema } from '../schema-introspect.js';
|
|
25
|
+
import { introspectContract, type QueryRunner, type AuthzContract } from '../authz-contract.js';
|
|
26
|
+
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
|
+
import { verifyDescent, type LiveState } from '../git-descent.js';
|
|
33
|
+
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity } from '../apply-authority.js';
|
|
34
|
+
import { resolveModelsPath } from '../models-path.js';
|
|
32
35
|
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
33
|
-
import {
|
|
36
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
37
|
+
import { invokeAction } from '../aws.js';
|
|
38
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
34
39
|
|
|
35
40
|
// ---------------------------------------------------------------------------
|
|
36
41
|
// The testable core.
|
|
@@ -40,17 +45,75 @@ export interface ApplyPlanOptions {
|
|
|
40
45
|
actor?: string | null;
|
|
41
46
|
/** Defaults to the plan's own gitRef (the intent that minted it). */
|
|
42
47
|
gitRef?: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* The fast-forward rule (brick 7): called with the target's live state
|
|
50
|
+
* after the concurrency lock passes, BEFORE anything executes. Return
|
|
51
|
+
* `ok: false` to refuse — the checkout does not descend from the commit
|
|
52
|
+
* declaring the target's state. Omitted = no descent enforcement (tests,
|
|
53
|
+
* or an explicit, confirmed, snapshotted force).
|
|
54
|
+
*/
|
|
55
|
+
verifyDescent?: (live: {
|
|
56
|
+
snapshot: SchemaSnapshot;
|
|
57
|
+
contract: AuthzContract;
|
|
58
|
+
fingerprint: string;
|
|
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>;
|
|
43
75
|
/** Injectable clock for tests. */
|
|
44
76
|
now?: () => number;
|
|
45
77
|
}
|
|
46
78
|
|
|
47
79
|
export interface ApplyPlanResult {
|
|
48
|
-
status: 'applied' | 'already-applied' | 'refused' | 'verify-failed';
|
|
80
|
+
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
|
|
49
81
|
liveFingerprint: string;
|
|
50
82
|
reason?: string;
|
|
51
83
|
logId?: number;
|
|
52
84
|
}
|
|
53
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
|
+
|
|
54
117
|
/** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
|
|
55
118
|
export async function executeApplyPlan(
|
|
56
119
|
runner: QueryRunner,
|
|
@@ -66,9 +129,30 @@ export async function executeApplyPlan(
|
|
|
66
129
|
}
|
|
67
130
|
const pre = checkPlanPrecondition(plan, live);
|
|
68
131
|
if (!pre.ok) {
|
|
132
|
+
await recordRefusal(runner, plan, live, 'concurrency lock', pre.reason, options);
|
|
69
133
|
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
70
134
|
}
|
|
71
135
|
|
|
136
|
+
if (options.verifyDescent) {
|
|
137
|
+
const descent = await options.verifyDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live });
|
|
138
|
+
if (!descent.ok) {
|
|
139
|
+
await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
|
|
140
|
+
return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
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
|
+
|
|
72
156
|
const result = await applyGeneratedStatements(runner, plan.statements, {
|
|
73
157
|
actor: options.actor,
|
|
74
158
|
gitRef: options.gitRef ?? plan.gitRef,
|
|
@@ -134,15 +218,101 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
134
218
|
process.exit(1);
|
|
135
219
|
}
|
|
136
220
|
|
|
221
|
+
// The fast-forward rule's force ceremony (design decision 9): explicit
|
|
222
|
+
// (--force-descent), snapshotted (its value NAMES the snapshot you took),
|
|
223
|
+
// and confirmed (--confirm). All three or no bypass.
|
|
224
|
+
const forceDescent = flags['force-descent'];
|
|
225
|
+
if (forceDescent !== undefined) {
|
|
226
|
+
if (forceDescent === 'true') {
|
|
227
|
+
fail('--force-descent needs the snapshot you took as its value: --force-descent <snapshot-ref> (take one first: db:snapshot or db:backup).');
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
if (flags.confirm !== 'true') {
|
|
231
|
+
fail('--force-descent bypasses the fast-forward rule — confirm explicitly with --confirm.');
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
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
|
+
|
|
137
282
|
step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
|
|
138
283
|
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
139
284
|
|
|
140
285
|
try {
|
|
141
286
|
info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
287
|
+
if (forceDescent !== undefined) {
|
|
288
|
+
warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
|
|
289
|
+
}
|
|
142
290
|
step('Verifying the target is where the plan started...');
|
|
143
291
|
const result = await executeApplyPlan(runner, plan, {
|
|
144
292
|
actor: process.env.USER ?? null,
|
|
145
293
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
294
|
+
...(verifyAuthority ? { verifyAuthority } : {}),
|
|
295
|
+
...(beforeDestructive ? { beforeDestructive } : {}),
|
|
296
|
+
...(forceDescent === undefined ? {
|
|
297
|
+
verifyDescent: async (live: LiveState & { fingerprint: string }) => {
|
|
298
|
+
step('Descent: searching git for the commit that declares the target\'s state...');
|
|
299
|
+
const verdict = await verifyDescent(resolveModelsPath(flags.models), live, {});
|
|
300
|
+
switch (verdict.status) {
|
|
301
|
+
case 'ok':
|
|
302
|
+
info(`descent: ${verdict.commit.slice(0, 12)} declares the target's state and is an ancestor of HEAD — fast-forward.`);
|
|
303
|
+
return { ok: true };
|
|
304
|
+
case 'fresh-target':
|
|
305
|
+
info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
|
|
306
|
+
return { ok: true };
|
|
307
|
+
case 'no-git':
|
|
308
|
+
warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
|
|
309
|
+
return { ok: true };
|
|
310
|
+
case 'diverged':
|
|
311
|
+
case 'drift':
|
|
312
|
+
return { ok: false, reason: verdict.reason };
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
} : {}),
|
|
146
316
|
});
|
|
147
317
|
|
|
148
318
|
switch (result.status) {
|
|
@@ -153,6 +323,14 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
153
323
|
fail(`REFUSED: ${result.reason}`);
|
|
154
324
|
process.exit(1);
|
|
155
325
|
break;
|
|
326
|
+
case 'descent-refused':
|
|
327
|
+
fail(`REFUSED (fast-forward rule): ${result.reason}`);
|
|
328
|
+
process.exit(1);
|
|
329
|
+
break;
|
|
330
|
+
case 'authority-refused':
|
|
331
|
+
fail(`REFUSED (destructive authority): ${result.reason}`);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
break;
|
|
156
334
|
case 'verify-failed':
|
|
157
335
|
fail(`Verify FAILED: ${result.reason}`);
|
|
158
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
|
+
}
|