rastack 0.0.38 → 0.0.40
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 +9 -0
- package/dist/deploy/config.d.ts +18 -0
- package/dist/deploy/config.js +49 -0
- package/dist/deploy/io.d.ts +24 -1
- package/dist/deploy/io.js +74 -1
- package/dist/rastack-bootstrap.d.ts +24 -0
- package/dist/rastack-bootstrap.js +222 -0
- package/dist/rastack-init.d.ts +1 -1
- package/dist/rastack-init.js +70 -20
- package/dist/rastack.d.ts +1 -0
- package/dist/rastack.js +7 -0
- package/package.json +1 -1
- package/src/deploy/config.ts +56 -0
- package/src/deploy/io.ts +74 -1
- package/src/rastack-bootstrap.ts +299 -0
- package/src/rastack-init.ts +95 -27
- package/src/rastack.ts +7 -0
- package/test/deploy.spec.ts +53 -0
package/dist/rastack-init.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* rastack init [--app <slug>] [--owner <o>] [--repo <r>] [--branch <b>]
|
|
8
8
|
* [--region <r>] [--prefix <p>] [--namespace <n>]
|
|
9
|
-
* [--force] [--no-workflows] [--no-deploy] [--print]
|
|
9
|
+
* [--force] [--no-workflows] [--no-deploy] [--print]
|
|
10
10
|
*
|
|
11
11
|
* This is the one command that takes you from nothing to deployed. It:
|
|
12
12
|
* 1. writes `.rastack/deploy.json` + `.github/workflows/{ci,deploy-backend}.yml`
|
|
@@ -68,7 +68,6 @@ function parseFlags(argv) {
|
|
|
68
68
|
let noWorkflows = false;
|
|
69
69
|
let noDeploy = false;
|
|
70
70
|
let print = false;
|
|
71
|
-
let nonInteractive = false;
|
|
72
71
|
for (let i = 0; i < argv.length; i++) {
|
|
73
72
|
const a = argv[i];
|
|
74
73
|
const next = () => argv[++i] ?? "";
|
|
@@ -107,11 +106,6 @@ function parseFlags(argv) {
|
|
|
107
106
|
case "--dry-run":
|
|
108
107
|
print = true;
|
|
109
108
|
break;
|
|
110
|
-
case "--yes":
|
|
111
|
-
case "-y":
|
|
112
|
-
case "--non-interactive":
|
|
113
|
-
nonInteractive = true;
|
|
114
|
-
break;
|
|
115
109
|
default:
|
|
116
110
|
if (a.startsWith("-")) {
|
|
117
111
|
console.error(`Unknown flag: ${a}`);
|
|
@@ -119,7 +113,7 @@ function parseFlags(argv) {
|
|
|
119
113
|
}
|
|
120
114
|
}
|
|
121
115
|
}
|
|
122
|
-
return { patch, force, noWorkflows, noDeploy, print
|
|
116
|
+
return { patch, force, noWorkflows, noDeploy, print };
|
|
123
117
|
}
|
|
124
118
|
/** Seed a config: existing file → flags → git remote → defaults. */
|
|
125
119
|
function seedConfig(flags) {
|
|
@@ -209,7 +203,7 @@ function printPlan(cfg) {
|
|
|
209
203
|
* upload the Lambda placeholder, deploy app.yaml, and read the Outputs back into
|
|
210
204
|
* the config. Returns the config enriched with the resolved buckets + outputs.
|
|
211
205
|
*/
|
|
212
|
-
async function standUp(cfg) {
|
|
206
|
+
async function standUp(cfg, interactive) {
|
|
213
207
|
if (!(0, io_1.awsAvailable)()) {
|
|
214
208
|
console.error(" ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
|
|
215
209
|
" or re-run with --print to see the commands, or --no-deploy to skip.");
|
|
@@ -252,8 +246,57 @@ async function standUp(cfg) {
|
|
|
252
246
|
const outputs = readAppOutputs(cfg);
|
|
253
247
|
cfg = (0, config_1.mergeConfig)(cfg, { outputs });
|
|
254
248
|
(0, io_1.saveConfig)(cfg);
|
|
249
|
+
// 5. Connect the GitHub CLI and wire the repo Variables CI reads, so the FIRST
|
|
250
|
+
// push actually ships. Without this the deploy-backend workflow's `if:
|
|
251
|
+
// vars.AWS_DEPLOY_ROLE_ARN != ''` guard silently skips every run.
|
|
252
|
+
wireRepoVariables(cfg, interactive);
|
|
255
253
|
return cfg;
|
|
256
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Set the `AWS_DEPLOY_ROLE_ARN` / `AWS_REGION` repo Variables via `gh` so the
|
|
257
|
+
* GitHub → Lambda pipeline is live the moment `init` finishes. Connects `gh` to
|
|
258
|
+
* GitHub first — running `gh auth login` when it is installed but not yet
|
|
259
|
+
* authenticated (on a TTY) — so a fresh machine goes from nothing to a working
|
|
260
|
+
* pipeline in one command. Best-effort throughout: no `gh`, a declined login, or
|
|
261
|
+
* a value the stack Outputs did not yield falls back to the printed instructions
|
|
262
|
+
* in `printSummary`. Records the names it wired so the summary reports what is
|
|
263
|
+
* (and isn't) done.
|
|
264
|
+
*/
|
|
265
|
+
const wiredVariables = new Set();
|
|
266
|
+
function wireRepoVariables(cfg, interactive) {
|
|
267
|
+
const vars = (0, config_1.repoVariables)(cfg).filter((v) => v.value);
|
|
268
|
+
if (!vars.length)
|
|
269
|
+
return;
|
|
270
|
+
if (!(0, io_1.ghAvailable)()) {
|
|
271
|
+
console.log("\n▸ GitHub CLI (`gh`) not found — skipping repo Variable wiring.\n" +
|
|
272
|
+
" Install `gh` (https://cli.github.com) and re-run, or wire the\n" +
|
|
273
|
+
" Variables below by hand so pushes deploy.");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Connect `gh` to GitHub if it isn't already, so it can set the Variables.
|
|
277
|
+
if (!(0, io_1.ghAuthenticated)()) {
|
|
278
|
+
if (interactive) {
|
|
279
|
+
console.log("\n▸ Connecting the GitHub CLI to your account (`gh auth login`)");
|
|
280
|
+
(0, io_1.ghAuthLogin)();
|
|
281
|
+
}
|
|
282
|
+
if (!(0, io_1.ghAuthenticated)()) {
|
|
283
|
+
console.log("\n▸ GitHub CLI is not connected — skipping repo Variable wiring.\n" +
|
|
284
|
+
" Run `gh auth login`, then wire the Variables below by hand.");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
console.log("\n▸ Wiring the GitHub repo Variables CI reads (via `gh`)");
|
|
289
|
+
for (const v of vars) {
|
|
290
|
+
const ok = (0, io_1.setRepoVariable)((0, config_1.ghVariableSetArgs)(cfg, v.name, v.value));
|
|
291
|
+
if (ok) {
|
|
292
|
+
wiredVariables.add(v.name);
|
|
293
|
+
console.log(` ✓ set ${v.name}`);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
console.log(` • could not set ${v.name} — wire it by hand (below)`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
257
300
|
/** Deploy the bootstrap stack, retrying once against an existing OIDC provider. */
|
|
258
301
|
function deployBootstrap(cfg) {
|
|
259
302
|
try {
|
|
@@ -277,26 +320,33 @@ function printSummary(cfg) {
|
|
|
277
320
|
console.log(`\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`);
|
|
278
321
|
if (cfg.outputs?.apiUrl)
|
|
279
322
|
console.log(` API: ${cfg.outputs.apiUrl}`);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
323
|
+
// Only the Variables `init` could NOT wire itself remain a manual step.
|
|
324
|
+
const pending = (0, config_1.repoVariables)(cfg).filter((v) => !wiredVariables.has(v.name));
|
|
325
|
+
if (pending.length) {
|
|
326
|
+
console.log("\n Wire these GitHub repo Variables so CI can ship code (Settings → Variables):");
|
|
327
|
+
console.log(table(pending.map((v) => ({
|
|
328
|
+
key: v.name,
|
|
329
|
+
value: v.value || `<${v.from}>`,
|
|
330
|
+
}))));
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
console.log("\n ✓ wired the GitHub repo Variables CI reads (AWS_DEPLOY_ROLE_ARN, AWS_REGION).");
|
|
334
|
+
}
|
|
335
|
+
console.log(`\n Every push to ${cfg.github.branch} now ships code via \`rastack ci deploy\`` +
|
|
286
336
|
` (also runnable locally as \`rastack deploy\`).\n`);
|
|
287
337
|
}
|
|
288
338
|
async function main(argv) {
|
|
289
339
|
const flags = parseFlags(argv);
|
|
290
340
|
let cfg = seedConfig(flags);
|
|
291
|
-
if ((0, io_1.canPrompt)(
|
|
341
|
+
if ((0, io_1.canPrompt)()) {
|
|
292
342
|
console.log("\n rastack init — scaffold the deploy config + stand the backend up\n");
|
|
293
343
|
cfg = await promptMissing(cfg);
|
|
294
344
|
}
|
|
295
345
|
const errors = (0, config_1.validateConfig)(cfg);
|
|
296
346
|
// Any deploy needs a complete config; a still-incomplete one is fatal unless we
|
|
297
|
-
// are only scaffolding files.
|
|
347
|
+
// are only scaffolding files, and always fatal off a TTY (no prompt to fix it).
|
|
298
348
|
if (errors.length) {
|
|
299
|
-
const fatal = !flags.noDeploy ||
|
|
349
|
+
const fatal = !flags.noDeploy || !(0, io_1.canPrompt)();
|
|
300
350
|
const label = fatal ? "✗" : "⚠";
|
|
301
351
|
for (const e of errors)
|
|
302
352
|
console.error(` ${label} ${e}`);
|
|
@@ -334,14 +384,14 @@ async function main(argv) {
|
|
|
334
384
|
` or \`rastack init --print\` to see the exact commands.\n`);
|
|
335
385
|
return;
|
|
336
386
|
}
|
|
337
|
-
if ((0, io_1.canPrompt)(
|
|
387
|
+
if ((0, io_1.canPrompt)()) {
|
|
338
388
|
const ok = await confirm(`Deploy ${cfg.stackName} to AWS ${cfg.region} now? (creates the bootstrap stack if needed) [y/N]`);
|
|
339
389
|
if (!ok) {
|
|
340
390
|
console.log("\n Skipped the deploy. Re-run to deploy, or `rastack init --print` for the commands.\n");
|
|
341
391
|
return;
|
|
342
392
|
}
|
|
343
393
|
}
|
|
344
|
-
cfg = await standUp(cfg);
|
|
394
|
+
cfg = await standUp(cfg, (0, io_1.canPrompt)());
|
|
345
395
|
printSummary(cfg);
|
|
346
396
|
}
|
|
347
397
|
if (require.main === module) {
|
package/dist/rastack.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* rastack wasm Compile the API to WebAssembly (in-browser local dev)
|
|
16
16
|
* rastack scan [files...] [--fail-on-pii] Scan CSVs for PII and schema info
|
|
17
17
|
* rastack seed [--env dev] [--source gs://] Load CSVs into Iceberg namespace
|
|
18
|
+
* rastack bootstrap [--owner o] [--repo r] Stand the tier-0 AWS account stack up (once per account+region)
|
|
18
19
|
* rastack init [--app slug] [--force] Scaffold config + GitHub Actions AND stand the backend up
|
|
19
20
|
* rastack deploy [--plan] Ship code to the Lambda (alias for `rastack ci deploy`)
|
|
20
21
|
* rastack ci <check|build|deploy> [--plan] Run the CI pipeline (keeps Actions a one-liner)
|
package/dist/rastack.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* rastack wasm Compile the API to WebAssembly (in-browser local dev)
|
|
17
17
|
* rastack scan [files...] [--fail-on-pii] Scan CSVs for PII and schema info
|
|
18
18
|
* rastack seed [--env dev] [--source gs://] Load CSVs into Iceberg namespace
|
|
19
|
+
* rastack bootstrap [--owner o] [--repo r] Stand the tier-0 AWS account stack up (once per account+region)
|
|
19
20
|
* rastack init [--app slug] [--force] Scaffold config + GitHub Actions AND stand the backend up
|
|
20
21
|
* rastack deploy [--plan] Ship code to the Lambda (alias for `rastack ci deploy`)
|
|
21
22
|
* rastack ci <check|build|deploy> [--plan] Run the CI pipeline (keeps Actions a one-liner)
|
|
@@ -113,6 +114,11 @@ switch (command) {
|
|
|
113
114
|
case "seed":
|
|
114
115
|
run("seed.js");
|
|
115
116
|
break;
|
|
117
|
+
case "bootstrap":
|
|
118
|
+
// Stand the tier-0 bootstrap stack up (buckets + GitHub OIDC) for an AWS
|
|
119
|
+
// account — the once-per-account+region step, decoupled from any app.
|
|
120
|
+
run("rastack-bootstrap.js", rest);
|
|
121
|
+
break;
|
|
116
122
|
case "init":
|
|
117
123
|
// Scaffold config + GitHub Actions and stand the serverless backend up.
|
|
118
124
|
run("rastack-init.js", rest);
|
|
@@ -148,6 +154,7 @@ switch (command) {
|
|
|
148
154
|
` rastack wasm Compile the API to WebAssembly (browser local dev)\n` +
|
|
149
155
|
` rastack scan [files...] [--fail-on-pii]\n` +
|
|
150
156
|
` rastack seed [--env dev|staging] [--source gs://bucket/ | ./myapp-dev/]\n` +
|
|
157
|
+
` rastack bootstrap [--owner o] [--repo r] [--region r] [--prefix p] [--reuse-oidc] [--print] Stand the AWS account stack up\n` +
|
|
151
158
|
` rastack init [--app slug] [--owner o] [--repo r] [--print] [--no-deploy] Scaffold + stand the backend up\n` +
|
|
152
159
|
` rastack deploy [--plan] Ship code to the Lambda (alias for ci deploy)\n` +
|
|
153
160
|
` rastack ci <check|build|deploy> [--plan] Run the CI pipeline (cleaner Actions)\n` +
|
package/package.json
CHANGED
package/src/deploy/config.ts
CHANGED
|
@@ -184,6 +184,37 @@ export function validateConfig(cfg: DeployConfig): string[] {
|
|
|
184
184
|
return errors;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Return the subset of problems that block standing the **tier-0 bootstrap
|
|
189
|
+
* stack** up. The bootstrap stack (bootstrap.yaml) is account+region scoped — it
|
|
190
|
+
* only needs the name prefix, the region, and the GitHub coordinates that anchor
|
|
191
|
+
* the OIDC trust — so `rastack bootstrap` validates just those, not the per-app
|
|
192
|
+
* fields (`app`, namespace, Lambda build) that `validateConfig` also requires.
|
|
193
|
+
*/
|
|
194
|
+
export function validateBootstrapConfig(cfg: DeployConfig): string[] {
|
|
195
|
+
const errors: string[] = [];
|
|
196
|
+
if (!SLUG.test(cfg.namePrefix))
|
|
197
|
+
errors.push(`namePrefix "${cfg.namePrefix}" must match ${SLUG}.`);
|
|
198
|
+
|
|
199
|
+
if (!cfg.region) errors.push("region is required (e.g. `af-south-1`).");
|
|
200
|
+
else if (!REGION.test(cfg.region))
|
|
201
|
+
errors.push(
|
|
202
|
+
`region "${cfg.region}" does not look like an AWS region (e.g. af-south-1).`,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (!cfg.github.owner) errors.push("github.owner is required.");
|
|
206
|
+
else if (!GH_NAME.test(cfg.github.owner))
|
|
207
|
+
errors.push(
|
|
208
|
+
`github.owner "${cfg.github.owner}" is not a valid GitHub name.`,
|
|
209
|
+
);
|
|
210
|
+
if (!cfg.github.repo) errors.push("github.repo is required.");
|
|
211
|
+
else if (!GH_NAME.test(cfg.github.repo))
|
|
212
|
+
errors.push(`github.repo "${cfg.github.repo}" is not a valid GitHub name.`);
|
|
213
|
+
if (!cfg.github.branch) errors.push("github.branch is required.");
|
|
214
|
+
|
|
215
|
+
return errors;
|
|
216
|
+
}
|
|
217
|
+
|
|
187
218
|
/** Deep-merge a patch over a base config (used by `deploy`/`init` reconfigure). */
|
|
188
219
|
export function mergeConfig(
|
|
189
220
|
base: DeployConfig,
|
|
@@ -440,6 +471,31 @@ export function repoVariables(cfg: DeployConfig): RepoVariable[] {
|
|
|
440
471
|
];
|
|
441
472
|
}
|
|
442
473
|
|
|
474
|
+
/**
|
|
475
|
+
* The argv (after `gh`) that sets one GitHub repo **Variable** on the config's
|
|
476
|
+
* repo. `init` runs this for each resolved `repoVariables` entry so the
|
|
477
|
+
* `deploy-backend.yml` guard (`if: vars.AWS_DEPLOY_ROLE_ARN != ''`) passes and
|
|
478
|
+
* the very first push actually ships — instead of the variables being a manual
|
|
479
|
+
* Console step the user has to remember. Uses `gh` (already authenticated to the
|
|
480
|
+
* user's GitHub) rather than an API token; a pure argv so it is inspectable and
|
|
481
|
+
* shell-free (`execFileSync`).
|
|
482
|
+
*/
|
|
483
|
+
export function ghVariableSetArgs(
|
|
484
|
+
cfg: DeployConfig,
|
|
485
|
+
name: string,
|
|
486
|
+
value: string,
|
|
487
|
+
): string[] {
|
|
488
|
+
return [
|
|
489
|
+
"variable",
|
|
490
|
+
"set",
|
|
491
|
+
name,
|
|
492
|
+
"--repo",
|
|
493
|
+
`${cfg.github.owner}/${cfg.github.repo}`,
|
|
494
|
+
"--body",
|
|
495
|
+
value,
|
|
496
|
+
];
|
|
497
|
+
}
|
|
498
|
+
|
|
443
499
|
/**
|
|
444
500
|
* The `RASTACK_*` environment the Lambda reads (docs/serverless-deploy.md §1).
|
|
445
501
|
* The IaC sets these on the function; surfaced here for `rastack deploy` to
|
package/src/deploy/io.ts
CHANGED
|
@@ -55,7 +55,7 @@ export function gitRemoteUrl(cwd = process.cwd()): string | null {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** True when we can prompt the user (a TTY, and not explicitly opted out). */
|
|
58
|
-
export function canPrompt(nonInteractive
|
|
58
|
+
export function canPrompt(nonInteractive = false): boolean {
|
|
59
59
|
return !nonInteractive && Boolean(process.stdin.isTTY);
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -144,6 +144,79 @@ export function uploadPlaceholder(
|
|
|
144
144
|
return true;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// ── GitHub CLI boundary ──────────────────────────────────────────────────────
|
|
148
|
+
// After the stack is up `init` wires the two repo Variables CI reads
|
|
149
|
+
// (AWS_DEPLOY_ROLE_ARN / AWS_REGION) through the developer's own `gh` — already
|
|
150
|
+
// authenticated to their GitHub — so the pipeline fires on the first push
|
|
151
|
+
// instead of leaving the wiring as a manual Console step. Best-effort: absent or
|
|
152
|
+
// unauthenticated `gh` degrades to the printed instructions.
|
|
153
|
+
|
|
154
|
+
/** True when the GitHub CLI is on PATH. */
|
|
155
|
+
export function ghAvailable(): boolean {
|
|
156
|
+
try {
|
|
157
|
+
execFileSync("gh", ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* True when `gh` already holds valid credentials for github.com. `gh auth
|
|
166
|
+
* status` exits non-zero when logged out, so a thrown call means "not connected".
|
|
167
|
+
*/
|
|
168
|
+
export function ghAuthenticated(cwd = process.cwd()): boolean {
|
|
169
|
+
try {
|
|
170
|
+
execFileSync("gh", ["auth", "status", "--hostname", "github.com"], {
|
|
171
|
+
cwd,
|
|
172
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
173
|
+
});
|
|
174
|
+
return true;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Connect `gh` to GitHub interactively (`gh auth login`) as part of `init`, so
|
|
182
|
+
* the repo Variables can be set right after. Streamed to the terminal — the
|
|
183
|
+
* browser/device-code flow needs the TTY — and left to `gh`'s own prompts so the
|
|
184
|
+
* user picks how to authenticate (browser or a pasted token). Returns true when
|
|
185
|
+
* the login exits 0.
|
|
186
|
+
*/
|
|
187
|
+
export function ghAuthLogin(cwd = process.cwd()): boolean {
|
|
188
|
+
try {
|
|
189
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
190
|
+
execFileSync("gh", ["auth", "login", "--hostname", "github.com"], {
|
|
191
|
+
cwd,
|
|
192
|
+
stdio: "inherit",
|
|
193
|
+
});
|
|
194
|
+
return true;
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Set one GitHub repo Variable via `gh variable set`. Returns true on success,
|
|
202
|
+
* false when `gh` is missing, unauthenticated, or the call otherwise fails —
|
|
203
|
+
* the caller falls back to printing the value for the user to wire by hand.
|
|
204
|
+
* `execFileSync` (no shell) so the ARN/region values are never interpolated
|
|
205
|
+
* into a command line.
|
|
206
|
+
*/
|
|
207
|
+
export function setRepoVariable(
|
|
208
|
+
args: string[],
|
|
209
|
+
cwd = process.cwd(),
|
|
210
|
+
): boolean {
|
|
211
|
+
try {
|
|
212
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
213
|
+
execFileSync("gh", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
147
220
|
/**
|
|
148
221
|
* Ask a single question with a default, echoed as `label [default]: `. Returns
|
|
149
222
|
* the trimmed answer, or the default on an empty line.
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `rastack bootstrap` — stand the **tier-0 bootstrap stack** up for an AWS
|
|
5
|
+
* account. This is the once-per-account+region step that prepares an account to
|
|
6
|
+
* host React API Stack backends, decoupled from any single app.
|
|
7
|
+
*
|
|
8
|
+
* rastack bootstrap [--owner <o>] [--repo <r>] [--branch <b>]
|
|
9
|
+
* [--region <r>] [--prefix <p>]
|
|
10
|
+
* [--reuse-oidc] [--print] [--yes]
|
|
11
|
+
*
|
|
12
|
+
* It deploys `deploy/cloudformation/bootstrap.yaml`, which creates:
|
|
13
|
+
* • the S3 buckets that hold the Lambda placeholder + hosted templates, and
|
|
14
|
+
* • the GitHub OIDC provider + trust that lets CI assume a deploy role.
|
|
15
|
+
* It then uploads the Lambda placeholder bundle to the artifacts bucket, so the
|
|
16
|
+
* account is immediately ready for per-app deploys (`rastack init`).
|
|
17
|
+
*
|
|
18
|
+
* `rastack init` still creates this stack on demand if it is missing, so
|
|
19
|
+
* `bootstrap` is the explicit, app-independent way to do the same thing up front
|
|
20
|
+
* (e.g. an account admin preparing an account before app teams run `init`).
|
|
21
|
+
* `--reuse-oidc` skips creating the GitHub OIDC provider (AWS allows only one per
|
|
22
|
+
* URL per account); `--print` shows the exact single-line `aws cloudformation
|
|
23
|
+
* deploy` command without touching the cloud.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
BOOTSTRAP_ARTIFACTS_OUTPUT,
|
|
28
|
+
BOOTSTRAP_TEMPLATES_OUTPUT,
|
|
29
|
+
DeployConfig,
|
|
30
|
+
PartialDeployConfig,
|
|
31
|
+
bootstrapStackArgs,
|
|
32
|
+
bootstrapStackCommand,
|
|
33
|
+
bootstrapStackName,
|
|
34
|
+
mergeConfig,
|
|
35
|
+
normalizeConfig,
|
|
36
|
+
parseGitHubRemote,
|
|
37
|
+
validateBootstrapConfig,
|
|
38
|
+
} from "./deploy/config";
|
|
39
|
+
import {
|
|
40
|
+
ask,
|
|
41
|
+
awsAvailable,
|
|
42
|
+
awsRun,
|
|
43
|
+
canPrompt,
|
|
44
|
+
gitRemoteUrl,
|
|
45
|
+
loadConfig,
|
|
46
|
+
stackOutput,
|
|
47
|
+
uploadPlaceholder,
|
|
48
|
+
withPrompt,
|
|
49
|
+
} from "./deploy/io";
|
|
50
|
+
|
|
51
|
+
interface Flags {
|
|
52
|
+
patch: PartialDeployConfig;
|
|
53
|
+
reuseOidc: boolean;
|
|
54
|
+
print: boolean;
|
|
55
|
+
nonInteractive: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseFlags(argv: string[]): Flags {
|
|
59
|
+
const patch: PartialDeployConfig = { github: {} };
|
|
60
|
+
let reuseOidc = false;
|
|
61
|
+
let print = false;
|
|
62
|
+
let nonInteractive = false;
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
const a = argv[i];
|
|
65
|
+
const next = () => argv[++i] ?? "";
|
|
66
|
+
switch (a) {
|
|
67
|
+
case "--owner":
|
|
68
|
+
patch.github!.owner = next();
|
|
69
|
+
break;
|
|
70
|
+
case "--repo":
|
|
71
|
+
patch.github!.repo = next();
|
|
72
|
+
break;
|
|
73
|
+
case "--branch":
|
|
74
|
+
patch.github!.branch = next();
|
|
75
|
+
break;
|
|
76
|
+
case "--region":
|
|
77
|
+
patch.region = next();
|
|
78
|
+
break;
|
|
79
|
+
case "--prefix":
|
|
80
|
+
patch.namePrefix = next();
|
|
81
|
+
break;
|
|
82
|
+
case "--reuse-oidc":
|
|
83
|
+
reuseOidc = true;
|
|
84
|
+
break;
|
|
85
|
+
case "--print":
|
|
86
|
+
case "--dry-run":
|
|
87
|
+
print = true;
|
|
88
|
+
break;
|
|
89
|
+
case "--yes":
|
|
90
|
+
case "-y":
|
|
91
|
+
case "--non-interactive":
|
|
92
|
+
nonInteractive = true;
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
if (a.startsWith("-")) {
|
|
96
|
+
console.error(`Unknown flag: ${a}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { patch, reuseOidc, print, nonInteractive };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Seed a config: existing file → flags → git remote → defaults. */
|
|
105
|
+
function seedConfig(flags: Flags): DeployConfig {
|
|
106
|
+
const existing = loadConfig();
|
|
107
|
+
let cfg = existing
|
|
108
|
+
? mergeConfig(existing, flags.patch)
|
|
109
|
+
: normalizeConfig(flags.patch);
|
|
110
|
+
// Default the GitHub coordinates from `origin` when not already known — they
|
|
111
|
+
// anchor the OIDC trust the bootstrap stack sets up.
|
|
112
|
+
if (!cfg.github.owner || !cfg.github.repo) {
|
|
113
|
+
const url = gitRemoteUrl();
|
|
114
|
+
const remote = url ? parseGitHubRemote(url) : null;
|
|
115
|
+
if (remote) {
|
|
116
|
+
cfg = mergeConfig(cfg, {
|
|
117
|
+
github: {
|
|
118
|
+
owner: cfg.github.owner || remote.owner,
|
|
119
|
+
repo: cfg.github.repo || remote.repo,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return cfg;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function promptMissing(cfg: DeployConfig): Promise<DeployConfig> {
|
|
128
|
+
return withPrompt(async (rl) => {
|
|
129
|
+
const owner = await ask(rl, "GitHub owner", cfg.github.owner);
|
|
130
|
+
const repo = await ask(rl, "GitHub repo", cfg.github.repo);
|
|
131
|
+
const branch = await ask(rl, "Deploy branch", cfg.github.branch);
|
|
132
|
+
const region = await ask(rl, "AWS region", cfg.region);
|
|
133
|
+
const prefix = await ask(rl, "Name prefix", cfg.namePrefix);
|
|
134
|
+
return mergeConfig(cfg, {
|
|
135
|
+
region,
|
|
136
|
+
namePrefix: prefix,
|
|
137
|
+
github: { owner, repo, branch },
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Yes/no confirmation with a default of "no" — gates the cloud-mutating step. */
|
|
143
|
+
async function confirm(question: string): Promise<boolean> {
|
|
144
|
+
return withPrompt(async (rl) => {
|
|
145
|
+
const answer = await ask(rl, question, "no");
|
|
146
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Print the exact command `bootstrap` would run, without touching the cloud. */
|
|
151
|
+
function printPlan(cfg: DeployConfig, reuseOidc: boolean): void {
|
|
152
|
+
console.log(
|
|
153
|
+
`\n Bootstrap plan (${bootstrapStackName(cfg)} @ ${cfg.region})\n`,
|
|
154
|
+
);
|
|
155
|
+
console.log(" ── Tier-0 bootstrap stack (bootstrap.yaml) ──");
|
|
156
|
+
console.log("\n " + bootstrapStackCommand(cfg, reuseOidc) + "\n");
|
|
157
|
+
console.log(
|
|
158
|
+
" Then `rastack init` (or `rastack bootstrap` again) resolves this stack's\n" +
|
|
159
|
+
" ArtifactsBucket to create app backends. Nothing here is app-specific.\n",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Deploy the bootstrap stack, retrying once against an account that already has
|
|
165
|
+
* a GitHub OIDC provider (AWS allows only one per URL) when the caller did not
|
|
166
|
+
* already ask to reuse it.
|
|
167
|
+
*/
|
|
168
|
+
function deployBootstrap(cfg: DeployConfig, reuseOidc: boolean): void {
|
|
169
|
+
try {
|
|
170
|
+
awsRun(bootstrapStackArgs(cfg, reuseOidc));
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (reuseOidc) {
|
|
173
|
+
console.error(
|
|
174
|
+
`\n ✗ bootstrap stack deploy failed.\n ${(err as Error).message}`,
|
|
175
|
+
);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
// The most common cause is an account that already has a GitHub OIDC
|
|
179
|
+
// provider. Retry telling the template to reuse it instead of creating a
|
|
180
|
+
// second (which CloudFormation rejects).
|
|
181
|
+
console.log(
|
|
182
|
+
" • retrying bootstrap, reusing the account's existing GitHub OIDC provider",
|
|
183
|
+
);
|
|
184
|
+
try {
|
|
185
|
+
awsRun(bootstrapStackArgs(cfg, true));
|
|
186
|
+
} catch (retryErr) {
|
|
187
|
+
console.error(
|
|
188
|
+
`\n ✗ bootstrap stack deploy failed.\n ${(retryErr as Error).message}`,
|
|
189
|
+
);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Stand the tier-0 bootstrap stack up and upload the Lambda placeholder. */
|
|
196
|
+
function standUp(cfg: DeployConfig, reuseOidc: boolean): void {
|
|
197
|
+
if (!awsAvailable()) {
|
|
198
|
+
console.error(
|
|
199
|
+
" ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
|
|
200
|
+
" or re-run with --print to see the command.",
|
|
201
|
+
);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const stack = bootstrapStackName(cfg);
|
|
206
|
+
const existing = stackOutput(stack, BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
|
|
207
|
+
if (existing) {
|
|
208
|
+
console.log(
|
|
209
|
+
`\n▸ Bootstrap stack ${stack} already exists — reconciling (idempotent).`,
|
|
210
|
+
);
|
|
211
|
+
} else {
|
|
212
|
+
console.log(`\n▸ Creating bootstrap stack ${stack} (@ ${cfg.region})`);
|
|
213
|
+
}
|
|
214
|
+
deployBootstrap(cfg, reuseOidc);
|
|
215
|
+
|
|
216
|
+
const artifactsBucket = stackOutput(
|
|
217
|
+
stack,
|
|
218
|
+
BOOTSTRAP_ARTIFACTS_OUTPUT,
|
|
219
|
+
cfg.region,
|
|
220
|
+
);
|
|
221
|
+
const templatesBucket = stackOutput(
|
|
222
|
+
stack,
|
|
223
|
+
BOOTSTRAP_TEMPLATES_OUTPUT,
|
|
224
|
+
cfg.region,
|
|
225
|
+
);
|
|
226
|
+
if (!artifactsBucket) {
|
|
227
|
+
console.error(
|
|
228
|
+
" ✗ bootstrap stack deployed but its ArtifactsBucket output could not be read.",
|
|
229
|
+
);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
|
|
233
|
+
if (templatesBucket) console.log(` ✓ templates bucket: ${templatesBucket}`);
|
|
234
|
+
|
|
235
|
+
// Upload the placeholder the app.yaml function is later created from, so the
|
|
236
|
+
// account is ready for `rastack init` without a second manual step.
|
|
237
|
+
console.log("\n▸ Uploading the Lambda placeholder bundle");
|
|
238
|
+
if (!uploadPlaceholder(artifactsBucket, cfg.region)) {
|
|
239
|
+
console.error(
|
|
240
|
+
" ✗ deploy/cloudformation/placeholder/bootstrap not found — the placeholder\n" +
|
|
241
|
+
" was not uploaded. `rastack init` will upload it on the first app deploy.",
|
|
242
|
+
);
|
|
243
|
+
} else {
|
|
244
|
+
console.log(" ✓ placeholder uploaded");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
console.log(
|
|
248
|
+
`\n ✓ account bootstrapped (${stack} @ ${cfg.region}).\n` +
|
|
249
|
+
` Run \`rastack init\` in an app repo to deploy a backend against it.\n`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function main(argv: string[]): Promise<void> {
|
|
254
|
+
const flags = parseFlags(argv);
|
|
255
|
+
let cfg = seedConfig(flags);
|
|
256
|
+
|
|
257
|
+
if (canPrompt(flags.nonInteractive) && !flags.print) {
|
|
258
|
+
console.log(
|
|
259
|
+
"\n rastack bootstrap — stand the tier-0 AWS account stack up\n",
|
|
260
|
+
);
|
|
261
|
+
cfg = await promptMissing(cfg);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const errors = validateBootstrapConfig(cfg);
|
|
265
|
+
if (errors.length) {
|
|
266
|
+
for (const e of errors) console.error(` ✗ ${e}`);
|
|
267
|
+
console.error(
|
|
268
|
+
`\n ${errors.length} problem(s). Pass the missing values as flags` +
|
|
269
|
+
` (--owner, --repo, --branch, --region, --prefix).`,
|
|
270
|
+
);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (flags.print) {
|
|
275
|
+
printPlan(cfg, flags.reuseOidc);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (canPrompt(flags.nonInteractive)) {
|
|
280
|
+
const ok = await confirm(
|
|
281
|
+
`Create/update bootstrap stack ${bootstrapStackName(cfg)} in AWS ${cfg.region} now? [y/N]`,
|
|
282
|
+
);
|
|
283
|
+
if (!ok) {
|
|
284
|
+
console.log(
|
|
285
|
+
"\n Skipped. Re-run to deploy, or `rastack bootstrap --print` for the command.\n",
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
standUp(cfg, flags.reuseOidc);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (require.main === module) {
|
|
295
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
296
|
+
console.error(` ✗ ${(err as Error).message}`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
});
|
|
299
|
+
}
|