@everystack/cli 0.4.85 → 0.4.87
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 +23 -0
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +80 -19
- package/src/cli/apply-refusals.ts +47 -9
- package/src/cli/commands/audit.ts +2 -2
- package/src/cli/commands/bundle.ts +2 -2
- package/src/cli/commands/cache.ts +13 -6
- package/src/cli/commands/db-apply.ts +85 -4
- package/src/cli/commands/db-plan.ts +29 -0
- package/src/cli/commands/db-snapshot.ts +2 -0
- package/src/cli/commands/db.ts +22 -1
- package/src/cli/commands/diag.ts +4 -4
- package/src/cli/commands/lighthouse.ts +2 -2
- package/src/cli/commands/security-probe.ts +2 -2
- package/src/cli/commands/update.ts +17 -3
- package/src/cli/config.ts +30 -2
- package/src/cli/discover-distribution.ts +58 -22
- package/src/cli/discover.ts +48 -6
- package/src/cli/edge-plan.ts +55 -13
- package/src/cli/fk-orphan-advisory.ts +172 -0
- package/src/cli/fk-preflight.ts +126 -43
- package/src/cli/host-url.ts +127 -0
- package/src/cli/index.ts +1 -41
- package/src/cli/model-render.ts +10 -0
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +47 -10
- package/src/cli/schema-fingerprint.ts +26 -0
- package/src/cli/schema-introspect.ts +20 -7
- package/src/cli/security-audit.ts +14 -2
- package/src/cli/state-apply.ts +84 -17
- package/src/cli/swap-snapshot.ts +2 -1
package/src/cli/commands/db.ts
CHANGED
|
@@ -1117,7 +1117,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1117
1117
|
// The reader's read grants ride this same step, for the same reason ADOPT does: they can stall
|
|
1118
1118
|
// on a lock, and nothing that can stall may sit between the password swap and the secret write.
|
|
1119
1119
|
const readerRole = result.readerRole as string | undefined;
|
|
1120
|
-
|
|
1120
|
+
// NO early return on "nothing declared". Convergence also carries the schema "public" posture
|
|
1121
|
+
// check — a property of the DATABASE, not of the Models — and the brownfield app that declares
|
|
1122
|
+
// no owners is exactly the one most likely to carry the pre-PG15 PUBLIC CREATE grant. Gating
|
|
1123
|
+
// this on the declarations made the check unreachable for it.
|
|
1121
1124
|
const ownerPayload = { ...ownershipPayload, adminRole: result.adminRole, ...(readerRole ? { readerRole, searchPath } : {}) };
|
|
1122
1125
|
let owned: any = {};
|
|
1123
1126
|
try {
|
|
@@ -1177,6 +1180,24 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
1177
1180
|
// then fail on every table. Same non-zero exit as an un-converged owner.
|
|
1178
1181
|
for (const r of reader.refusals) { warn(`Read-only grants: ${r}`); ownershipIncomplete = true; }
|
|
1179
1182
|
}
|
|
1183
|
+
// The schema "public" posture. Reported only when there IS a gap: a PG15+ database (or one
|
|
1184
|
+
// already hardened) is the expected state and says nothing, exactly like a converged owner.
|
|
1185
|
+
// Absent entirely on a server that predates this check — also silent, never narrated as clean.
|
|
1186
|
+
const posture = owned.publicSchemaPosture;
|
|
1187
|
+
if (posture?.publicCanCreate) {
|
|
1188
|
+
if (posture.revoked) {
|
|
1189
|
+
// The SQL is spelled out rather than imported from @everystack/server: that package is a
|
|
1190
|
+
// lazily-loaded peer here (the ops-Lambda venue never loads it), so a static import for a
|
|
1191
|
+
// display string would be a new hard dependency for a sentence.
|
|
1192
|
+
success('Schema posture: REVOKE CREATE ON SCHEMA public FROM PUBLIC — the pre-PG15 default, which PG15 removed, is gone. Verified by re-reading the catalog.');
|
|
1193
|
+
} else {
|
|
1194
|
+
// NOT counted as ownershipIncomplete, deliberately. This is an advisory hardening on a live
|
|
1195
|
+
// database, and `--confirm` is a shared gate (an operator passes it for ADOPT). Failing a
|
|
1196
|
+
// provisioning run whose credentials succeeded, over a posture the operator has not been
|
|
1197
|
+
// asked about yet, would break every existing pre-PG15 deploy pipeline on upgrade.
|
|
1198
|
+
for (const r of posture.refusals) warn(`Schema posture: ${r}`);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1180
1201
|
};
|
|
1181
1202
|
|
|
1182
1203
|
// Additive-only: if the login roles already existed as live logins, provision changed no
|
package/src/cli/commands/diag.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* everystack diag [url] --hydration # Analyze SSR hydration issues
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { resolveConfig } from '../config';
|
|
16
|
+
import { resolveConfig, probeUrl } from '../config';
|
|
17
17
|
import { getFromS3 } from '../aws';
|
|
18
18
|
import { step, success, fail, info, warn } from '../output';
|
|
19
19
|
import { analyzeHtml, generateHydrationReport } from '../hydration-analyzer';
|
|
@@ -53,11 +53,11 @@ export async function diagCommand(
|
|
|
53
53
|
// Build the URL: positional arg > baseUrl + --path
|
|
54
54
|
let url = positionalUrl;
|
|
55
55
|
if (!url) {
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
url = probeUrl(config);
|
|
57
|
+
if (!url) {
|
|
58
|
+
fail('No URL provided and no site URL for this stage. Pass a URL, set CDN_URL on the API function, or use --stage on a deployed stage.');
|
|
58
59
|
process.exit(1);
|
|
59
60
|
}
|
|
60
|
-
url = config.baseUrl;
|
|
61
61
|
}
|
|
62
62
|
// Strip trailing slash from base URL before appending path
|
|
63
63
|
url = url.replace(/\/+$/, '');
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type ExtractedRun,
|
|
22
22
|
type LhReport,
|
|
23
23
|
} from '../lighthouse';
|
|
24
|
-
import { resolveConfig } from '../config';
|
|
24
|
+
import { resolveConfig, probeUrl } from '../config';
|
|
25
25
|
import { step, info, fail, warn } from '../output';
|
|
26
26
|
|
|
27
27
|
function runLighthouse(url: string, outPath: string): LhReport {
|
|
@@ -69,7 +69,7 @@ export async function auditLighthouseCommand(flags: Record<string, string>): Pro
|
|
|
69
69
|
if (!host) {
|
|
70
70
|
try {
|
|
71
71
|
const config = await resolveConfig(flags.stage);
|
|
72
|
-
host = config
|
|
72
|
+
host = probeUrl(config);
|
|
73
73
|
} catch (err: any) {
|
|
74
74
|
fail(err.message);
|
|
75
75
|
info('Pass --host <url> to audit a specific URL without resolving a stage.');
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type CatalogGrant,
|
|
27
27
|
type ProbeResult,
|
|
28
28
|
} from '../security-probe';
|
|
29
|
-
import { resolveConfig, opsFunction } from '../config';
|
|
29
|
+
import { resolveConfig, opsFunction, probeUrl } from '../config';
|
|
30
30
|
import { invokeAction } from '../aws';
|
|
31
31
|
import { step, info, fail, success, warn } from '../output';
|
|
32
32
|
|
|
@@ -188,7 +188,7 @@ export async function securityProbeCommand(flags: Record<string, string>): Promi
|
|
|
188
188
|
fail(err.message);
|
|
189
189
|
process.exit(1);
|
|
190
190
|
}
|
|
191
|
-
const base = (flags.host || config
|
|
191
|
+
const base = (flags.host || probeUrl(config)).replace(/\/+$/, '');
|
|
192
192
|
if (!base) {
|
|
193
193
|
fail('No router URL to probe. Pass --host or ensure the stage has a deployed URL.');
|
|
194
194
|
process.exit(1);
|
|
@@ -5,7 +5,8 @@ import zlib from 'node:zlib';
|
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
6
|
import { createWriteStream } from 'node:fs';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
|
-
import { resolveConfig, type CliConfig } from '../config';
|
|
8
|
+
import { resolveConfig, probeUrl, type CliConfig } from '../config';
|
|
9
|
+
import { missingHostUrlError } from '../host-url';
|
|
9
10
|
import { uploadToS3, headS3, invokeAction, rollingRestart } from '../aws';
|
|
10
11
|
import { step, success, warn, fail, info } from '../output';
|
|
11
12
|
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
|
|
@@ -128,6 +129,13 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
128
129
|
warn(`Could not load SST secrets: ${err.message}`);
|
|
129
130
|
info('Secrets may be unavailable during export. Provide env vars from the caller if needed.');
|
|
130
131
|
}
|
|
132
|
+
|
|
133
|
+
// After the secrets step: a secret may supply HOST_URL.
|
|
134
|
+
const noHostUrl = await missingHostUrlError(flags.stage, { siteUrl: resolvedConfig?.siteUrl });
|
|
135
|
+
if (noHostUrl) {
|
|
136
|
+
fail(noHostUrl);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
const branch = flags.branch;
|
|
@@ -639,13 +647,17 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
639
647
|
|
|
640
648
|
// 7. Verify the origin is serving the new release.
|
|
641
649
|
step('Verifying origin serves new release...');
|
|
642
|
-
const
|
|
650
|
+
const siteUrl = probeUrl(config);
|
|
651
|
+
const verified = await verifyOriginRelease(siteUrl, updateId);
|
|
643
652
|
if (verified) {
|
|
644
653
|
success(`Origin confirmed: ${updateId.slice(0, 12)}...`);
|
|
654
|
+
} else if (!siteUrl) {
|
|
655
|
+
warn('No site URL for this stage, so the origin was not checked.');
|
|
656
|
+
info('Set CDN_URL on the API function, or check the site by hand for the x-everystack-release header.');
|
|
645
657
|
} else {
|
|
646
658
|
warn('Origin did not confirm the new release within the timeout.');
|
|
647
659
|
info('The release may still be propagating. Check manually:');
|
|
648
|
-
info(` curl -sI ${
|
|
660
|
+
info(` curl -sI ${siteUrl} | grep x-everystack-release`);
|
|
649
661
|
}
|
|
650
662
|
|
|
651
663
|
// Clean up archive
|
|
@@ -822,6 +834,8 @@ export async function verifyOriginRelease(
|
|
|
822
834
|
baseUrl: string,
|
|
823
835
|
expectedUpdateId: string,
|
|
824
836
|
): Promise<boolean> {
|
|
837
|
+
// No site URL: every probe would throw and be swallowed, burning the whole timeout.
|
|
838
|
+
if (!baseUrl) return false;
|
|
825
839
|
const deadline = Date.now() + VERIFY_TIMEOUT_MS;
|
|
826
840
|
let consecutive = 0;
|
|
827
841
|
|
package/src/cli/config.ts
CHANGED
|
@@ -12,7 +12,10 @@ import path from 'node:path';
|
|
|
12
12
|
import { step } from './output';
|
|
13
13
|
|
|
14
14
|
export interface CliConfig {
|
|
15
|
+
/** CDN_URL / routerUrl as deployed. update pins it into OTA bundles: never derived. */
|
|
15
16
|
baseUrl: string;
|
|
17
|
+
/** What cache:purge and update's release check probe. Falls back to baseUrl when absent. */
|
|
18
|
+
siteUrl?: string;
|
|
16
19
|
region: string;
|
|
17
20
|
apiFunctionName: string;
|
|
18
21
|
/** Dedicated operator function (db/console actions). Falls back to apiFunctionName. */
|
|
@@ -30,6 +33,14 @@ export interface CliConfig {
|
|
|
30
33
|
backupsBucket?: string;
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The URL commands PROBE (release checks, audits, lighthouse): siteUrl, else baseUrl, else ''.
|
|
38
|
+
* Never use it to write a URL into anything shipped; that is baseUrl's job (HOST_URL).
|
|
39
|
+
*/
|
|
40
|
+
export function probeUrl(config: Pick<CliConfig, 'baseUrl' | 'siteUrl'>): string {
|
|
41
|
+
return config.siteUrl || config.baseUrl || '';
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
/** Operator invoke target — dedicated ops function when deployed, else the API function. */
|
|
34
45
|
export function opsFunction(config: CliConfig): string {
|
|
35
46
|
return config.opsFunctionName ?? config.apiFunctionName;
|
|
@@ -81,6 +92,22 @@ async function readLocalOutputs(): Promise<Partial<SstOutputs> | null> {
|
|
|
81
92
|
}
|
|
82
93
|
}
|
|
83
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The local outputs.json, only if its deploy WAS this stage. It is the last deploy on ANY stage,
|
|
97
|
+
* and databaseInstanceId picks db:apply/db:swap's confirmed rollback snapshot, so another
|
|
98
|
+
* stage's file must never reach a stage's config. Unknown means no.
|
|
99
|
+
*/
|
|
100
|
+
async function readOutputsFor(stage: string): Promise<Partial<SstOutputs> | null> {
|
|
101
|
+
const outputs = await readLocalOutputs();
|
|
102
|
+
if (!outputs) return null;
|
|
103
|
+
try {
|
|
104
|
+
const { parseAppName, outputsDescribeStage } = await import('./discover');
|
|
105
|
+
return outputsDescribeStage(outputs, await parseAppName(), stage) ? outputs : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
85
112
|
const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
86
113
|
|
|
@@ -95,7 +122,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
|
95
122
|
|
|
96
123
|
// Check cache first
|
|
97
124
|
const cached = await getCachedConfig(stage);
|
|
98
|
-
if (cached) return enrichConfigFromOutputs(cached, await
|
|
125
|
+
if (cached) return enrichConfigFromOutputs(cached, await readOutputsFor(stage));
|
|
99
126
|
|
|
100
127
|
step(`Discovering resources for stage "${stage}"...`);
|
|
101
128
|
const appName = await parseAppName();
|
|
@@ -103,7 +130,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
|
103
130
|
|
|
104
131
|
// Cache for future fast lookups
|
|
105
132
|
await setCachedConfig(stage, config);
|
|
106
|
-
return enrichConfigFromOutputs(config, await
|
|
133
|
+
return enrichConfigFromOutputs(config, await readOutputsFor(stage));
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
// Default: read .sst/outputs.json (unchanged behavior)
|
|
@@ -140,6 +167,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
|
140
167
|
|
|
141
168
|
return {
|
|
142
169
|
baseUrl: outputs.routerUrl,
|
|
170
|
+
siteUrl: outputs.routerUrl,
|
|
143
171
|
region,
|
|
144
172
|
apiFunctionName: outputs.apiFunctionName,
|
|
145
173
|
opsFunctionName: outputs.opsFunctionName,
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* SST Routers do not put the app/stage in the distribution Comment, so
|
|
8
8
|
* matching runs in order of reliability:
|
|
9
9
|
* 1. DomainName / Aliases match the discovered router URL host
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
10
|
+
* 2. sst:app + sst:stage tags (authoritative, but one API call per candidate)
|
|
11
|
+
* 3. Comment contains the {app}-{stage}- prefix (legacy setups; a substring, so a last resort)
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
export interface DistributionHints {
|
|
@@ -26,35 +26,69 @@ interface DistributionSummaryLite {
|
|
|
26
26
|
Aliases?: { Items?: string[] };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
/**
|
|
30
|
+
* A distribution and how it was found. `host` and `tag` are confirmed matches; `comment` is a
|
|
31
|
+
* substring guess (prefix `app-dev-` also matches the Comment of `my-app`'s dev stage), good
|
|
32
|
+
* enough for --invalidate but not to name the site a purge probes.
|
|
33
|
+
*/
|
|
34
|
+
export interface DistributionMatch {
|
|
35
|
+
id: string;
|
|
36
|
+
/** cloudfront.net domain, no scheme — the host this distribution always answers on. */
|
|
37
|
+
domain?: string;
|
|
38
|
+
by: 'host' | 'comment' | 'tag';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toMatch(dist: DistributionSummaryLite, by: DistributionMatch['by']): DistributionMatch | undefined {
|
|
42
|
+
return dist.Id ? { id: dist.Id, domain: dist.DomainName, by } : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function matchByHost(items: readonly DistributionSummaryLite[], routerHost?: string): DistributionMatch | undefined {
|
|
35
46
|
const host = routerHost?.toLowerCase();
|
|
36
|
-
if (host)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
if (!host) return undefined;
|
|
48
|
+
for (const dist of items) {
|
|
49
|
+
const hit =
|
|
50
|
+
dist.DomainName?.toLowerCase() === host ||
|
|
51
|
+
dist.Aliases?.Items?.some((alias) => alias.toLowerCase() === host);
|
|
52
|
+
if (hit) return toMatch(dist, 'host');
|
|
41
53
|
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function matchByComment(items: readonly DistributionSummaryLite[], prefix: string): DistributionMatch | undefined {
|
|
42
58
|
const needle = prefix.toLowerCase();
|
|
43
59
|
for (const dist of items) {
|
|
44
|
-
if ((dist.Comment || '').toLowerCase().includes(needle)) return dist
|
|
60
|
+
if ((dist.Comment || '').toLowerCase().includes(needle)) return toMatch(dist, 'comment');
|
|
45
61
|
}
|
|
46
62
|
return undefined;
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
/** Pure matcher for the non-tag passes (domain, alias, legacy comment). Exported for tests. */
|
|
66
|
+
export function matchDistributionBy(
|
|
67
|
+
items: readonly DistributionSummaryLite[],
|
|
68
|
+
prefix: string,
|
|
69
|
+
routerHost?: string,
|
|
70
|
+
): DistributionMatch | undefined {
|
|
71
|
+
return matchByHost(items, routerHost) ?? matchByComment(items, prefix);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The distribution id only. Exported for tests. */
|
|
75
|
+
export function matchDistribution(
|
|
76
|
+
items: readonly DistributionSummaryLite[],
|
|
77
|
+
prefix: string,
|
|
78
|
+
routerHost?: string,
|
|
79
|
+
): string | undefined {
|
|
80
|
+
return matchDistributionBy(items, prefix, routerHost)?.id;
|
|
81
|
+
}
|
|
82
|
+
|
|
49
83
|
/**
|
|
50
|
-
* Find the CloudFront distribution for a stage.
|
|
84
|
+
* Find the CloudFront distribution for a stage, and how it was matched.
|
|
51
85
|
* Best-effort: returns undefined if not found.
|
|
52
86
|
*/
|
|
53
|
-
export async function
|
|
87
|
+
export async function discoverDistributionMatch(
|
|
54
88
|
region: string,
|
|
55
89
|
prefix: string,
|
|
56
90
|
hints: DistributionHints = {},
|
|
57
|
-
): Promise<
|
|
91
|
+
): Promise<DistributionMatch | undefined> {
|
|
58
92
|
const { CloudFrontClient, ListDistributionsCommand, ListTagsForResourceCommand } =
|
|
59
93
|
await import('@aws-sdk/client-cloudfront');
|
|
60
94
|
const client = new CloudFrontClient({ region });
|
|
@@ -69,10 +103,11 @@ export async function discoverDistribution(
|
|
|
69
103
|
marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined;
|
|
70
104
|
} while (marker);
|
|
71
105
|
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
106
|
+
const byHost = matchByHost(items, hints.routerHost);
|
|
107
|
+
if (byHost) return byHost;
|
|
74
108
|
|
|
75
|
-
// Tag pass — authoritative
|
|
109
|
+
// Tag pass — authoritative, so it runs before the Comment guess (a Comment substring can name
|
|
110
|
+
// another app's stage). One call per distribution; SST tags every distribution it makes.
|
|
76
111
|
if (hints.appName && hints.stage) {
|
|
77
112
|
for (const dist of items) {
|
|
78
113
|
if (!dist.ARN) continue;
|
|
@@ -82,7 +117,7 @@ export async function discoverDistribution(
|
|
|
82
117
|
(res.Tags?.Items || []).map((t) => [t.Key, t.Value]),
|
|
83
118
|
);
|
|
84
119
|
if (tags.get('sst:app') === hints.appName && tags.get('sst:stage') === hints.stage) {
|
|
85
|
-
return dist
|
|
120
|
+
return toMatch(dist, 'tag');
|
|
86
121
|
}
|
|
87
122
|
} catch {
|
|
88
123
|
// Tag read denied or transient — keep scanning
|
|
@@ -90,5 +125,6 @@ export async function discoverDistribution(
|
|
|
90
125
|
}
|
|
91
126
|
}
|
|
92
127
|
|
|
93
|
-
|
|
128
|
+
// Legacy setups whose distribution carries the {app}-{stage}- prefix in its Comment.
|
|
129
|
+
return matchByComment(items, prefix);
|
|
94
130
|
}
|
package/src/cli/discover.ts
CHANGED
|
@@ -17,7 +17,16 @@ import { importOptionalAws } from './aws';
|
|
|
17
17
|
|
|
18
18
|
/** Mirrors CliConfig from config.ts — defined locally to avoid circular imports. */
|
|
19
19
|
interface DiscoveredConfig {
|
|
20
|
+
/**
|
|
21
|
+
* CDN_URL from the API Lambda, or ''. update pins this into shipped OTA bundles (HOST_URL),
|
|
22
|
+
* so it is NEVER derived from anything else.
|
|
23
|
+
*/
|
|
20
24
|
baseUrl: string;
|
|
25
|
+
/**
|
|
26
|
+
* The URL cache:purge and update's release check probe: baseUrl, else the domain of the stage's
|
|
27
|
+
* tag-confirmed CloudFront distribution, else ''. Probe-only; never shipped.
|
|
28
|
+
*/
|
|
29
|
+
siteUrl: string;
|
|
21
30
|
region: string;
|
|
22
31
|
apiFunctionName: string;
|
|
23
32
|
opsFunctionName?: string;
|
|
@@ -62,6 +71,22 @@ export async function parseAppName(configPath?: string): Promise<string> {
|
|
|
62
71
|
return match[1];
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Does a deploy-written .sst/outputs.json describe this stage? The file is the last deploy on ANY
|
|
76
|
+
* stage and has no stage field, but its apiFunctionName is `<app>-<stage>-ApiFunction-<hash>`
|
|
77
|
+
* (the convention discovery matches on). `.sst/stage` is NOT evidence: it is SST's personal-stage
|
|
78
|
+
* marker and can name a different stage than the last deploy. Unknown means no.
|
|
79
|
+
*/
|
|
80
|
+
export function outputsDescribeStage(
|
|
81
|
+
outputs: { apiFunctionName?: string } | null | undefined,
|
|
82
|
+
appName: string,
|
|
83
|
+
stage: string,
|
|
84
|
+
): boolean {
|
|
85
|
+
const fn = outputs?.apiFunctionName;
|
|
86
|
+
if (!fn) return false;
|
|
87
|
+
return fn.toLowerCase().startsWith(`${appName}-${stage}-ApiFunction-`.toLowerCase());
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
// ---------------------------------------------------------------------------
|
|
66
91
|
// CloudFront function discovery (→ KVS ARN)
|
|
67
92
|
// ---------------------------------------------------------------------------
|
|
@@ -284,20 +309,30 @@ export async function discoverConfig(
|
|
|
284
309
|
|
|
285
310
|
// Distribution discovery is best-effort (status CDN section, cache:purge --invalidate)
|
|
286
311
|
let distributionId: string | undefined;
|
|
312
|
+
let distributionDomain: string | undefined;
|
|
287
313
|
try {
|
|
288
|
-
const {
|
|
314
|
+
const { discoverDistributionMatch } = await import('./discover-distribution');
|
|
289
315
|
const routerHost = lambdaResult.routerUrl
|
|
290
316
|
? new URL(lambdaResult.routerUrl).host
|
|
291
317
|
: undefined;
|
|
292
|
-
|
|
318
|
+
const match = await discoverDistributionMatch(region, prefix, {
|
|
293
319
|
routerHost,
|
|
294
320
|
appName,
|
|
295
321
|
stage,
|
|
296
322
|
});
|
|
323
|
+
distributionId = match?.id;
|
|
324
|
+
// A Comment-substring match can name another app's stage; only a confirmed one may say
|
|
325
|
+
// which site a purge probes.
|
|
326
|
+
if (match && match.by !== 'comment') distributionDomain = match.domain;
|
|
297
327
|
} catch {
|
|
298
328
|
// Non-fatal
|
|
299
329
|
}
|
|
300
330
|
|
|
331
|
+
// CDN_URL is optional on the API Lambda. Without it the stage's own distribution supplies the
|
|
332
|
+
// probe URL — and only that: baseUrl stays as deployed (see DiscoveredConfig).
|
|
333
|
+
const baseUrl = lambdaResult.routerUrl || '';
|
|
334
|
+
const siteUrl = baseUrl || (distributionDomain ? `https://${distributionDomain}` : '');
|
|
335
|
+
|
|
301
336
|
if (!bucketsResult.updatesBucket) {
|
|
302
337
|
throw new Error(
|
|
303
338
|
`Could not find S3 bucket "${prefix}updatesbucket-*". ` +
|
|
@@ -320,15 +355,19 @@ export async function discoverConfig(
|
|
|
320
355
|
try {
|
|
321
356
|
const raw = await fs.readFile(path.resolve('.sst', 'outputs.json'), 'utf8');
|
|
322
357
|
const outputs = JSON.parse(raw);
|
|
323
|
-
|
|
324
|
-
if (
|
|
358
|
+
// outputs.json is the last deploy on ANY stage: adopt its buckets only if it was this one.
|
|
359
|
+
if (outputsDescribeStage(outputs, appName, stage)) {
|
|
360
|
+
if (!mediaBucket && outputs.mediaBucket) mediaBucket = outputs.mediaBucket;
|
|
361
|
+
if (!backupsBucket && outputs.backupsBucket) backupsBucket = outputs.backupsBucket;
|
|
362
|
+
}
|
|
325
363
|
} catch {
|
|
326
364
|
// outputs.json not available — non-fatal
|
|
327
365
|
}
|
|
328
366
|
}
|
|
329
367
|
|
|
330
368
|
return {
|
|
331
|
-
baseUrl
|
|
369
|
+
baseUrl,
|
|
370
|
+
siteUrl,
|
|
332
371
|
region,
|
|
333
372
|
apiFunctionName: lambdaResult.functionName,
|
|
334
373
|
opsFunctionName: opsFn,
|
|
@@ -348,12 +387,13 @@ export async function discoverConfig(
|
|
|
348
387
|
// ---------------------------------------------------------------------------
|
|
349
388
|
|
|
350
389
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
351
|
-
const CACHE_VERSION =
|
|
390
|
+
const CACHE_VERSION = 6; // Bump when CachedOutputs schema changes to invalidate stale caches (5: +backupsBucket, 6: +siteUrl; getCachedConfig drops v5 files, an empty siteUrl is cached as a fact)
|
|
352
391
|
|
|
353
392
|
interface CachedOutputs {
|
|
354
393
|
_cachedAt: number;
|
|
355
394
|
_version?: number;
|
|
356
395
|
routerUrl?: string;
|
|
396
|
+
siteUrl?: string;
|
|
357
397
|
apiFunctionName?: string;
|
|
358
398
|
opsFunctionName?: string;
|
|
359
399
|
imageFunctionName?: string;
|
|
@@ -381,6 +421,7 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
|
|
|
381
421
|
}
|
|
382
422
|
return {
|
|
383
423
|
baseUrl: cached.routerUrl || '',
|
|
424
|
+
siteUrl: cached.siteUrl ?? cached.routerUrl ?? '',
|
|
384
425
|
region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
|
|
385
426
|
apiFunctionName: cached.apiFunctionName,
|
|
386
427
|
opsFunctionName: cached.opsFunctionName,
|
|
@@ -458,6 +499,7 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
|
|
|
458
499
|
_cachedAt: Date.now(),
|
|
459
500
|
_version: CACHE_VERSION,
|
|
460
501
|
routerUrl: config.baseUrl,
|
|
502
|
+
siteUrl: config.siteUrl,
|
|
461
503
|
apiFunctionName: config.apiFunctionName,
|
|
462
504
|
opsFunctionName: config.opsFunctionName,
|
|
463
505
|
imageFunctionName: config.imageFunctionName,
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
|
|
27
27
|
import { createHash } from 'node:crypto';
|
|
28
28
|
import type { ModelDescriptor } from '@everystack/model';
|
|
29
|
-
import type { SchemaSnapshot } from './schema-introspect';
|
|
29
|
+
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
30
30
|
import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
|
|
31
|
-
import { ownSequenceName } from './schema-diff';
|
|
31
|
+
import { ownSequenceName, foreignKeyContentKey } from './schema-diff';
|
|
32
32
|
import { generateMigrationSql, unmodeledTables } from './migration-generate';
|
|
33
33
|
import { compileTableContract } from './authz-compile';
|
|
34
34
|
import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
|
|
@@ -73,8 +73,19 @@ export const PLAN_VERSION = 2;
|
|
|
73
73
|
* stage would skip the preflight and run the VALIDATE inside the edge — a silently dropped gate
|
|
74
74
|
* and a different failure mode, both of which the BUMP RULE covers. Peers refuse until the stage
|
|
75
75
|
* is redeployed.
|
|
76
|
+
*
|
|
77
|
+
* v5 (validation-pending stops refusing the whole edge): `ApplyPlanResult` gained `pendingIsSafe`
|
|
78
|
+
* and `unappliedStatements` — the CLIENT now decides, from `pendingIsSafe`, whether a
|
|
79
|
+
* `validation-pending` result exits 0 (a withheld or ordinary-orphan cause) or 1 (a cut-off or
|
|
80
|
+
* unrecognised cause, a real anomaly). A v4 ops Lambda's `executeApplyPlan` never sets
|
|
81
|
+
* `pendingIsSafe` at all, so a v5 client reading its response would see it absent, treat every
|
|
82
|
+
* pending result as UNSAFE by the same fail-closed rule that protects the in-version case, and
|
|
83
|
+
* exit 1 on a perfectly safe withheld key — the exact false CI failure this whole feature exists
|
|
84
|
+
* to remove, reintroduced silently across a version skew instead of refused cleanly. The BUMP
|
|
85
|
+
* RULE covers exactly this: a field the CLIENT now depends on to decide something is part of the
|
|
86
|
+
* contract. Peers refuse until the stage is redeployed.
|
|
76
87
|
*/
|
|
77
|
-
export const APPLY_CONTRACT_VERSION =
|
|
88
|
+
export const APPLY_CONTRACT_VERSION = 5;
|
|
78
89
|
|
|
79
90
|
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
|
|
80
91
|
export interface PlanClassification {
|
|
@@ -225,6 +236,36 @@ export function predictLiveFingerprint(
|
|
|
225
236
|
return predictLiveState(models, snapshot, contract, opts).hash;
|
|
226
237
|
}
|
|
227
238
|
|
|
239
|
+
/**
|
|
240
|
+
* One declared table with its `validate: false` foreign keys' VALIDITY taken from live.
|
|
241
|
+
*
|
|
242
|
+
* A key the model declared `validate: false` compiles to `notValid: true`, and that is right for
|
|
243
|
+
* a from-scratch build: nothing validates it, so the built key really is NOT VALID. But the
|
|
244
|
+
* PREDICTED endpoint is not a build, it is "the live database after this edge" — and the edge
|
|
245
|
+
* emits nothing at all for such a key when the live one already content-matches (see
|
|
246
|
+
* `diffConstraints`: a live NOT VALID key needs no statement, and a live FULLY-VALID key is the
|
|
247
|
+
* accept-either case). Predicting NOT VALID over a key the database has already validated would
|
|
248
|
+
* make the plan's own verify-after fail on an edge that touched nothing, on every apply, forever.
|
|
249
|
+
*
|
|
250
|
+
* So the prediction copies what the key IS. Untouched for every other key: absent live table,
|
|
251
|
+
* absent live counterpart, or a declaration that never said `validate: false` — those predict
|
|
252
|
+
* exactly what they always did.
|
|
253
|
+
*/
|
|
254
|
+
function carryFkValidityThrough(declared: TableSchema, live: TableSchema | undefined): TableSchema {
|
|
255
|
+
if (!live || !declared.foreignKeys.some((fk) => fk.notValid)) return declared;
|
|
256
|
+
const liveByContent = new Map(live.foreignKeys.map((fk) => [foreignKeyContentKey(fk), fk]));
|
|
257
|
+
return {
|
|
258
|
+
...declared,
|
|
259
|
+
foreignKeys: declared.foreignKeys.map((fk) => {
|
|
260
|
+
if (!fk.notValid) return fk;
|
|
261
|
+
const counterpart = liveByContent.get(foreignKeyContentKey(fk));
|
|
262
|
+
if (!counterpart || counterpart.notValid) return fk;
|
|
263
|
+
const { notValid: _dropped, ...rest } = fk;
|
|
264
|
+
return rest;
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
228
269
|
/**
|
|
229
270
|
* The predicted endpoint as a full {@link Fingerprint} (hash AND canonical form). The hash is
|
|
230
271
|
* the plan's `to` and the declares-test; the canonical form lets a MISMATCH report name WHICH
|
|
@@ -268,16 +309,6 @@ export function predictLiveState(
|
|
|
268
309
|
!declaredTables.has(table) && !pendingRenameSources.has(table) && !pendingMoveSources.has(table);
|
|
269
310
|
const declaredEnumList = declared.snapshot.enums ?? [];
|
|
270
311
|
const declaredEnums = new Set(declaredEnumList.map((e) => e.name));
|
|
271
|
-
const mergedSnapshot: SchemaSnapshot = {
|
|
272
|
-
tables: [
|
|
273
|
-
...declared.snapshot.tables,
|
|
274
|
-
...snapshot.tables.filter((t) => ridesThrough(t.table)),
|
|
275
|
-
],
|
|
276
|
-
enums: [
|
|
277
|
-
...declaredEnumList,
|
|
278
|
-
...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
|
|
279
|
-
],
|
|
280
|
-
};
|
|
281
312
|
// The live counterpart of a declared table: its rename or move source when the edge brings it
|
|
282
313
|
// in under a new name, else its own name. Grants, policies and owned sequences survive RENAME
|
|
283
314
|
// TABLE and SET SCHEMA alike (they attach by OID), so the foreign authz carries across both.
|
|
@@ -290,6 +321,17 @@ export function predictLiveState(
|
|
|
290
321
|
!currentNames.has(declaredTable) && currentNames.has(priorNameOf[declaredTable] ?? '')
|
|
291
322
|
? priorNameOf[declaredTable]
|
|
292
323
|
: declaredTable;
|
|
324
|
+
const liveSnapshotByTable = new Map(snapshot.tables.map((t) => [t.table, t]));
|
|
325
|
+
const mergedSnapshot: SchemaSnapshot = {
|
|
326
|
+
tables: [
|
|
327
|
+
...declared.snapshot.tables.map((t) => carryFkValidityThrough(t, liveSnapshotByTable.get(liveNameFor(t.table)))),
|
|
328
|
+
...snapshot.tables.filter((t) => ridesThrough(t.table)),
|
|
329
|
+
],
|
|
330
|
+
enums: [
|
|
331
|
+
...declaredEnumList,
|
|
332
|
+
...(snapshot.enums ?? []).filter((e) => !declaredEnums.has(e.name)),
|
|
333
|
+
],
|
|
334
|
+
};
|
|
293
335
|
const liveByTable = new Map(contract.tables.map((t) => [t.table, t]));
|
|
294
336
|
const declaredColumnsByTable = new Map(
|
|
295
337
|
declared.snapshot.tables.map((t) => [t.table, new Set(t.columns.map((c) => c.name))]),
|