rastack 0.0.37 → 0.0.39
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 +4 -0
- package/dist/deploy/config.d.ts +22 -1
- package/dist/deploy/config.js +66 -2
- package/dist/rastack-bootstrap.d.ts +24 -0
- package/dist/rastack-bootstrap.js +222 -0
- package/dist/rastack-update.js +4 -2
- package/dist/rastack.d.ts +1 -0
- package/dist/rastack.js +7 -0
- package/dist/update.d.ts +8 -0
- package/dist/update.js +11 -0
- package/package.json +1 -1
- package/src/deploy/config.ts +69 -1
- package/src/rastack-bootstrap.ts +299 -0
- package/src/rastack-update.ts +6 -2
- package/src/rastack.ts +7 -0
- package/src/update.ts +11 -0
- package/test/deploy.spec.ts +48 -3
- package/test/update.spec.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [0.0.39](https://github.com/theserverkid/reactapistack/compare/v0.0.38...v0.0.39) (2026-07-11)
|
|
6
|
+
|
|
7
|
+
### [0.0.38](https://github.com/theserverkid/reactapistack/compare/v0.0.37...v0.0.38) (2026-07-11)
|
|
8
|
+
|
|
5
9
|
### [0.0.37](https://github.com/theserverkid/reactapistack/compare/v0.0.36...v0.0.37) (2026-07-11)
|
|
6
10
|
|
|
7
11
|
### [0.0.36](https://github.com/theserverkid/reactapistack/compare/v0.0.35...v0.0.36) (2026-07-11)
|
package/dist/deploy/config.d.ts
CHANGED
|
@@ -91,6 +91,14 @@ export declare function normalizeConfig(partial?: PartialDeployConfig): DeployCo
|
|
|
91
91
|
* config that passes here also passes CloudFormation's own validation.
|
|
92
92
|
*/
|
|
93
93
|
export declare function validateConfig(cfg: DeployConfig): string[];
|
|
94
|
+
/**
|
|
95
|
+
* Return the subset of problems that block standing the **tier-0 bootstrap
|
|
96
|
+
* stack** up. The bootstrap stack (bootstrap.yaml) is account+region scoped — it
|
|
97
|
+
* only needs the name prefix, the region, and the GitHub coordinates that anchor
|
|
98
|
+
* the OIDC trust — so `rastack bootstrap` validates just those, not the per-app
|
|
99
|
+
* fields (`app`, namespace, Lambda build) that `validateConfig` also requires.
|
|
100
|
+
*/
|
|
101
|
+
export declare function validateBootstrapConfig(cfg: DeployConfig): string[];
|
|
94
102
|
/** Deep-merge a patch over a base config (used by `deploy`/`init` reconfigure). */
|
|
95
103
|
export declare function mergeConfig(base: DeployConfig, patch: PartialDeployConfig): DeployConfig;
|
|
96
104
|
/**
|
|
@@ -109,10 +117,23 @@ export interface StackParameter {
|
|
|
109
117
|
key: string;
|
|
110
118
|
value: string;
|
|
111
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Regions where Cognito advanced security (threat protection) is NOT offered, as
|
|
122
|
+
* of 2026-07. `AWS::Cognito::UserPool` carrying a `UserPoolAddOns` block is
|
|
123
|
+
* undeployable there — CloudFormation's early-validation ResourceExistenceCheck
|
|
124
|
+
* rejects the change set — so app.yaml must omit the add-on (AdvancedSecurity=OFF)
|
|
125
|
+
* in these regions. A denylist (not an allowlist) so new/standard regions keep
|
|
126
|
+
* the secure default; AWS expands coverage over time, so revisit when they do.
|
|
127
|
+
*/
|
|
128
|
+
export declare const REGIONS_WITHOUT_COGNITO_ASF: ReadonlySet<string>;
|
|
129
|
+
/** Whether Cognito advanced security can be enabled in a region. */
|
|
130
|
+
export declare function cognitoAdvancedSecuritySupported(region: string): boolean;
|
|
112
131
|
/**
|
|
113
132
|
* The `--parameter-overrides` for `aws cloudformation deploy` of app.yaml,
|
|
114
133
|
* derived from the config. Order and keys match the template's `Parameters`.
|
|
115
|
-
* `ArtifactsBucket` (which has no template default) is included only when set
|
|
134
|
+
* `ArtifactsBucket` (which has no template default) is included only when set;
|
|
135
|
+
* `AdvancedSecurity` is forced OFF only in regions that lack the feature (else
|
|
136
|
+
* the template's ENFORCED default stands).
|
|
116
137
|
*/
|
|
117
138
|
export declare function stackParameters(cfg: DeployConfig): StackParameter[];
|
|
118
139
|
/** The in-repo CloudFormation templates `rastack init` deploys from. */
|
package/dist/deploy/config.js
CHANGED
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
* thin IO wrappers that read/write the file and shell out.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.APP_STACK_OUTPUTS = exports.BOOTSTRAP_TEMPLATES_OUTPUT = exports.BOOTSTRAP_ARTIFACTS_OUTPUT = exports.BOOTSTRAP_TEMPLATE_FILE = exports.APP_TEMPLATE_FILE = exports.DEFAULTS = exports.DEFAULT_LAMBDA = void 0;
|
|
21
|
+
exports.APP_STACK_OUTPUTS = exports.BOOTSTRAP_TEMPLATES_OUTPUT = exports.BOOTSTRAP_ARTIFACTS_OUTPUT = exports.BOOTSTRAP_TEMPLATE_FILE = exports.APP_TEMPLATE_FILE = exports.REGIONS_WITHOUT_COGNITO_ASF = exports.DEFAULTS = exports.DEFAULT_LAMBDA = void 0;
|
|
22
22
|
exports.normalizeConfig = normalizeConfig;
|
|
23
23
|
exports.validateConfig = validateConfig;
|
|
24
|
+
exports.validateBootstrapConfig = validateBootstrapConfig;
|
|
24
25
|
exports.mergeConfig = mergeConfig;
|
|
25
26
|
exports.parseGitHubRemote = parseGitHubRemote;
|
|
27
|
+
exports.cognitoAdvancedSecuritySupported = cognitoAdvancedSecuritySupported;
|
|
26
28
|
exports.stackParameters = stackParameters;
|
|
27
29
|
exports.deployStackArgs = deployStackArgs;
|
|
28
30
|
exports.deployStackCommand = deployStackCommand;
|
|
@@ -120,6 +122,33 @@ function validateConfig(cfg) {
|
|
|
120
122
|
errors.push("lambda.bin is required.");
|
|
121
123
|
return errors;
|
|
122
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Return the subset of problems that block standing the **tier-0 bootstrap
|
|
127
|
+
* stack** up. The bootstrap stack (bootstrap.yaml) is account+region scoped — it
|
|
128
|
+
* only needs the name prefix, the region, and the GitHub coordinates that anchor
|
|
129
|
+
* the OIDC trust — so `rastack bootstrap` validates just those, not the per-app
|
|
130
|
+
* fields (`app`, namespace, Lambda build) that `validateConfig` also requires.
|
|
131
|
+
*/
|
|
132
|
+
function validateBootstrapConfig(cfg) {
|
|
133
|
+
const errors = [];
|
|
134
|
+
if (!SLUG.test(cfg.namePrefix))
|
|
135
|
+
errors.push(`namePrefix "${cfg.namePrefix}" must match ${SLUG}.`);
|
|
136
|
+
if (!cfg.region)
|
|
137
|
+
errors.push("region is required (e.g. `af-south-1`).");
|
|
138
|
+
else if (!REGION.test(cfg.region))
|
|
139
|
+
errors.push(`region "${cfg.region}" does not look like an AWS region (e.g. af-south-1).`);
|
|
140
|
+
if (!cfg.github.owner)
|
|
141
|
+
errors.push("github.owner is required.");
|
|
142
|
+
else if (!GH_NAME.test(cfg.github.owner))
|
|
143
|
+
errors.push(`github.owner "${cfg.github.owner}" is not a valid GitHub name.`);
|
|
144
|
+
if (!cfg.github.repo)
|
|
145
|
+
errors.push("github.repo is required.");
|
|
146
|
+
else if (!GH_NAME.test(cfg.github.repo))
|
|
147
|
+
errors.push(`github.repo "${cfg.github.repo}" is not a valid GitHub name.`);
|
|
148
|
+
if (!cfg.github.branch)
|
|
149
|
+
errors.push("github.branch is required.");
|
|
150
|
+
return errors;
|
|
151
|
+
}
|
|
123
152
|
/** Deep-merge a patch over a base config (used by `deploy`/`init` reconfigure). */
|
|
124
153
|
function mergeConfig(base, patch) {
|
|
125
154
|
return normalizeConfig({
|
|
@@ -149,10 +178,43 @@ function parseGitHubRemote(url) {
|
|
|
149
178
|
return null;
|
|
150
179
|
return { owner, repo };
|
|
151
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Regions where Cognito advanced security (threat protection) is NOT offered, as
|
|
183
|
+
* of 2026-07. `AWS::Cognito::UserPool` carrying a `UserPoolAddOns` block is
|
|
184
|
+
* undeployable there — CloudFormation's early-validation ResourceExistenceCheck
|
|
185
|
+
* rejects the change set — so app.yaml must omit the add-on (AdvancedSecurity=OFF)
|
|
186
|
+
* in these regions. A denylist (not an allowlist) so new/standard regions keep
|
|
187
|
+
* the secure default; AWS expands coverage over time, so revisit when they do.
|
|
188
|
+
*/
|
|
189
|
+
exports.REGIONS_WITHOUT_COGNITO_ASF = new Set([
|
|
190
|
+
"af-south-1", // Cape Town
|
|
191
|
+
"ap-east-1", // Hong Kong
|
|
192
|
+
"ap-east-2", // Taipei
|
|
193
|
+
"ap-south-2", // Hyderabad
|
|
194
|
+
"ap-southeast-3", // Jakarta
|
|
195
|
+
"ap-southeast-4", // Melbourne
|
|
196
|
+
"ap-southeast-5", // Malaysia
|
|
197
|
+
"ap-southeast-7", // Thailand
|
|
198
|
+
"ca-west-1", // Calgary
|
|
199
|
+
"eu-central-2", // Zurich
|
|
200
|
+
"eu-south-2", // Spain
|
|
201
|
+
"me-central-1", // UAE
|
|
202
|
+
"mx-central-1", // Mexico (Central)
|
|
203
|
+
"us-gov-east-1",
|
|
204
|
+
"us-gov-west-1",
|
|
205
|
+
"cn-north-1", // Beijing
|
|
206
|
+
"cn-northwest-1", // Ningxia
|
|
207
|
+
]);
|
|
208
|
+
/** Whether Cognito advanced security can be enabled in a region. */
|
|
209
|
+
function cognitoAdvancedSecuritySupported(region) {
|
|
210
|
+
return !exports.REGIONS_WITHOUT_COGNITO_ASF.has(region);
|
|
211
|
+
}
|
|
152
212
|
/**
|
|
153
213
|
* The `--parameter-overrides` for `aws cloudformation deploy` of app.yaml,
|
|
154
214
|
* derived from the config. Order and keys match the template's `Parameters`.
|
|
155
|
-
* `ArtifactsBucket` (which has no template default) is included only when set
|
|
215
|
+
* `ArtifactsBucket` (which has no template default) is included only when set;
|
|
216
|
+
* `AdvancedSecurity` is forced OFF only in regions that lack the feature (else
|
|
217
|
+
* the template's ENFORCED default stands).
|
|
156
218
|
*/
|
|
157
219
|
function stackParameters(cfg) {
|
|
158
220
|
const params = [
|
|
@@ -165,6 +227,8 @@ function stackParameters(cfg) {
|
|
|
165
227
|
];
|
|
166
228
|
if (cfg.artifactsBucket)
|
|
167
229
|
params.push({ key: "ArtifactsBucket", value: cfg.artifactsBucket });
|
|
230
|
+
if (!cognitoAdvancedSecuritySupported(cfg.region))
|
|
231
|
+
params.push({ key: "AdvancedSecurity", value: "OFF" });
|
|
168
232
|
return params;
|
|
169
233
|
}
|
|
170
234
|
/** The in-repo CloudFormation templates `rastack init` deploys from. */
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `rastack bootstrap` — stand the **tier-0 bootstrap stack** up for an AWS
|
|
4
|
+
* account. This is the once-per-account+region step that prepares an account to
|
|
5
|
+
* host React API Stack backends, decoupled from any single app.
|
|
6
|
+
*
|
|
7
|
+
* rastack bootstrap [--owner <o>] [--repo <r>] [--branch <b>]
|
|
8
|
+
* [--region <r>] [--prefix <p>]
|
|
9
|
+
* [--reuse-oidc] [--print] [--yes]
|
|
10
|
+
*
|
|
11
|
+
* It deploys `deploy/cloudformation/bootstrap.yaml`, which creates:
|
|
12
|
+
* • the S3 buckets that hold the Lambda placeholder + hosted templates, and
|
|
13
|
+
* • the GitHub OIDC provider + trust that lets CI assume a deploy role.
|
|
14
|
+
* It then uploads the Lambda placeholder bundle to the artifacts bucket, so the
|
|
15
|
+
* account is immediately ready for per-app deploys (`rastack init`).
|
|
16
|
+
*
|
|
17
|
+
* `rastack init` still creates this stack on demand if it is missing, so
|
|
18
|
+
* `bootstrap` is the explicit, app-independent way to do the same thing up front
|
|
19
|
+
* (e.g. an account admin preparing an account before app teams run `init`).
|
|
20
|
+
* `--reuse-oidc` skips creating the GitHub OIDC provider (AWS allows only one per
|
|
21
|
+
* URL per account); `--print` shows the exact single-line `aws cloudformation
|
|
22
|
+
* deploy` command without touching the cloud.
|
|
23
|
+
*/
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
const config_1 = require("./deploy/config");
|
|
27
|
+
const io_1 = require("./deploy/io");
|
|
28
|
+
function parseFlags(argv) {
|
|
29
|
+
const patch = { github: {} };
|
|
30
|
+
let reuseOidc = false;
|
|
31
|
+
let print = false;
|
|
32
|
+
let nonInteractive = false;
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const a = argv[i];
|
|
35
|
+
const next = () => argv[++i] ?? "";
|
|
36
|
+
switch (a) {
|
|
37
|
+
case "--owner":
|
|
38
|
+
patch.github.owner = next();
|
|
39
|
+
break;
|
|
40
|
+
case "--repo":
|
|
41
|
+
patch.github.repo = next();
|
|
42
|
+
break;
|
|
43
|
+
case "--branch":
|
|
44
|
+
patch.github.branch = next();
|
|
45
|
+
break;
|
|
46
|
+
case "--region":
|
|
47
|
+
patch.region = next();
|
|
48
|
+
break;
|
|
49
|
+
case "--prefix":
|
|
50
|
+
patch.namePrefix = next();
|
|
51
|
+
break;
|
|
52
|
+
case "--reuse-oidc":
|
|
53
|
+
reuseOidc = true;
|
|
54
|
+
break;
|
|
55
|
+
case "--print":
|
|
56
|
+
case "--dry-run":
|
|
57
|
+
print = true;
|
|
58
|
+
break;
|
|
59
|
+
case "--yes":
|
|
60
|
+
case "-y":
|
|
61
|
+
case "--non-interactive":
|
|
62
|
+
nonInteractive = true;
|
|
63
|
+
break;
|
|
64
|
+
default:
|
|
65
|
+
if (a.startsWith("-")) {
|
|
66
|
+
console.error(`Unknown flag: ${a}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { patch, reuseOidc, print, nonInteractive };
|
|
72
|
+
}
|
|
73
|
+
/** Seed a config: existing file → flags → git remote → defaults. */
|
|
74
|
+
function seedConfig(flags) {
|
|
75
|
+
const existing = (0, io_1.loadConfig)();
|
|
76
|
+
let cfg = existing
|
|
77
|
+
? (0, config_1.mergeConfig)(existing, flags.patch)
|
|
78
|
+
: (0, config_1.normalizeConfig)(flags.patch);
|
|
79
|
+
// Default the GitHub coordinates from `origin` when not already known — they
|
|
80
|
+
// anchor the OIDC trust the bootstrap stack sets up.
|
|
81
|
+
if (!cfg.github.owner || !cfg.github.repo) {
|
|
82
|
+
const url = (0, io_1.gitRemoteUrl)();
|
|
83
|
+
const remote = url ? (0, config_1.parseGitHubRemote)(url) : null;
|
|
84
|
+
if (remote) {
|
|
85
|
+
cfg = (0, config_1.mergeConfig)(cfg, {
|
|
86
|
+
github: {
|
|
87
|
+
owner: cfg.github.owner || remote.owner,
|
|
88
|
+
repo: cfg.github.repo || remote.repo,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return cfg;
|
|
94
|
+
}
|
|
95
|
+
async function promptMissing(cfg) {
|
|
96
|
+
return (0, io_1.withPrompt)(async (rl) => {
|
|
97
|
+
const owner = await (0, io_1.ask)(rl, "GitHub owner", cfg.github.owner);
|
|
98
|
+
const repo = await (0, io_1.ask)(rl, "GitHub repo", cfg.github.repo);
|
|
99
|
+
const branch = await (0, io_1.ask)(rl, "Deploy branch", cfg.github.branch);
|
|
100
|
+
const region = await (0, io_1.ask)(rl, "AWS region", cfg.region);
|
|
101
|
+
const prefix = await (0, io_1.ask)(rl, "Name prefix", cfg.namePrefix);
|
|
102
|
+
return (0, config_1.mergeConfig)(cfg, {
|
|
103
|
+
region,
|
|
104
|
+
namePrefix: prefix,
|
|
105
|
+
github: { owner, repo, branch },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Yes/no confirmation with a default of "no" — gates the cloud-mutating step. */
|
|
110
|
+
async function confirm(question) {
|
|
111
|
+
return (0, io_1.withPrompt)(async (rl) => {
|
|
112
|
+
const answer = await (0, io_1.ask)(rl, question, "no");
|
|
113
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Print the exact command `bootstrap` would run, without touching the cloud. */
|
|
117
|
+
function printPlan(cfg, reuseOidc) {
|
|
118
|
+
console.log(`\n Bootstrap plan (${(0, config_1.bootstrapStackName)(cfg)} @ ${cfg.region})\n`);
|
|
119
|
+
console.log(" ── Tier-0 bootstrap stack (bootstrap.yaml) ──");
|
|
120
|
+
console.log("\n " + (0, config_1.bootstrapStackCommand)(cfg, reuseOidc) + "\n");
|
|
121
|
+
console.log(" Then `rastack init` (or `rastack bootstrap` again) resolves this stack's\n" +
|
|
122
|
+
" ArtifactsBucket to create app backends. Nothing here is app-specific.\n");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Deploy the bootstrap stack, retrying once against an account that already has
|
|
126
|
+
* a GitHub OIDC provider (AWS allows only one per URL) when the caller did not
|
|
127
|
+
* already ask to reuse it.
|
|
128
|
+
*/
|
|
129
|
+
function deployBootstrap(cfg, reuseOidc) {
|
|
130
|
+
try {
|
|
131
|
+
(0, io_1.awsRun)((0, config_1.bootstrapStackArgs)(cfg, reuseOidc));
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (reuseOidc) {
|
|
135
|
+
console.error(`\n ✗ bootstrap stack deploy failed.\n ${err.message}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
// The most common cause is an account that already has a GitHub OIDC
|
|
139
|
+
// provider. Retry telling the template to reuse it instead of creating a
|
|
140
|
+
// second (which CloudFormation rejects).
|
|
141
|
+
console.log(" • retrying bootstrap, reusing the account's existing GitHub OIDC provider");
|
|
142
|
+
try {
|
|
143
|
+
(0, io_1.awsRun)((0, config_1.bootstrapStackArgs)(cfg, true));
|
|
144
|
+
}
|
|
145
|
+
catch (retryErr) {
|
|
146
|
+
console.error(`\n ✗ bootstrap stack deploy failed.\n ${retryErr.message}`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Stand the tier-0 bootstrap stack up and upload the Lambda placeholder. */
|
|
152
|
+
function standUp(cfg, reuseOidc) {
|
|
153
|
+
if (!(0, io_1.awsAvailable)()) {
|
|
154
|
+
console.error(" ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
|
|
155
|
+
" or re-run with --print to see the command.");
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const stack = (0, config_1.bootstrapStackName)(cfg);
|
|
159
|
+
const existing = (0, io_1.stackOutput)(stack, config_1.BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
|
|
160
|
+
if (existing) {
|
|
161
|
+
console.log(`\n▸ Bootstrap stack ${stack} already exists — reconciling (idempotent).`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
console.log(`\n▸ Creating bootstrap stack ${stack} (@ ${cfg.region})`);
|
|
165
|
+
}
|
|
166
|
+
deployBootstrap(cfg, reuseOidc);
|
|
167
|
+
const artifactsBucket = (0, io_1.stackOutput)(stack, config_1.BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region);
|
|
168
|
+
const templatesBucket = (0, io_1.stackOutput)(stack, config_1.BOOTSTRAP_TEMPLATES_OUTPUT, cfg.region);
|
|
169
|
+
if (!artifactsBucket) {
|
|
170
|
+
console.error(" ✗ bootstrap stack deployed but its ArtifactsBucket output could not be read.");
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
|
|
174
|
+
if (templatesBucket)
|
|
175
|
+
console.log(` ✓ templates bucket: ${templatesBucket}`);
|
|
176
|
+
// Upload the placeholder the app.yaml function is later created from, so the
|
|
177
|
+
// account is ready for `rastack init` without a second manual step.
|
|
178
|
+
console.log("\n▸ Uploading the Lambda placeholder bundle");
|
|
179
|
+
if (!(0, io_1.uploadPlaceholder)(artifactsBucket, cfg.region)) {
|
|
180
|
+
console.error(" ✗ deploy/cloudformation/placeholder/bootstrap not found — the placeholder\n" +
|
|
181
|
+
" was not uploaded. `rastack init` will upload it on the first app deploy.");
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.log(" ✓ placeholder uploaded");
|
|
185
|
+
}
|
|
186
|
+
console.log(`\n ✓ account bootstrapped (${stack} @ ${cfg.region}).\n` +
|
|
187
|
+
` Run \`rastack init\` in an app repo to deploy a backend against it.\n`);
|
|
188
|
+
}
|
|
189
|
+
async function main(argv) {
|
|
190
|
+
const flags = parseFlags(argv);
|
|
191
|
+
let cfg = seedConfig(flags);
|
|
192
|
+
if ((0, io_1.canPrompt)(flags.nonInteractive) && !flags.print) {
|
|
193
|
+
console.log("\n rastack bootstrap — stand the tier-0 AWS account stack up\n");
|
|
194
|
+
cfg = await promptMissing(cfg);
|
|
195
|
+
}
|
|
196
|
+
const errors = (0, config_1.validateBootstrapConfig)(cfg);
|
|
197
|
+
if (errors.length) {
|
|
198
|
+
for (const e of errors)
|
|
199
|
+
console.error(` ✗ ${e}`);
|
|
200
|
+
console.error(`\n ${errors.length} problem(s). Pass the missing values as flags` +
|
|
201
|
+
` (--owner, --repo, --branch, --region, --prefix).`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (flags.print) {
|
|
205
|
+
printPlan(cfg, flags.reuseOidc);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if ((0, io_1.canPrompt)(flags.nonInteractive)) {
|
|
209
|
+
const ok = await confirm(`Create/update bootstrap stack ${(0, config_1.bootstrapStackName)(cfg)} in AWS ${cfg.region} now? [y/N]`);
|
|
210
|
+
if (!ok) {
|
|
211
|
+
console.log("\n Skipped. Re-run to deploy, or `rastack bootstrap --print` for the command.\n");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
standUp(cfg, flags.reuseOidc);
|
|
216
|
+
}
|
|
217
|
+
if (require.main === module) {
|
|
218
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
219
|
+
console.error(` ✗ ${err.message}`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
});
|
|
222
|
+
}
|
package/dist/rastack-update.js
CHANGED
|
@@ -54,6 +54,8 @@ const child_process_1 = require("child_process");
|
|
|
54
54
|
const fs_1 = require("fs");
|
|
55
55
|
const path = __importStar(require("path"));
|
|
56
56
|
const update_1 = require("./update");
|
|
57
|
+
/** The npm executable for this platform (`npm.cmd` on Windows, else `npm`). */
|
|
58
|
+
const NPM = (0, update_1.npmBinary)();
|
|
57
59
|
/** The installed rastack package directory (…/rastack), one up from dist/. */
|
|
58
60
|
const PKG_DIR = path.join(__dirname, "..");
|
|
59
61
|
const PKG_JSON = path.join(PKG_DIR, "package.json");
|
|
@@ -69,7 +71,7 @@ function readVersion(file) {
|
|
|
69
71
|
/** Capture `npm <args>` stdout, or null when npm is absent / the call fails. */
|
|
70
72
|
function npmCapture(args) {
|
|
71
73
|
try {
|
|
72
|
-
return (0, child_process_1.execFileSync)(
|
|
74
|
+
return (0, child_process_1.execFileSync)(NPM, args, {
|
|
73
75
|
stdio: ["ignore", "pipe", "pipe"],
|
|
74
76
|
})
|
|
75
77
|
.toString()
|
|
@@ -119,7 +121,7 @@ function main(argv) {
|
|
|
119
121
|
// Re-runs the developer's own npm to update this global package — no
|
|
120
122
|
// untrusted input, and execFileSync (no shell) avoids any injection.
|
|
121
123
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
122
|
-
(0, child_process_1.execFileSync)(
|
|
124
|
+
(0, child_process_1.execFileSync)(NPM, args, { stdio: "inherit", env });
|
|
123
125
|
}
|
|
124
126
|
catch (err) {
|
|
125
127
|
console.error(`\n ✗ npm failed to update rastack. Re-run for detail:\n npm install -g ${(0, update_1.npmInstallArgs)(flags.version)[2]}\n ${err.message}`);
|
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/dist/update.d.ts
CHANGED
|
@@ -23,6 +23,14 @@ export interface UpdateFlags {
|
|
|
23
23
|
* 0.0.40`). Unknown values default to `latest`.
|
|
24
24
|
*/
|
|
25
25
|
export declare function parseUpdateFlags(argv: string[]): UpdateFlags;
|
|
26
|
+
/**
|
|
27
|
+
* The npm executable to spawn for a platform. On Windows npm is a `.cmd` shim,
|
|
28
|
+
* not a real executable, so `execFile("npm", …)` (no shell) fails with
|
|
29
|
+
* `spawnSync npm ENOENT` — the direct-spawn path only resolves real binaries,
|
|
30
|
+
* never `PATHEXT` scripts. Spawn `npm.cmd` there; POSIX keeps plain `npm`.
|
|
31
|
+
* (`aws` is unaffected — it installs as `aws.exe`, a real executable.)
|
|
32
|
+
*/
|
|
33
|
+
export declare function npmBinary(platform?: string): string;
|
|
26
34
|
/** The npm package spec to install for a target version. */
|
|
27
35
|
export declare function installSpec(version: string): string;
|
|
28
36
|
/** The `npm <args>` (after the `npm` binary) that installs the target globally. */
|
package/dist/update.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.parseUpdateFlags = parseUpdateFlags;
|
|
15
|
+
exports.npmBinary = npmBinary;
|
|
15
16
|
exports.installSpec = installSpec;
|
|
16
17
|
exports.npmInstallArgs = npmInstallArgs;
|
|
17
18
|
exports.globalPrefixFromPackageDir = globalPrefixFromPackageDir;
|
|
@@ -48,6 +49,16 @@ function parseUpdateFlags(argv) {
|
|
|
48
49
|
}
|
|
49
50
|
return { version, check, dryRun };
|
|
50
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The npm executable to spawn for a platform. On Windows npm is a `.cmd` shim,
|
|
54
|
+
* not a real executable, so `execFile("npm", …)` (no shell) fails with
|
|
55
|
+
* `spawnSync npm ENOENT` — the direct-spawn path only resolves real binaries,
|
|
56
|
+
* never `PATHEXT` scripts. Spawn `npm.cmd` there; POSIX keeps plain `npm`.
|
|
57
|
+
* (`aws` is unaffected — it installs as `aws.exe`, a real executable.)
|
|
58
|
+
*/
|
|
59
|
+
function npmBinary(platform = process.platform) {
|
|
60
|
+
return platform === "win32" ? "npm.cmd" : "npm";
|
|
61
|
+
}
|
|
51
62
|
/** The npm package spec to install for a target version. */
|
|
52
63
|
function installSpec(version) {
|
|
53
64
|
return !version || version === "latest" ? "rastack" : `rastack@${version}`;
|
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,
|
|
@@ -227,10 +258,45 @@ export interface StackParameter {
|
|
|
227
258
|
value: string;
|
|
228
259
|
}
|
|
229
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Regions where Cognito advanced security (threat protection) is NOT offered, as
|
|
263
|
+
* of 2026-07. `AWS::Cognito::UserPool` carrying a `UserPoolAddOns` block is
|
|
264
|
+
* undeployable there — CloudFormation's early-validation ResourceExistenceCheck
|
|
265
|
+
* rejects the change set — so app.yaml must omit the add-on (AdvancedSecurity=OFF)
|
|
266
|
+
* in these regions. A denylist (not an allowlist) so new/standard regions keep
|
|
267
|
+
* the secure default; AWS expands coverage over time, so revisit when they do.
|
|
268
|
+
*/
|
|
269
|
+
export const REGIONS_WITHOUT_COGNITO_ASF: ReadonlySet<string> = new Set([
|
|
270
|
+
"af-south-1", // Cape Town
|
|
271
|
+
"ap-east-1", // Hong Kong
|
|
272
|
+
"ap-east-2", // Taipei
|
|
273
|
+
"ap-south-2", // Hyderabad
|
|
274
|
+
"ap-southeast-3", // Jakarta
|
|
275
|
+
"ap-southeast-4", // Melbourne
|
|
276
|
+
"ap-southeast-5", // Malaysia
|
|
277
|
+
"ap-southeast-7", // Thailand
|
|
278
|
+
"ca-west-1", // Calgary
|
|
279
|
+
"eu-central-2", // Zurich
|
|
280
|
+
"eu-south-2", // Spain
|
|
281
|
+
"me-central-1", // UAE
|
|
282
|
+
"mx-central-1", // Mexico (Central)
|
|
283
|
+
"us-gov-east-1",
|
|
284
|
+
"us-gov-west-1",
|
|
285
|
+
"cn-north-1", // Beijing
|
|
286
|
+
"cn-northwest-1", // Ningxia
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
/** Whether Cognito advanced security can be enabled in a region. */
|
|
290
|
+
export function cognitoAdvancedSecuritySupported(region: string): boolean {
|
|
291
|
+
return !REGIONS_WITHOUT_COGNITO_ASF.has(region);
|
|
292
|
+
}
|
|
293
|
+
|
|
230
294
|
/**
|
|
231
295
|
* The `--parameter-overrides` for `aws cloudformation deploy` of app.yaml,
|
|
232
296
|
* derived from the config. Order and keys match the template's `Parameters`.
|
|
233
|
-
* `ArtifactsBucket` (which has no template default) is included only when set
|
|
297
|
+
* `ArtifactsBucket` (which has no template default) is included only when set;
|
|
298
|
+
* `AdvancedSecurity` is forced OFF only in regions that lack the feature (else
|
|
299
|
+
* the template's ENFORCED default stands).
|
|
234
300
|
*/
|
|
235
301
|
export function stackParameters(cfg: DeployConfig): StackParameter[] {
|
|
236
302
|
const params: StackParameter[] = [
|
|
@@ -243,6 +309,8 @@ export function stackParameters(cfg: DeployConfig): StackParameter[] {
|
|
|
243
309
|
];
|
|
244
310
|
if (cfg.artifactsBucket)
|
|
245
311
|
params.push({ key: "ArtifactsBucket", value: cfg.artifactsBucket });
|
|
312
|
+
if (!cognitoAdvancedSecuritySupported(cfg.region))
|
|
313
|
+
params.push({ key: "AdvancedSecurity", value: "OFF" });
|
|
246
314
|
return params;
|
|
247
315
|
}
|
|
248
316
|
|
|
@@ -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
|
+
}
|
package/src/rastack-update.ts
CHANGED
|
@@ -22,11 +22,15 @@ import { readFileSync } from "fs";
|
|
|
22
22
|
import * as path from "path";
|
|
23
23
|
import {
|
|
24
24
|
globalPrefixFromPackageDir,
|
|
25
|
+
npmBinary,
|
|
25
26
|
npmInstallArgs,
|
|
26
27
|
parseUpdateFlags,
|
|
27
28
|
updateAvailable,
|
|
28
29
|
} from "./update";
|
|
29
30
|
|
|
31
|
+
/** The npm executable for this platform (`npm.cmd` on Windows, else `npm`). */
|
|
32
|
+
const NPM = npmBinary();
|
|
33
|
+
|
|
30
34
|
/** The installed rastack package directory (…/rastack), one up from dist/. */
|
|
31
35
|
const PKG_DIR = path.join(__dirname, "..");
|
|
32
36
|
const PKG_JSON = path.join(PKG_DIR, "package.json");
|
|
@@ -43,7 +47,7 @@ function readVersion(file: string): string {
|
|
|
43
47
|
/** Capture `npm <args>` stdout, or null when npm is absent / the call fails. */
|
|
44
48
|
function npmCapture(args: string[]): string | null {
|
|
45
49
|
try {
|
|
46
|
-
return execFileSync(
|
|
50
|
+
return execFileSync(NPM, args, {
|
|
47
51
|
stdio: ["ignore", "pipe", "pipe"],
|
|
48
52
|
})
|
|
49
53
|
.toString()
|
|
@@ -102,7 +106,7 @@ function main(argv: string[]): void {
|
|
|
102
106
|
// Re-runs the developer's own npm to update this global package — no
|
|
103
107
|
// untrusted input, and execFileSync (no shell) avoids any injection.
|
|
104
108
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
105
|
-
execFileSync(
|
|
109
|
+
execFileSync(NPM, args, { stdio: "inherit", env });
|
|
106
110
|
} catch (err) {
|
|
107
111
|
console.error(
|
|
108
112
|
`\n ✗ npm failed to update rastack. Re-run for detail:\n npm install -g ${npmInstallArgs(flags.version)[2]}\n ${(err as Error).message}`,
|
package/src/rastack.ts
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)
|
|
@@ -118,6 +119,11 @@ switch (command) {
|
|
|
118
119
|
case "seed":
|
|
119
120
|
run("seed.js");
|
|
120
121
|
break;
|
|
122
|
+
case "bootstrap":
|
|
123
|
+
// Stand the tier-0 bootstrap stack up (buckets + GitHub OIDC) for an AWS
|
|
124
|
+
// account — the once-per-account+region step, decoupled from any app.
|
|
125
|
+
run("rastack-bootstrap.js", rest);
|
|
126
|
+
break;
|
|
121
127
|
case "init":
|
|
122
128
|
// Scaffold config + GitHub Actions and stand the serverless backend up.
|
|
123
129
|
run("rastack-init.js", rest);
|
|
@@ -154,6 +160,7 @@ switch (command) {
|
|
|
154
160
|
` rastack wasm Compile the API to WebAssembly (browser local dev)\n` +
|
|
155
161
|
` rastack scan [files...] [--fail-on-pii]\n` +
|
|
156
162
|
` rastack seed [--env dev|staging] [--source gs://bucket/ | ./myapp-dev/]\n` +
|
|
163
|
+
` rastack bootstrap [--owner o] [--repo r] [--region r] [--prefix p] [--reuse-oidc] [--print] Stand the AWS account stack up\n` +
|
|
157
164
|
` rastack init [--app slug] [--owner o] [--repo r] [--print] [--no-deploy] Scaffold + stand the backend up\n` +
|
|
158
165
|
` rastack deploy [--plan] Ship code to the Lambda (alias for ci deploy)\n` +
|
|
159
166
|
` rastack ci <check|build|deploy> [--plan] Run the CI pipeline (cleaner Actions)\n` +
|
package/src/update.ts
CHANGED
|
@@ -50,6 +50,17 @@ export function parseUpdateFlags(argv: string[]): UpdateFlags {
|
|
|
50
50
|
return { version, check, dryRun };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The npm executable to spawn for a platform. On Windows npm is a `.cmd` shim,
|
|
55
|
+
* not a real executable, so `execFile("npm", …)` (no shell) fails with
|
|
56
|
+
* `spawnSync npm ENOENT` — the direct-spawn path only resolves real binaries,
|
|
57
|
+
* never `PATHEXT` scripts. Spawn `npm.cmd` there; POSIX keeps plain `npm`.
|
|
58
|
+
* (`aws` is unaffected — it installs as `aws.exe`, a real executable.)
|
|
59
|
+
*/
|
|
60
|
+
export function npmBinary(platform: string = process.platform): string {
|
|
61
|
+
return platform === "win32" ? "npm.cmd" : "npm";
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
/** The npm package spec to install for a target version. */
|
|
54
65
|
export function installSpec(version: string): string {
|
|
55
66
|
return !version || version === "latest" ? "rastack" : `rastack@${version}`;
|
package/test/deploy.spec.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
bootstrapStackCommand,
|
|
12
12
|
bootstrapStackName,
|
|
13
13
|
bootstrapStackParameters,
|
|
14
|
+
cognitoAdvancedSecuritySupported,
|
|
14
15
|
APP_STACK_OUTPUTS,
|
|
15
16
|
lambdaEnv,
|
|
16
17
|
mergeConfig,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
scaffoldFiles,
|
|
21
22
|
stackParameters,
|
|
22
23
|
validateConfig,
|
|
24
|
+
validateBootstrapConfig,
|
|
23
25
|
zipStore,
|
|
24
26
|
renderCiWorkflow,
|
|
25
27
|
renderDeployWorkflow,
|
|
@@ -168,7 +170,10 @@ describe("parseGitHubRemote", () => {
|
|
|
168
170
|
|
|
169
171
|
describe("stack parameters + launch command", () => {
|
|
170
172
|
it("emits the app.yaml parameters in template order", () => {
|
|
171
|
-
|
|
173
|
+
// A region WITH Cognito advanced security, so AdvancedSecurity is left off.
|
|
174
|
+
const params = stackParameters(
|
|
175
|
+
base({ region: "us-east-1", artifactsBucket: "art-bucket" }),
|
|
176
|
+
);
|
|
172
177
|
expect(params.map((p) => p.key)).toEqual([
|
|
173
178
|
"AppName",
|
|
174
179
|
"NamePrefix",
|
|
@@ -181,9 +186,24 @@ describe("stack parameters + launch command", () => {
|
|
|
181
186
|
});
|
|
182
187
|
|
|
183
188
|
it("omits ArtifactsBucket when unset (it has no template default)", () => {
|
|
184
|
-
expect(
|
|
185
|
-
"
|
|
189
|
+
expect(
|
|
190
|
+
stackParameters(base({ region: "us-east-1" })).map((p) => p.key),
|
|
191
|
+
).not.toContain("ArtifactsBucket");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("forces AdvancedSecurity=OFF only in regions lacking Cognito ASF", () => {
|
|
195
|
+
// af-south-1 (the repo default) has no threat protection — a User Pool with
|
|
196
|
+
// a UserPoolAddOns block is undeployable, so the override must be emitted.
|
|
197
|
+
expect(cognitoAdvancedSecuritySupported("af-south-1")).toBe(false);
|
|
198
|
+
expect(cognitoAdvancedSecuritySupported("us-east-1")).toBe(true);
|
|
199
|
+
|
|
200
|
+
const capeTown = stackParameters(base({ region: "af-south-1" }));
|
|
201
|
+
expect(capeTown.find((p) => p.key === "AdvancedSecurity")?.value).toBe(
|
|
202
|
+
"OFF",
|
|
186
203
|
);
|
|
204
|
+
|
|
205
|
+
const virginia = stackParameters(base({ region: "us-east-1" }));
|
|
206
|
+
expect(virginia.map((p) => p.key)).not.toContain("AdvancedSecurity");
|
|
187
207
|
});
|
|
188
208
|
|
|
189
209
|
it("always launches from the in-repo --template-file (deploy takes no URL)", () => {
|
|
@@ -259,6 +279,31 @@ describe("bootstrap stack", () => {
|
|
|
259
279
|
expect(cmd).toContain("--stack-name rastack-bootstrap");
|
|
260
280
|
expect(`aws ${bootstrapStackArgs(base()).join(" ")}`).toBe(cmd);
|
|
261
281
|
});
|
|
282
|
+
|
|
283
|
+
it("validates only the account-level fields, not the per-app ones", () => {
|
|
284
|
+
// A config with no `app` is fine for bootstrap — it never touches app.yaml.
|
|
285
|
+
const cfg = normalizeConfig({
|
|
286
|
+
github: { owner: "theserverkid", repo: "reactapistack-demo" },
|
|
287
|
+
});
|
|
288
|
+
expect(validateConfig(cfg)).toContain(
|
|
289
|
+
"app is required (a short slug, e.g. `airline`).",
|
|
290
|
+
);
|
|
291
|
+
expect(validateBootstrapConfig(cfg)).toEqual([]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("still flags a bad prefix, region, or GitHub coordinates", () => {
|
|
295
|
+
expect(
|
|
296
|
+
validateBootstrapConfig(base({ namePrefix: "Bad_Prefix" })),
|
|
297
|
+
).toContainEqual(expect.stringContaining("namePrefix"));
|
|
298
|
+
expect(
|
|
299
|
+
validateBootstrapConfig(base({ region: "not-a-region" })),
|
|
300
|
+
).toContainEqual(expect.stringContaining("region"));
|
|
301
|
+
expect(
|
|
302
|
+
validateBootstrapConfig(
|
|
303
|
+
normalizeConfig({ app: "airline", github: { repo: "demo" } }),
|
|
304
|
+
),
|
|
305
|
+
).toContain("github.owner is required.");
|
|
306
|
+
});
|
|
262
307
|
});
|
|
263
308
|
|
|
264
309
|
describe("app stack outputs map", () => {
|
package/test/update.spec.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
compareSemver,
|
|
3
3
|
globalPrefixFromPackageDir,
|
|
4
4
|
installSpec,
|
|
5
|
+
npmBinary,
|
|
5
6
|
npmInstallArgs,
|
|
6
7
|
parseUpdateFlags,
|
|
7
8
|
updateAvailable,
|
|
@@ -47,6 +48,14 @@ describe("update — npm spec + args", () => {
|
|
|
47
48
|
]);
|
|
48
49
|
expect(npmInstallArgs("0.0.40")[2]).toBe("rastack@0.0.40");
|
|
49
50
|
});
|
|
51
|
+
|
|
52
|
+
it("spawns npm.cmd on Windows and plain npm elsewhere", () => {
|
|
53
|
+
// npm is a `.cmd` shim on Windows; execFile (no shell) can't launch a bare
|
|
54
|
+
// `npm` there, so the wrapper must target `npm.cmd`. POSIX keeps `npm`.
|
|
55
|
+
expect(npmBinary("win32")).toBe("npm.cmd");
|
|
56
|
+
expect(npmBinary("linux")).toBe("npm");
|
|
57
|
+
expect(npmBinary("darwin")).toBe("npm");
|
|
58
|
+
});
|
|
50
59
|
});
|
|
51
60
|
|
|
52
61
|
describe("update — global prefix derivation", () => {
|