rastack 0.0.62 → 0.0.64
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 +14 -0
- package/admin/AdminApp.tsx +56 -3
- package/admin/index.tsx +8 -2
- package/admin/ui/icons.tsx +9 -0
- package/admin/ui/styles.ts +3 -0
- package/admin/useAdmin.ts +41 -4
- package/auth/provider.ts +71 -8
- package/dist/admin.js +17 -14
- package/dist/compile/analyze.js +11 -1
- package/dist/define/index.d.ts +8 -1
- package/dist/define/index.js +2 -2
- package/dist/define/manifest.js +7 -1
- package/dist/deploy/ci.d.ts +10 -3
- package/dist/deploy/ci.js +22 -11
- package/dist/deploy/config.d.ts +155 -5
- package/dist/deploy/config.js +216 -6
- package/dist/deploy/io.d.ts +57 -0
- package/dist/deploy/io.js +141 -0
- package/dist/deploy/workflows.d.ts +30 -0
- package/dist/deploy/workflows.js +117 -12
- package/dist/rastack-bootstrap.js +18 -47
- package/dist/rastack-ci.js +19 -1
- package/dist/rastack-dev.js +48 -2
- package/dist/rastack-init.d.ts +6 -3
- package/dist/rastack-init.js +231 -92
- package/dist/rastack.js +1 -0
- package/dist/templates/cloudformation/app.yaml +133 -2
- package/dist/wasm/rastack_wasm.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/provider/provider.tsx +23 -0
- package/provider/types.ts +9 -0
- package/src/compile/analyze.ts +11 -1
- package/src/define/index.ts +5 -1
- package/src/define/manifest.ts +7 -1
- package/src/deploy/ci.ts +50 -16
- package/src/deploy/config.ts +340 -7
- package/src/deploy/io.ts +179 -2
- package/src/deploy/workflows.ts +122 -12
- package/src/rastack-bootstrap.ts +24 -69
- package/src/rastack-ci.ts +20 -2
- package/src/rastack-dev.ts +61 -6
- package/src/rastack-init.ts +283 -115
- package/src/rastack.ts +1 -0
package/src/deploy/config.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface DeployOutputs {
|
|
|
33
33
|
jwksUrl?: string;
|
|
34
34
|
/** S3 Iceberg warehouse bucket — the system of record. */
|
|
35
35
|
warehouseBucket?: string;
|
|
36
|
+
/** Cognito Hosted UI domain (scheme-less), for browser sign-in. */
|
|
37
|
+
userPoolDomain?: string;
|
|
38
|
+
/** Full Cognito Hosted UI base URL (`https://…`). */
|
|
39
|
+
hostedUiUrl?: string;
|
|
40
|
+
/** Base URL of the external-API proxy route, when the env provisions one. */
|
|
41
|
+
proxyUrl?: string;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export interface LambdaBuild {
|
|
@@ -44,6 +50,36 @@ export interface LambdaBuild {
|
|
|
44
50
|
features: string;
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Per-environment overrides. The **prod** environment defaults its values from
|
|
55
|
+
* the top-level config (its branch is `github.branch`, its stack is `stackName`,
|
|
56
|
+
* its outputs are the top-level `outputs`); the **dev** environment defaults to
|
|
57
|
+
* the `development` branch and a `-dev` stack. Anything set here wins over those
|
|
58
|
+
* defaults. Only the fields that actually differ per environment live here —
|
|
59
|
+
* `app`/`namePrefix`/`region`/`github.owner`/`github.repo` are shared.
|
|
60
|
+
*/
|
|
61
|
+
export interface EnvOverride {
|
|
62
|
+
/** GitHub branch whose push deploys this environment. */
|
|
63
|
+
branch?: string;
|
|
64
|
+
/** GitHub Actions **Environment** name (scoped Variables + protection rules). */
|
|
65
|
+
ghEnvironment?: string;
|
|
66
|
+
/** CloudFormation stack name for this environment. */
|
|
67
|
+
stackName?: string;
|
|
68
|
+
/** Iceberg namespace this environment's warehouse writes under. */
|
|
69
|
+
namespace?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Base URL of the upstream this environment's **external-API proxy** forwards
|
|
72
|
+
* to (typically a UAT or prod backend). When set, the stack provisions a
|
|
73
|
+
* Cognito-authorized `ANY /proxy/{proxy+}` route on the HTTP API that forwards
|
|
74
|
+
* to `${proxyUpstream}/{proxy}` — so a locally-running app (signed in against
|
|
75
|
+
* this environment's Cognito pool) reaches external APIs *only* through the
|
|
76
|
+
* proxy, never directly, and credentials stay server-side. Empty = no proxy.
|
|
77
|
+
*/
|
|
78
|
+
proxyUpstream?: string;
|
|
79
|
+
/** Values resolved from this environment's stack after it is created. */
|
|
80
|
+
outputs?: DeployOutputs;
|
|
81
|
+
}
|
|
82
|
+
|
|
47
83
|
export interface DeployConfig {
|
|
48
84
|
/** App slug — namespaces buckets/roles/SSM. Cognito-safe: `^[a-z0-9]{1,20}$`. */
|
|
49
85
|
app: string;
|
|
@@ -51,9 +87,16 @@ export interface DeployConfig {
|
|
|
51
87
|
namePrefix: string;
|
|
52
88
|
/** AWS region the stack (and CI) target. */
|
|
53
89
|
region: string;
|
|
54
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* CloudFormation stack name of the **prod** environment — defaults to
|
|
92
|
+
* `${namePrefix}-${app}`. Dev's stack defaults to this + `-dev`.
|
|
93
|
+
*/
|
|
55
94
|
stackName: string;
|
|
56
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* The GitHub repo whose CI is trusted to ship the Lambda code. `branch` is the
|
|
97
|
+
* **prod** deploy branch (default `master`); the dev branch defaults to
|
|
98
|
+
* `development` and is overridable under `environments.dev.branch`.
|
|
99
|
+
*/
|
|
57
100
|
github: { owner: string; repo: string; branch: string };
|
|
58
101
|
/** Iceberg namespace the warehouse writes under. */
|
|
59
102
|
namespace: string;
|
|
@@ -69,10 +112,130 @@ export interface DeployConfig {
|
|
|
69
112
|
artifactsBucket?: string;
|
|
70
113
|
/** Whether the function verifies the `access` or `id` token. */
|
|
71
114
|
tokenUse: "access" | "id";
|
|
72
|
-
/** Values resolved from the stack after it is created (optional). */
|
|
115
|
+
/** Values resolved from the (prod) stack after it is created (optional). */
|
|
116
|
+
outputs?: DeployOutputs;
|
|
117
|
+
/**
|
|
118
|
+
* Per-environment overrides. A `dev` + `prod` split is the default even with
|
|
119
|
+
* this omitted (see `resolveEnvironment`); this only records what a user
|
|
120
|
+
* changed (a custom dev branch, dev stack outputs, …).
|
|
121
|
+
*/
|
|
122
|
+
environments?: Partial<Record<EnvName, EnvOverride>>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The two deployment environments — dev (development branch) and prod (master). */
|
|
126
|
+
export type EnvName = "prod" | "dev";
|
|
127
|
+
export const ENV_NAMES: readonly EnvName[] = ["prod", "dev"] as const;
|
|
128
|
+
|
|
129
|
+
/** The fully-resolved shape of one environment (top-level + defaults + overrides). */
|
|
130
|
+
export interface EnvDef {
|
|
131
|
+
/** Which environment this is. */
|
|
132
|
+
name: EnvName;
|
|
133
|
+
/** Branch whose push deploys it (`master` for prod, `development` for dev). */
|
|
134
|
+
branch: string;
|
|
135
|
+
/** GitHub Actions Environment name (`production` / `development`). */
|
|
136
|
+
ghEnvironment: string;
|
|
137
|
+
/**
|
|
138
|
+
* The app.yaml `AppName` parameter — the infra namespace. Prod uses the app
|
|
139
|
+
* slug; dev uses `${app}dev` so its buckets/roles/pools/SSM never collide with
|
|
140
|
+
* prod's (AppName is Cognito-safe `^[a-z0-9]{1,20}$`, so the suffix is glued,
|
|
141
|
+
* not hyphenated).
|
|
142
|
+
*/
|
|
143
|
+
appName: string;
|
|
144
|
+
/** CloudFormation stack name for this environment. */
|
|
145
|
+
stackName: string;
|
|
146
|
+
/** Iceberg namespace this environment writes under. */
|
|
147
|
+
namespace: string;
|
|
148
|
+
/** Upstream the external-API proxy forwards to (empty when no proxy). */
|
|
149
|
+
proxyUpstream: string;
|
|
150
|
+
/** Values resolved from this environment's stack, when known. */
|
|
73
151
|
outputs?: DeployOutputs;
|
|
74
152
|
}
|
|
75
153
|
|
|
154
|
+
/** Defaults for the dev environment when `environments.dev` is unset. */
|
|
155
|
+
export const DEV_DEFAULTS = {
|
|
156
|
+
branch: "development",
|
|
157
|
+
ghEnvironment: "development",
|
|
158
|
+
namespace: "development",
|
|
159
|
+
} as const;
|
|
160
|
+
|
|
161
|
+
/** The GitHub Actions Environment name the prod deploy uses. */
|
|
162
|
+
export const PROD_GH_ENVIRONMENT = "production";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The app.yaml `AppName` for an environment — the infra namespace. Dev glues a
|
|
166
|
+
* `dev` suffix on so its stack's buckets/roles/pools/SSM are physically distinct
|
|
167
|
+
* from prod's without a hyphen (Cognito `AppName`/`IdentityPoolName` forbid one).
|
|
168
|
+
*/
|
|
169
|
+
export function envAppName(cfg: DeployConfig, env: EnvName): string {
|
|
170
|
+
return env === "dev" ? `${cfg.app}dev` : cfg.app;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve one environment to its full definition: the prod environment inherits
|
|
175
|
+
* the top-level branch/stack/namespace/outputs, the dev environment defaults to
|
|
176
|
+
* the `development` branch and a `-dev` stack, and anything under
|
|
177
|
+
* `cfg.environments[env]` overrides those.
|
|
178
|
+
*/
|
|
179
|
+
export function resolveEnvironment(cfg: DeployConfig, env: EnvName): EnvDef {
|
|
180
|
+
const o = cfg.environments?.[env] ?? {};
|
|
181
|
+
if (env === "prod") {
|
|
182
|
+
return {
|
|
183
|
+
name: "prod",
|
|
184
|
+
branch: o.branch ?? cfg.github.branch,
|
|
185
|
+
ghEnvironment: o.ghEnvironment ?? PROD_GH_ENVIRONMENT,
|
|
186
|
+
appName: cfg.app,
|
|
187
|
+
stackName: o.stackName ?? cfg.stackName,
|
|
188
|
+
namespace: o.namespace ?? cfg.namespace,
|
|
189
|
+
proxyUpstream: o.proxyUpstream ?? "",
|
|
190
|
+
outputs: o.outputs ?? cfg.outputs,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
name: "dev",
|
|
195
|
+
branch: o.branch ?? DEV_DEFAULTS.branch,
|
|
196
|
+
ghEnvironment: o.ghEnvironment ?? DEV_DEFAULTS.ghEnvironment,
|
|
197
|
+
appName: envAppName(cfg, "dev"),
|
|
198
|
+
stackName: o.stackName ?? (cfg.stackName ? `${cfg.stackName}-dev` : ""),
|
|
199
|
+
namespace: o.namespace ?? DEV_DEFAULTS.namespace,
|
|
200
|
+
proxyUpstream: o.proxyUpstream ?? "",
|
|
201
|
+
outputs: o.outputs,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Both environments, prod first, fully resolved. */
|
|
206
|
+
export function resolveEnvironments(cfg: DeployConfig): EnvDef[] {
|
|
207
|
+
return ENV_NAMES.map((e) => resolveEnvironment(cfg, e));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The browser-facing Cognito config for one environment — what `rastack dev`
|
|
212
|
+
* serves at `/__rastack/cognito.json` so a locally-running app can sign in
|
|
213
|
+
* against the real (dev) Cognito pool via the Hosted UI. Shaped to feed
|
|
214
|
+
* `cognitoRuntimeToConfig` in `rastack/auth`. Returns null until the
|
|
215
|
+
* environment's stack Outputs (client id + Hosted UI url) are known.
|
|
216
|
+
*/
|
|
217
|
+
export interface CognitoRuntimeConfig {
|
|
218
|
+
userPoolId?: string;
|
|
219
|
+
clientId: string;
|
|
220
|
+
/** Hosted UI base URL (`https://…amazoncognito.com`). */
|
|
221
|
+
hostedUiUrl: string;
|
|
222
|
+
region: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function cognitoRuntimeConfig(
|
|
226
|
+
cfg: DeployConfig,
|
|
227
|
+
env: EnvName,
|
|
228
|
+
): CognitoRuntimeConfig | null {
|
|
229
|
+
const o = resolveEnvironment(cfg, env).outputs;
|
|
230
|
+
if (!o?.userPoolClientId || !o?.hostedUiUrl) return null;
|
|
231
|
+
return {
|
|
232
|
+
userPoolId: o.userPoolId,
|
|
233
|
+
clientId: o.userPoolClientId,
|
|
234
|
+
hostedUiUrl: o.hostedUiUrl,
|
|
235
|
+
region: cfg.region,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
76
239
|
export const DEFAULT_LAMBDA: LambdaBuild = {
|
|
77
240
|
bin: "bootstrap",
|
|
78
241
|
arch: "arm64",
|
|
@@ -132,9 +295,31 @@ export function normalizeConfig(
|
|
|
132
295
|
artifactsBucket: partial.artifactsBucket?.trim() || undefined,
|
|
133
296
|
tokenUse: partial.tokenUse ?? DEFAULTS.tokenUse,
|
|
134
297
|
outputs: partial.outputs ? { ...partial.outputs } : undefined,
|
|
298
|
+
environments: normalizeEnvironments(partial.environments),
|
|
135
299
|
};
|
|
136
300
|
}
|
|
137
301
|
|
|
302
|
+
/** Copy through per-environment overrides, dropping an empty map to undefined. */
|
|
303
|
+
function normalizeEnvironments(
|
|
304
|
+
envs: PartialDeployConfig["environments"],
|
|
305
|
+
): DeployConfig["environments"] {
|
|
306
|
+
if (!envs) return undefined;
|
|
307
|
+
const out: Partial<Record<EnvName, EnvOverride>> = {};
|
|
308
|
+
for (const name of ENV_NAMES) {
|
|
309
|
+
const o = envs[name];
|
|
310
|
+
if (!o) continue;
|
|
311
|
+
const entry: EnvOverride = {};
|
|
312
|
+
if (o.branch?.trim()) entry.branch = o.branch.trim();
|
|
313
|
+
if (o.ghEnvironment?.trim()) entry.ghEnvironment = o.ghEnvironment.trim();
|
|
314
|
+
if (o.stackName?.trim()) entry.stackName = o.stackName.trim();
|
|
315
|
+
if (o.namespace?.trim()) entry.namespace = o.namespace.trim();
|
|
316
|
+
if (o.proxyUpstream?.trim()) entry.proxyUpstream = o.proxyUpstream.trim();
|
|
317
|
+
if (o.outputs) entry.outputs = { ...o.outputs };
|
|
318
|
+
if (Object.keys(entry).length) out[name] = entry;
|
|
319
|
+
}
|
|
320
|
+
return Object.keys(out).length ? out : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
138
323
|
const SLUG = /^[a-z0-9]{1,20}$/;
|
|
139
324
|
const REGION = /^[a-z]{2}-[a-z]+-\d+$/;
|
|
140
325
|
const GH_NAME = /^[A-Za-z0-9_.-]+$/;
|
|
@@ -152,6 +337,14 @@ export function validateConfig(cfg: DeployConfig): string[] {
|
|
|
152
337
|
errors.push(
|
|
153
338
|
`app "${cfg.app}" must match ${SLUG} (lowercase letters/digits, 1–20).`,
|
|
154
339
|
);
|
|
340
|
+
// The dev environment appends "dev" to the app slug for its infra namespace
|
|
341
|
+
// (buckets/roles/pools/SSM). That derived name must still be a valid slug, so
|
|
342
|
+
// the app slug is effectively capped at 17 chars once a dev env is in play.
|
|
343
|
+
else if (!SLUG.test(envAppName(cfg, "dev")))
|
|
344
|
+
errors.push(
|
|
345
|
+
`app "${cfg.app}" is too long: the dev environment uses "${envAppName(cfg, "dev")}" ` +
|
|
346
|
+
`for its infra names, which must still match ${SLUG} (so app ≤ 17 chars).`,
|
|
347
|
+
);
|
|
155
348
|
|
|
156
349
|
if (!SLUG.test(cfg.namePrefix))
|
|
157
350
|
errors.push(`namePrefix "${cfg.namePrefix}" must match ${SLUG}.`);
|
|
@@ -226,9 +419,31 @@ export function mergeConfig(
|
|
|
226
419
|
github: { ...base.github, ...patch.github },
|
|
227
420
|
lambda: { ...base.lambda, ...patch.lambda },
|
|
228
421
|
outputs: { ...base.outputs, ...patch.outputs },
|
|
422
|
+
environments: mergeEnvironments(base.environments, patch.environments),
|
|
229
423
|
});
|
|
230
424
|
}
|
|
231
425
|
|
|
426
|
+
/** Deep-merge per-environment overrides (per env, and per env's outputs). */
|
|
427
|
+
function mergeEnvironments(
|
|
428
|
+
base: DeployConfig["environments"],
|
|
429
|
+
patch: PartialDeployConfig["environments"],
|
|
430
|
+
): DeployConfig["environments"] {
|
|
431
|
+
if (!base && !patch) return undefined;
|
|
432
|
+
const out: Partial<Record<EnvName, EnvOverride>> = {};
|
|
433
|
+
for (const name of ENV_NAMES) {
|
|
434
|
+
const b = base?.[name];
|
|
435
|
+
const p = patch?.[name];
|
|
436
|
+
if (!b && !p) continue;
|
|
437
|
+
out[name] = {
|
|
438
|
+
...b,
|
|
439
|
+
...p,
|
|
440
|
+
outputs:
|
|
441
|
+
b?.outputs || p?.outputs ? { ...b?.outputs, ...p?.outputs } : undefined,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
return Object.keys(out).length ? out : undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
232
447
|
/**
|
|
233
448
|
* Recover `{ owner, repo }` from a GitHub remote URL — both the SSH
|
|
234
449
|
* (`git@github.com:owner/repo.git`) and HTTPS
|
|
@@ -314,6 +529,40 @@ export function stackParameters(cfg: DeployConfig): StackParameter[] {
|
|
|
314
529
|
return params;
|
|
315
530
|
}
|
|
316
531
|
|
|
532
|
+
/**
|
|
533
|
+
* The app.yaml `--parameter-overrides` for a **specific environment** (dev or
|
|
534
|
+
* prod). Same as `stackParameters` but the AppName/branch/namespace come from the
|
|
535
|
+
* resolved environment, and it also passes `GitHubEnvironment` so the stack's
|
|
536
|
+
* deploy-role trust is scoped to the GitHub Actions Environment (the OIDC subject
|
|
537
|
+
* for an environment-gated job is `…:environment:<env>`, not `…:ref:…`). Prod's
|
|
538
|
+
* AppName is the bare slug, so prod's resource names are byte-for-byte what the
|
|
539
|
+
* pre-environment template produced.
|
|
540
|
+
*/
|
|
541
|
+
export function stackParametersForEnv(
|
|
542
|
+
cfg: DeployConfig,
|
|
543
|
+
env: EnvName,
|
|
544
|
+
): StackParameter[] {
|
|
545
|
+
const e = resolveEnvironment(cfg, env);
|
|
546
|
+
const params: StackParameter[] = [
|
|
547
|
+
{ key: "AppName", value: e.appName },
|
|
548
|
+
{ key: "NamePrefix", value: cfg.namePrefix },
|
|
549
|
+
{ key: "GitHubOwner", value: cfg.github.owner },
|
|
550
|
+
{ key: "GitHubRepo", value: cfg.github.repo },
|
|
551
|
+
{ key: "GitHubBranch", value: e.branch },
|
|
552
|
+
{ key: "GitHubEnvironment", value: e.ghEnvironment },
|
|
553
|
+
{ key: "IcebergNamespace", value: e.namespace },
|
|
554
|
+
];
|
|
555
|
+
if (cfg.artifactsBucket)
|
|
556
|
+
params.push({ key: "ArtifactsBucket", value: cfg.artifactsBucket });
|
|
557
|
+
// The external-API proxy is provisioned only when an upstream is configured
|
|
558
|
+
// (typically dev → a UAT/prod backend). Empty leaves the route/authorizer out.
|
|
559
|
+
if (e.proxyUpstream)
|
|
560
|
+
params.push({ key: "ExternalApiUpstream", value: e.proxyUpstream });
|
|
561
|
+
if (!cognitoAdvancedSecuritySupported(cfg.region))
|
|
562
|
+
params.push({ key: "AdvancedSecurity", value: "OFF" });
|
|
563
|
+
return params;
|
|
564
|
+
}
|
|
565
|
+
|
|
317
566
|
/**
|
|
318
567
|
* The canonical repo-relative CloudFormation template paths. These are the paths
|
|
319
568
|
* in the *framework* repo (theserverkid/reactapistack) and the default the argv
|
|
@@ -399,6 +648,29 @@ export function deployStackCommand(
|
|
|
399
648
|
return awsCommand(deployStackArgs(cfg, templateFile));
|
|
400
649
|
}
|
|
401
650
|
|
|
651
|
+
/** The `aws cloudformation deploy` argv for one environment's stack (dev/prod). */
|
|
652
|
+
export function deployStackArgsForEnv(
|
|
653
|
+
cfg: DeployConfig,
|
|
654
|
+
env: EnvName,
|
|
655
|
+
templateFile: string = APP_TEMPLATE_FILE,
|
|
656
|
+
): string[] {
|
|
657
|
+
return cfnDeployArgs(
|
|
658
|
+
templateFile,
|
|
659
|
+
resolveEnvironment(cfg, env).stackName,
|
|
660
|
+
cfg.region,
|
|
661
|
+
stackParametersForEnv(cfg, env),
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** The single-line `aws cloudformation deploy` command for one environment. */
|
|
666
|
+
export function deployStackCommandForEnv(
|
|
667
|
+
cfg: DeployConfig,
|
|
668
|
+
env: EnvName,
|
|
669
|
+
templateFile: string = APP_TEMPLATE_FILE,
|
|
670
|
+
): string {
|
|
671
|
+
return awsCommand(deployStackArgsForEnv(cfg, env, templateFile));
|
|
672
|
+
}
|
|
673
|
+
|
|
402
674
|
/**
|
|
403
675
|
* The tier-0 bootstrap stack (bootstrap.yaml) — the S3 buckets that hold the
|
|
404
676
|
* Lambda placeholder + hosted templates, and the GitHub OIDC provider. One per
|
|
@@ -470,6 +742,9 @@ export const APP_STACK_OUTPUTS: { key: string; field: keyof DeployOutputs }[] =
|
|
|
470
742
|
{ key: "UserPoolClientId", field: "userPoolClientId" },
|
|
471
743
|
{ key: "JwksUrl", field: "jwksUrl" },
|
|
472
744
|
{ key: "WarehouseBucketName", field: "warehouseBucket" },
|
|
745
|
+
{ key: "UserPoolDomain", field: "userPoolDomain" },
|
|
746
|
+
{ key: "HostedUiUrl", field: "hostedUiUrl" },
|
|
747
|
+
{ key: "ProxyUrl", field: "proxyUrl" },
|
|
473
748
|
];
|
|
474
749
|
|
|
475
750
|
/** GitHub repo Variable to wire (name + resolved value, if known from outputs). */
|
|
@@ -477,13 +752,20 @@ export interface RepoVariable {
|
|
|
477
752
|
name: string;
|
|
478
753
|
value: string;
|
|
479
754
|
from: string;
|
|
755
|
+
/**
|
|
756
|
+
* The GitHub Actions **Environment** this Variable is scoped to (`development`
|
|
757
|
+
* / `production`). When set, `init` wires it under that environment so the
|
|
758
|
+
* dev and prod deploys each read their own role/region.
|
|
759
|
+
*/
|
|
760
|
+
ghEnvironment?: string;
|
|
480
761
|
}
|
|
481
762
|
|
|
482
763
|
/**
|
|
483
764
|
* The GitHub repo **Variables** CI reads — `AWS_DEPLOY_ROLE_ARN` and
|
|
484
765
|
* `AWS_REGION` (see docs/serverless-deploy.md §3). Values come from the stack
|
|
485
766
|
* Outputs when already resolved, else an empty string to fill in from the
|
|
486
|
-
* Console.
|
|
767
|
+
* Console. This is the legacy single-environment view (the prod stack); the
|
|
768
|
+
* dev/prod split wires them per GitHub Environment via `repoVariablesForEnv`.
|
|
487
769
|
*/
|
|
488
770
|
export function repoVariables(cfg: DeployConfig): RepoVariable[] {
|
|
489
771
|
return [
|
|
@@ -496,6 +778,33 @@ export function repoVariables(cfg: DeployConfig): RepoVariable[] {
|
|
|
496
778
|
];
|
|
497
779
|
}
|
|
498
780
|
|
|
781
|
+
/**
|
|
782
|
+
* The same two Variables, scoped to one environment's GitHub Actions
|
|
783
|
+
* Environment. The dev deploy reads `development`'s copies, the prod deploy
|
|
784
|
+
* `production`'s — so the two never cross-assume each other's role. Values come
|
|
785
|
+
* from the environment's own stack Outputs.
|
|
786
|
+
*/
|
|
787
|
+
export function repoVariablesForEnv(
|
|
788
|
+
cfg: DeployConfig,
|
|
789
|
+
env: EnvName,
|
|
790
|
+
): RepoVariable[] {
|
|
791
|
+
const e = resolveEnvironment(cfg, env);
|
|
792
|
+
return [
|
|
793
|
+
{
|
|
794
|
+
name: "AWS_DEPLOY_ROLE_ARN",
|
|
795
|
+
value: e.outputs?.deployRoleArn ?? "",
|
|
796
|
+
from: "DeployRoleArn stack output",
|
|
797
|
+
ghEnvironment: e.ghEnvironment,
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
name: "AWS_REGION",
|
|
801
|
+
value: cfg.region,
|
|
802
|
+
from: "Region stack output",
|
|
803
|
+
ghEnvironment: e.ghEnvironment,
|
|
804
|
+
},
|
|
805
|
+
];
|
|
806
|
+
}
|
|
807
|
+
|
|
499
808
|
/**
|
|
500
809
|
* The argv (after `gh`) that sets one GitHub repo **Variable** on the config's
|
|
501
810
|
* repo. `init` runs this for each resolved `repoVariables` entry so the
|
|
@@ -509,15 +818,39 @@ export function ghVariableSetArgs(
|
|
|
509
818
|
cfg: DeployConfig,
|
|
510
819
|
name: string,
|
|
511
820
|
value: string,
|
|
821
|
+
ghEnvironment?: string,
|
|
512
822
|
): string[] {
|
|
513
|
-
|
|
823
|
+
const args = [
|
|
514
824
|
"variable",
|
|
515
825
|
"set",
|
|
516
826
|
name,
|
|
517
827
|
"--repo",
|
|
518
828
|
`${cfg.github.owner}/${cfg.github.repo}`,
|
|
519
|
-
|
|
520
|
-
|
|
829
|
+
];
|
|
830
|
+
// Scope the Variable to a GitHub Actions Environment when one is given, so the
|
|
831
|
+
// dev and prod deploys each read their own role/region.
|
|
832
|
+
if (ghEnvironment) args.push("--env", ghEnvironment);
|
|
833
|
+
args.push("--body", value);
|
|
834
|
+
return args;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* The argv (after `gh`) that creates (idempotently) a GitHub Actions
|
|
839
|
+
* **Environment** on the config's repo — `gh api --method PUT
|
|
840
|
+
* repos/{owner}/{repo}/environments/{env}`. `init` runs this for `development`
|
|
841
|
+
* and `production` before wiring their scoped Variables, since a Variable can
|
|
842
|
+
* only be set on an environment that already exists. PUT is idempotent, so
|
|
843
|
+
* re-running `init` is safe.
|
|
844
|
+
*/
|
|
845
|
+
export function ghEnvironmentCreateArgs(
|
|
846
|
+
cfg: DeployConfig,
|
|
847
|
+
ghEnvironment: string,
|
|
848
|
+
): string[] {
|
|
849
|
+
return [
|
|
850
|
+
"api",
|
|
851
|
+
"--method",
|
|
852
|
+
"PUT",
|
|
853
|
+
`repos/${cfg.github.owner}/${cfg.github.repo}/environments/${ghEnvironment}`,
|
|
521
854
|
];
|
|
522
855
|
}
|
|
523
856
|
|
package/src/deploy/io.ts
CHANGED
|
@@ -6,11 +6,20 @@
|
|
|
6
6
|
|
|
7
7
|
import { execFileSync, execSync } from "child_process";
|
|
8
8
|
import * as fs from "fs";
|
|
9
|
+
import * as https from "https";
|
|
9
10
|
import * as os from "os";
|
|
10
11
|
import * as path from "path";
|
|
11
12
|
import * as readline from "readline";
|
|
12
13
|
import { CONFIG_PATH } from "./workflows";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
BOOTSTRAP_ARTIFACTS_OUTPUT,
|
|
16
|
+
BOOTSTRAP_TEMPLATES_OUTPUT,
|
|
17
|
+
DeployConfig,
|
|
18
|
+
PartialDeployConfig,
|
|
19
|
+
bootstrapStackArgs,
|
|
20
|
+
bootstrapStackName,
|
|
21
|
+
normalizeConfig,
|
|
22
|
+
} from "./config";
|
|
14
23
|
import { zipStore } from "./zip";
|
|
15
24
|
|
|
16
25
|
/** Absolute path to the deploy config for a project root. */
|
|
@@ -50,6 +59,65 @@ export function resolveDeployAsset(
|
|
|
50
59
|
return null;
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Base URL the framework's CloudFormation templates are published at. The same
|
|
64
|
+
* site that hosts the installer (`reactapistack.com/setup.sh`) serves the base
|
|
65
|
+
* templates, so a CLI whose bundled copy is missing (a partial install, or a
|
|
66
|
+
* globally-linked dev build) can still fetch them.
|
|
67
|
+
*/
|
|
68
|
+
export const TEMPLATE_BASE_URL = "https://reactapistack.com/cloudformation";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Download a published CloudFormation template to a temp file, returning its
|
|
72
|
+
* path — or null on any failure (offline, 404, write error). Used as the
|
|
73
|
+
* fallback when the template is not bundled with the install.
|
|
74
|
+
*/
|
|
75
|
+
export function downloadTemplate(
|
|
76
|
+
name: string,
|
|
77
|
+
base = TEMPLATE_BASE_URL,
|
|
78
|
+
): Promise<string | null> {
|
|
79
|
+
const url = `${base}/${name}`;
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
https
|
|
82
|
+
.get(url, (res) => {
|
|
83
|
+
if (res.statusCode !== 200) {
|
|
84
|
+
res.resume();
|
|
85
|
+
resolve(null);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const chunks: Buffer[] = [];
|
|
89
|
+
res.on("data", (c: Buffer) => chunks.push(c));
|
|
90
|
+
res.on("end", () => {
|
|
91
|
+
try {
|
|
92
|
+
const dest = path.join(
|
|
93
|
+
os.tmpdir(),
|
|
94
|
+
`rastack-${path.basename(name)}`,
|
|
95
|
+
);
|
|
96
|
+
fs.writeFileSync(dest, Buffer.concat(chunks));
|
|
97
|
+
resolve(dest);
|
|
98
|
+
} catch {
|
|
99
|
+
resolve(null);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
})
|
|
103
|
+
.on("error", () => resolve(null));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a CloudFormation template to a local file path for `aws cloudformation
|
|
109
|
+
* deploy` (which accepts only `--template-file`). Prefers the copy bundled with
|
|
110
|
+
* the `rastack` install; when that is missing, downloads it from the React API
|
|
111
|
+
* Stack website. Returns null only when the template is neither bundled nor
|
|
112
|
+
* fetchable.
|
|
113
|
+
*/
|
|
114
|
+
export async function resolveTemplateFile(
|
|
115
|
+
name: string,
|
|
116
|
+
cwd = process.cwd(),
|
|
117
|
+
): Promise<string | null> {
|
|
118
|
+
return resolveDeployAsset(name, cwd) ?? (await downloadTemplate(name));
|
|
119
|
+
}
|
|
120
|
+
|
|
53
121
|
/** Read + normalize the deploy config, or null when it does not exist yet. */
|
|
54
122
|
export function loadConfig(cwd = process.cwd()): DeployConfig | null {
|
|
55
123
|
const file = configPath(cwd);
|
|
@@ -121,6 +189,26 @@ export function awsAvailable(): boolean {
|
|
|
121
189
|
return awsCapture(["--version"]) !== null;
|
|
122
190
|
}
|
|
123
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The AWS account id the CLI's active credentials resolve to, or null when the
|
|
194
|
+
* CLI has no usable credentials (not logged in, an expired SSO session, or no
|
|
195
|
+
* configured profile). `init`/`bootstrap` call this to fail fast with an
|
|
196
|
+
* actionable "log in" message before attempting a stack deploy, instead of
|
|
197
|
+
* letting the first `cloudformation deploy` fail with a cryptic AWS error.
|
|
198
|
+
*/
|
|
199
|
+
export function awsIdentity(region?: string): string | null {
|
|
200
|
+
const args = [
|
|
201
|
+
"sts",
|
|
202
|
+
"get-caller-identity",
|
|
203
|
+
"--query",
|
|
204
|
+
"Account",
|
|
205
|
+
"--output",
|
|
206
|
+
"text",
|
|
207
|
+
];
|
|
208
|
+
if (region) args.push("--region", region);
|
|
209
|
+
return awsCapture(args);
|
|
210
|
+
}
|
|
211
|
+
|
|
124
212
|
/**
|
|
125
213
|
* A single Output value of a CloudFormation stack, or null when the stack does
|
|
126
214
|
* not exist or the key is unset. `describe-stacks` prints `None` for a missing
|
|
@@ -176,6 +264,79 @@ export function uploadPlaceholder(
|
|
|
176
264
|
return true;
|
|
177
265
|
}
|
|
178
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Deploy the tier-0 bootstrap stack, retrying once against an account that
|
|
269
|
+
* already has a GitHub OIDC provider — AWS allows only one provider per URL, so
|
|
270
|
+
* a second create is rejected and the retry tells the template to reuse the
|
|
271
|
+
* existing one. Throws on failure (after the retry). Shared by `rastack init`
|
|
272
|
+
* and `rastack bootstrap` so both stand the stack up identically.
|
|
273
|
+
*/
|
|
274
|
+
export function deployBootstrap(
|
|
275
|
+
cfg: DeployConfig,
|
|
276
|
+
reuseOidc: boolean,
|
|
277
|
+
template: string,
|
|
278
|
+
): void {
|
|
279
|
+
try {
|
|
280
|
+
awsRun(bootstrapStackArgs(cfg, reuseOidc, template));
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (reuseOidc) throw err;
|
|
283
|
+
console.log(
|
|
284
|
+
" • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
|
|
285
|
+
);
|
|
286
|
+
awsRun(bootstrapStackArgs(cfg, true, template));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Ensure the tier-0 bootstrap stack exists for this account+region, **creating
|
|
292
|
+
* it when missing**, and return its artifacts/templates buckets. This is why
|
|
293
|
+
* `rastack init` is a single command that stands up *everything* — the
|
|
294
|
+
* account-level bootstrap stack and the per-app backend — with no separate
|
|
295
|
+
* `rastack bootstrap` step. Idempotent: an existing stack is reconciled in
|
|
296
|
+
* place. Throws with an actionable message when the template cannot be resolved,
|
|
297
|
+
* the deploy fails, or the buckets can't be read back.
|
|
298
|
+
*/
|
|
299
|
+
export async function ensureBootstrapStack(
|
|
300
|
+
cfg: DeployConfig,
|
|
301
|
+
reuseOidc = false,
|
|
302
|
+
): Promise<{ artifactsBucket: string; templatesBucket: string | null }> {
|
|
303
|
+
const stack = bootstrapStackName(cfg);
|
|
304
|
+
const existing = stackOutput(stack, BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
|
|
305
|
+
if (existing) {
|
|
306
|
+
console.log(` ✓ bootstrap stack ${stack} already exists — reconciling`);
|
|
307
|
+
} else {
|
|
308
|
+
console.log(
|
|
309
|
+
`\n▸ Creating the tier-0 bootstrap stack ${stack} (@ ${cfg.region})`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const template = await resolveTemplateFile("bootstrap.yaml");
|
|
314
|
+
if (!template) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"bootstrap.yaml template not found in the rastack package and could not be\n" +
|
|
317
|
+
" downloaded from reactapistack.com. Reinstall rastack (`npm i -g rastack`).",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
deployBootstrap(cfg, reuseOidc, template);
|
|
321
|
+
|
|
322
|
+
const artifactsBucket = stackOutput(
|
|
323
|
+
stack,
|
|
324
|
+
BOOTSTRAP_ARTIFACTS_OUTPUT,
|
|
325
|
+
cfg.region,
|
|
326
|
+
);
|
|
327
|
+
const templatesBucket = stackOutput(
|
|
328
|
+
stack,
|
|
329
|
+
BOOTSTRAP_TEMPLATES_OUTPUT,
|
|
330
|
+
cfg.region,
|
|
331
|
+
);
|
|
332
|
+
if (!artifactsBucket) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`bootstrap stack ${stack} deployed but its ArtifactsBucket output could not be read.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return { artifactsBucket, templatesBucket };
|
|
338
|
+
}
|
|
339
|
+
|
|
179
340
|
// ── GitHub CLI boundary ──────────────────────────────────────────────────────
|
|
180
341
|
// After the stack is up `init` wires the two repo Variables CI reads
|
|
181
342
|
// (AWS_DEPLOY_ROLE_ARN / AWS_REGION) through the developer's own `gh` — already
|
|
@@ -236,7 +397,23 @@ export function ghAuthLogin(cwd = process.cwd()): boolean {
|
|
|
236
397
|
* `execFileSync` (no shell) so the ARN/region values are never interpolated
|
|
237
398
|
* into a command line.
|
|
238
399
|
*/
|
|
239
|
-
export function setRepoVariable(
|
|
400
|
+
export function setRepoVariable(args: string[], cwd = process.cwd()): boolean {
|
|
401
|
+
try {
|
|
402
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
403
|
+
execFileSync("gh", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
404
|
+
return true;
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Create (idempotently, via `gh api --method PUT`) a GitHub Actions Environment
|
|
412
|
+
* so its scoped Variables can be set. Returns true on success. Best-effort — a
|
|
413
|
+
* missing/unauthenticated `gh` or an API error degrades to the printed
|
|
414
|
+
* instructions, exactly like `setRepoVariable`.
|
|
415
|
+
*/
|
|
416
|
+
export function ghEnvironmentCreate(
|
|
240
417
|
args: string[],
|
|
241
418
|
cwd = process.cwd(),
|
|
242
419
|
): boolean {
|