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.
Files changed (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/admin/AdminApp.tsx +56 -3
  3. package/admin/index.tsx +8 -2
  4. package/admin/ui/icons.tsx +9 -0
  5. package/admin/ui/styles.ts +3 -0
  6. package/admin/useAdmin.ts +41 -4
  7. package/auth/provider.ts +71 -8
  8. package/dist/admin.js +17 -14
  9. package/dist/compile/analyze.js +11 -1
  10. package/dist/define/index.d.ts +8 -1
  11. package/dist/define/index.js +2 -2
  12. package/dist/define/manifest.js +7 -1
  13. package/dist/deploy/ci.d.ts +10 -3
  14. package/dist/deploy/ci.js +22 -11
  15. package/dist/deploy/config.d.ts +155 -5
  16. package/dist/deploy/config.js +216 -6
  17. package/dist/deploy/io.d.ts +57 -0
  18. package/dist/deploy/io.js +141 -0
  19. package/dist/deploy/workflows.d.ts +30 -0
  20. package/dist/deploy/workflows.js +117 -12
  21. package/dist/rastack-bootstrap.js +18 -47
  22. package/dist/rastack-ci.js +19 -1
  23. package/dist/rastack-dev.js +48 -2
  24. package/dist/rastack-init.d.ts +6 -3
  25. package/dist/rastack-init.js +231 -92
  26. package/dist/rastack.js +1 -0
  27. package/dist/templates/cloudformation/app.yaml +133 -2
  28. package/dist/wasm/rastack_wasm.js +1 -1
  29. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  30. package/package.json +1 -1
  31. package/provider/provider.tsx +23 -0
  32. package/provider/types.ts +9 -0
  33. package/src/compile/analyze.ts +11 -1
  34. package/src/define/index.ts +5 -1
  35. package/src/define/manifest.ts +7 -1
  36. package/src/deploy/ci.ts +50 -16
  37. package/src/deploy/config.ts +340 -7
  38. package/src/deploy/io.ts +179 -2
  39. package/src/deploy/workflows.ts +122 -12
  40. package/src/rastack-bootstrap.ts +24 -69
  41. package/src/rastack-ci.ts +20 -2
  42. package/src/rastack-dev.ts +61 -6
  43. package/src/rastack-init.ts +283 -115
  44. package/src/rastack.ts +1 -0
@@ -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
- /** CloudFormation stack name — defaults to `${namePrefix}-${app}`. */
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
- /** The GitHub repo whose CI is trusted to ship the Lambda code. */
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
- return [
823
+ const args = [
514
824
  "variable",
515
825
  "set",
516
826
  name,
517
827
  "--repo",
518
828
  `${cfg.github.owner}/${cfg.github.repo}`,
519
- "--body",
520
- value,
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 { DeployConfig, PartialDeployConfig, normalizeConfig } from "./config";
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 {