rastack 0.0.35 → 0.0.37

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.
@@ -63,7 +63,7 @@ export interface DeployConfig {
63
63
  resourcesDir: string;
64
64
  /** Where `rastack compile` writes the manifest + OpenAPI. */
65
65
  outDir: string;
66
- /** Bootstrap templates bucket that hosts `app.yaml` (for the launch command). */
66
+ /** Bootstrap templates bucket that hosts `app.yaml` (recorded for reference). */
67
67
  templatesBucket?: string;
68
68
  /** Bootstrap artifacts bucket holding the Lambda placeholder bundle. */
69
69
  artifactsBucket?: string;
@@ -246,35 +246,139 @@ export function stackParameters(cfg: DeployConfig): StackParameter[] {
246
246
  return params;
247
247
  }
248
248
 
249
- /** The S3 URL app.yaml is launched from, when the templates bucket is known. */
250
- export function templateUrl(cfg: DeployConfig): string | null {
251
- if (!cfg.templatesBucket) return null;
252
- return `https://${cfg.templatesBucket}.s3.${cfg.region}.amazonaws.com/app.yaml`;
249
+ /** The in-repo CloudFormation templates `rastack init` deploys from. */
250
+ export const APP_TEMPLATE_FILE = "deploy/cloudformation/app.yaml";
251
+ export const BOOTSTRAP_TEMPLATE_FILE = "deploy/cloudformation/bootstrap.yaml";
252
+
253
+ /**
254
+ * The argv (after `aws`) for an `aws cloudformation deploy`. `init` feeds this to
255
+ * `execFileSync` (no shell), and the printed command string is just this joined
256
+ * with spaces — so what is shown is exactly what runs.
257
+ *
258
+ * Always `--template-file`: the high-level `aws cloudformation deploy` command
259
+ * accepts *only* a local template file. `--template-url` is exclusive to the
260
+ * low-level `create-stack`/`update-stack`/`create-change-set` commands, so
261
+ * pairing it with `deploy` fails with `the following arguments are required:
262
+ * --template-file`. `init` runs in the repo, where both templates are present.
263
+ */
264
+ function cfnDeployArgs(
265
+ templateFile: string,
266
+ stackName: string,
267
+ region: string,
268
+ params: StackParameter[],
269
+ ): string[] {
270
+ return [
271
+ "cloudformation",
272
+ "deploy",
273
+ "--template-file",
274
+ templateFile,
275
+ "--stack-name",
276
+ stackName,
277
+ "--capabilities",
278
+ "CAPABILITY_NAMED_IAM",
279
+ "--region",
280
+ region,
281
+ "--parameter-overrides",
282
+ ...params.map((p) => `${p.key}=${p.value}`),
283
+ ];
284
+ }
285
+
286
+ /**
287
+ * Render an argv as a single-line command string. Deliberately ONE line: a
288
+ * backslash-continued command pasted into Windows PowerShell breaks — there `\`
289
+ * is not a line-continuation, so every `--flag` is parsed as its own statement
290
+ * (`Missing expression after unary operator '--'`). A single line pastes cleanly
291
+ * into bash, zsh and PowerShell alike.
292
+ */
293
+ function awsCommand(args: string[]): string {
294
+ return `aws ${args.join(" ")}`;
295
+ }
296
+
297
+ /** The argv that creates the app's infrastructure (app.yaml). */
298
+ export function deployStackArgs(cfg: DeployConfig): string[] {
299
+ return cfnDeployArgs(
300
+ APP_TEMPLATE_FILE,
301
+ cfg.stackName,
302
+ cfg.region,
303
+ stackParameters(cfg),
304
+ );
253
305
  }
254
306
 
255
307
  /**
256
308
  * The one-time `aws cloudformation deploy` command that creates the app's
257
- * infrastructure. Launched from the hosted template URL when the templates
258
- * bucket is known, else from the in-repo `deploy/cloudformation/app.yaml`.
309
+ * infrastructure (app.yaml), from the in-repo template. `rastack init` runs this
310
+ * for you; it is also printed by `--print` so it can be run by hand.
259
311
  */
260
312
  export function deployStackCommand(cfg: DeployConfig): string {
261
- const src = templateUrl(cfg);
262
- const templateArg = src
263
- ? `--template-url ${src}`
264
- : "--template-file deploy/cloudformation/app.yaml";
265
- const overrides = stackParameters(cfg)
266
- .map((p) => `${p.key}=${p.value}`)
267
- .join(" ");
268
- return [
269
- "aws cloudformation deploy",
270
- templateArg,
271
- `--stack-name ${cfg.stackName}`,
272
- "--capabilities CAPABILITY_NAMED_IAM",
273
- `--region ${cfg.region}`,
274
- `--parameter-overrides ${overrides}`,
275
- ].join(" \\\n ");
313
+ return awsCommand(deployStackArgs(cfg));
314
+ }
315
+
316
+ /**
317
+ * The tier-0 bootstrap stack (bootstrap.yaml) — the S3 buckets that hold the
318
+ * Lambda placeholder + hosted templates, and the GitHub OIDC provider. One per
319
+ * account **and region**; its name is derived from the prefix so `init` can find
320
+ * it (and its `ArtifactsBucket`) without extra config.
321
+ */
322
+ export function bootstrapStackName(cfg: DeployConfig): string {
323
+ return `${cfg.namePrefix}-bootstrap`;
276
324
  }
277
325
 
326
+ /**
327
+ * `--parameter-overrides` for bootstrap.yaml. `reuseOidc` re-runs against an
328
+ * account that already has a GitHub OIDC provider (AWS allows only one per URL):
329
+ * the template then derives the existing provider's ARN itself.
330
+ */
331
+ export function bootstrapStackParameters(
332
+ cfg: DeployConfig,
333
+ reuseOidc = false,
334
+ ): StackParameter[] {
335
+ const params: StackParameter[] = [
336
+ { key: "NamePrefix", value: cfg.namePrefix },
337
+ { key: "GitHubOwner", value: cfg.github.owner },
338
+ { key: "PublisherRepo", value: cfg.github.repo },
339
+ { key: "PublisherBranch", value: cfg.github.branch },
340
+ ];
341
+ if (reuseOidc) params.push({ key: "CreateOIDCProvider", value: "false" });
342
+ return params;
343
+ }
344
+
345
+ /** The argv that creates the bootstrap stack (bootstrap.yaml). */
346
+ export function bootstrapStackArgs(
347
+ cfg: DeployConfig,
348
+ reuseOidc = false,
349
+ ): string[] {
350
+ return cfnDeployArgs(
351
+ BOOTSTRAP_TEMPLATE_FILE,
352
+ bootstrapStackName(cfg),
353
+ cfg.region,
354
+ bootstrapStackParameters(cfg, reuseOidc),
355
+ );
356
+ }
357
+
358
+ /** The one-time `aws cloudformation deploy` command that creates the bootstrap stack. */
359
+ export function bootstrapStackCommand(
360
+ cfg: DeployConfig,
361
+ reuseOidc = false,
362
+ ): string {
363
+ return awsCommand(bootstrapStackArgs(cfg, reuseOidc));
364
+ }
365
+
366
+ /** Stack Output keys `init` reads back off the bootstrap stack. */
367
+ export const BOOTSTRAP_ARTIFACTS_OUTPUT = "ArtifactsBucketName";
368
+ export const BOOTSTRAP_TEMPLATES_OUTPUT = "TemplatesBucketName";
369
+
370
+ /** app.yaml stack Output key → the `DeployOutputs` field it populates. */
371
+ export const APP_STACK_OUTPUTS: { key: string; field: keyof DeployOutputs }[] =
372
+ [
373
+ { key: "FunctionName", field: "functionName" },
374
+ { key: "ApiUrl", field: "apiUrl" },
375
+ { key: "DeployRoleArn", field: "deployRoleArn" },
376
+ { key: "UserPoolId", field: "userPoolId" },
377
+ { key: "UserPoolClientId", field: "userPoolClientId" },
378
+ { key: "JwksUrl", field: "jwksUrl" },
379
+ { key: "WarehouseBucketName", field: "warehouseBucket" },
380
+ ];
381
+
278
382
  /** GitHub repo Variable to wire (name + resolved value, if known from outputs). */
279
383
  export interface RepoVariable {
280
384
  name: string;
@@ -8,3 +8,4 @@
8
8
  export * from "./config";
9
9
  export * from "./ci";
10
10
  export * from "./workflows";
11
+ export * from "./zip";
package/src/deploy/io.ts CHANGED
@@ -4,12 +4,14 @@
4
4
  * IO boundary they wrap.
5
5
  */
6
6
 
7
- import { execSync } from "child_process";
7
+ import { execFileSync, execSync } from "child_process";
8
8
  import * as fs from "fs";
9
+ import * as os from "os";
9
10
  import * as path from "path";
10
11
  import * as readline from "readline";
11
12
  import { CONFIG_PATH } from "./workflows";
12
13
  import { DeployConfig, PartialDeployConfig, normalizeConfig } from "./config";
14
+ import { zipStore } from "./zip";
13
15
 
14
16
  /** Absolute path to the deploy config for a project root. */
15
17
  export function configPath(cwd = process.cwd()): string {
@@ -57,6 +59,91 @@ export function canPrompt(nonInteractive: boolean): boolean {
57
59
  return !nonInteractive && Boolean(process.stdin.isTTY);
58
60
  }
59
61
 
62
+ // ── AWS CLI boundary ─────────────────────────────────────────────────────────
63
+ // `init` shells out to the developer's own AWS CLI (execFileSync — no shell, so
64
+ // no injection surface) to stand the infrastructure up. Read helpers capture
65
+ // output and return null on failure; mutating helpers stream to the console and
66
+ // throw so the caller can report which step failed.
67
+
68
+ /** Capture `aws <args>` stdout, or null when the CLI is absent / the call fails. */
69
+ export function awsCapture(args: string[], cwd = process.cwd()): string | null {
70
+ try {
71
+ const out = execFileSync("aws", args, {
72
+ cwd,
73
+ stdio: ["ignore", "pipe", "pipe"],
74
+ });
75
+ return out.toString().trim();
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** Run `aws <args>` streaming output; throws on a non-zero exit. */
82
+ export function awsRun(args: string[], cwd = process.cwd()): void {
83
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
84
+ execFileSync("aws", args, { cwd, stdio: "inherit" });
85
+ }
86
+
87
+ /** True when the AWS CLI is on PATH. */
88
+ export function awsAvailable(): boolean {
89
+ return awsCapture(["--version"]) !== null;
90
+ }
91
+
92
+ /**
93
+ * A single Output value of a CloudFormation stack, or null when the stack does
94
+ * not exist or the key is unset. `describe-stacks` prints `None` for a missing
95
+ * key with `--output text`, which we normalize to null.
96
+ */
97
+ export function stackOutput(
98
+ stackName: string,
99
+ key: string,
100
+ region: string,
101
+ ): string | null {
102
+ const val = awsCapture([
103
+ "cloudformation",
104
+ "describe-stacks",
105
+ "--stack-name",
106
+ stackName,
107
+ "--region",
108
+ region,
109
+ "--query",
110
+ `Stacks[0].Outputs[?OutputKey=='${key}'].OutputValue`,
111
+ "--output",
112
+ "text",
113
+ ]);
114
+ if (!val || val === "None") return null;
115
+ return val;
116
+ }
117
+
118
+ /**
119
+ * Package the in-repo Lambda placeholder and upload it to the bootstrap
120
+ * artifacts bucket at `placeholders/bootstrap.zip` — the object app.yaml's
121
+ * function is *created from*. Idempotent (the zip is deterministic); safe to run
122
+ * on every `init`. Returns false when the placeholder file is missing.
123
+ */
124
+ export function uploadPlaceholder(
125
+ artifactsBucket: string,
126
+ region: string,
127
+ cwd = process.cwd(),
128
+ ): boolean {
129
+ const src = path.join(cwd, "deploy/cloudformation/placeholder/bootstrap");
130
+ if (!fs.existsSync(src)) return false;
131
+ const zip = zipStore([
132
+ { name: "bootstrap", data: fs.readFileSync(src), mode: 0o755 },
133
+ ]);
134
+ const tmp = path.join(os.tmpdir(), "rastack-placeholder-bootstrap.zip");
135
+ fs.writeFileSync(tmp, zip);
136
+ awsRun([
137
+ "s3",
138
+ "cp",
139
+ tmp,
140
+ `s3://${artifactsBucket}/placeholders/bootstrap.zip`,
141
+ "--region",
142
+ region,
143
+ ]);
144
+ return true;
145
+ }
146
+
60
147
  /**
61
148
  * Ask a single question with a default, echoed as `label [default]: `. Returns
62
149
  * the trimmed answer, or the default on an empty line.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * A tiny, dependency-free ZIP writer used by `rastack init` to package the Lambda
3
+ * placeholder bundle (`placeholders/bootstrap.zip`) before uploading it to the
4
+ * bootstrap artifacts bucket.
5
+ *
6
+ * Why hand-roll it: `init` runs on the developer's machine — including Windows
7
+ * PowerShell, which has no `zip` binary — so shelling out to `zip` is not
8
+ * portable. Entries are STORED (no compression); the placeholder is ~1 KB, so
9
+ * compression buys nothing and STORE keeps this pure and trivial to verify. The
10
+ * one detail that matters for Lambda is the Unix executable bit on `bootstrap`,
11
+ * which is carried in the central-directory external attributes.
12
+ */
13
+
14
+ /** A file to place in the archive. `mode` defaults to `0o644` (or `0o755` via helper). */
15
+ export interface ZipEntry {
16
+ name: string;
17
+ data: Buffer;
18
+ mode?: number;
19
+ }
20
+
21
+ const CRC_TABLE = (() => {
22
+ const table = new Int32Array(256);
23
+ for (let n = 0; n < 256; n++) {
24
+ let c = n;
25
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
26
+ table[n] = c;
27
+ }
28
+ return table;
29
+ })();
30
+
31
+ function crc32(buf: Buffer): number {
32
+ let c = 0xffffffff;
33
+ for (let i = 0; i < buf.length; i++)
34
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
35
+ return (c ^ 0xffffffff) >>> 0;
36
+ }
37
+
38
+ // Fixed 1980-01-01 00:00 DOS timestamp — deterministic output (no wall-clock).
39
+ const DOS_TIME = 0;
40
+ const DOS_DATE = 0x0021;
41
+
42
+ /**
43
+ * Build a ZIP archive from `entries` and return its bytes. Deterministic: the
44
+ * same inputs always produce the same buffer (a fixed DOS timestamp, no
45
+ * compression), so the placeholder upload is idempotent.
46
+ */
47
+ export function zipStore(entries: ZipEntry[]): Buffer {
48
+ const locals: Buffer[] = [];
49
+ const centrals: Buffer[] = [];
50
+ let offset = 0;
51
+
52
+ for (const entry of entries) {
53
+ const name = Buffer.from(entry.name, "utf-8");
54
+ const crc = crc32(entry.data);
55
+ const size = entry.data.length;
56
+ const mode = entry.mode ?? 0o644;
57
+
58
+ const local = Buffer.alloc(30);
59
+ local.writeUInt32LE(0x04034b50, 0); // local file header signature
60
+ local.writeUInt16LE(20, 4); // version needed
61
+ local.writeUInt16LE(0, 6); // flags
62
+ local.writeUInt16LE(0, 8); // method: STORE
63
+ local.writeUInt16LE(DOS_TIME, 10);
64
+ local.writeUInt16LE(DOS_DATE, 12);
65
+ local.writeUInt32LE(crc, 14);
66
+ local.writeUInt32LE(size, 18); // compressed size
67
+ local.writeUInt32LE(size, 22); // uncompressed size
68
+ local.writeUInt16LE(name.length, 26);
69
+ local.writeUInt16LE(0, 28); // extra length
70
+ locals.push(local, name, entry.data);
71
+
72
+ const central = Buffer.alloc(46);
73
+ central.writeUInt32LE(0x02014b50, 0); // central directory signature
74
+ central.writeUInt16LE((3 << 8) | 20, 4); // version made by: Unix host
75
+ central.writeUInt16LE(20, 6); // version needed
76
+ central.writeUInt16LE(0, 8); // flags
77
+ central.writeUInt16LE(0, 10); // method: STORE
78
+ central.writeUInt16LE(DOS_TIME, 12);
79
+ central.writeUInt16LE(DOS_DATE, 14);
80
+ central.writeUInt32LE(crc, 16);
81
+ central.writeUInt32LE(size, 20);
82
+ central.writeUInt32LE(size, 24);
83
+ central.writeUInt16LE(name.length, 28);
84
+ central.writeUInt16LE(0, 30); // extra length
85
+ central.writeUInt16LE(0, 32); // comment length
86
+ central.writeUInt16LE(0, 34); // disk number
87
+ central.writeUInt16LE(0, 36); // internal attributes
88
+ central.writeUInt32LE((mode & 0xffff) << 16, 38); // external attrs: Unix mode
89
+ central.writeUInt32LE(offset, 42); // local header offset
90
+ centrals.push(central, name);
91
+
92
+ offset += local.length + name.length + entry.data.length;
93
+ }
94
+
95
+ const localBytes = Buffer.concat(locals);
96
+ const centralBytes = Buffer.concat(centrals);
97
+
98
+ const eocd = Buffer.alloc(22);
99
+ eocd.writeUInt32LE(0x06054b50, 0); // end of central directory signature
100
+ eocd.writeUInt16LE(0, 4); // disk number
101
+ eocd.writeUInt16LE(0, 6); // central dir start disk
102
+ eocd.writeUInt16LE(entries.length, 8); // entries on this disk
103
+ eocd.writeUInt16LE(entries.length, 10); // total entries
104
+ eocd.writeUInt32LE(centralBytes.length, 12); // central dir size
105
+ eocd.writeUInt32LE(localBytes.length, 16); // central dir offset
106
+ eocd.writeUInt16LE(0, 20); // comment length
107
+
108
+ return Buffer.concat([localBytes, centralBytes, eocd]);
109
+ }
package/src/rastack-ci.ts CHANGED
@@ -116,7 +116,7 @@ function main(argv: string[]): void {
116
116
  const cfg = loadConfig();
117
117
  if (!cfg) {
118
118
  console.error(
119
- " ✗ no .rastack/deploy.json found. Run `rastack init` (or `rastack deploy`) first.",
119
+ " ✗ no .rastack/deploy.json found. Run `rastack init` first.",
120
120
  );
121
121
  process.exit(1);
122
122
  return;