rastack 0.0.33 → 0.0.34

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.
@@ -0,0 +1,355 @@
1
+ import yaml from "js-yaml";
2
+ import {
3
+ DeployConfig,
4
+ buildSteps,
5
+ checkSteps,
6
+ ciPlan,
7
+ deployStackCommand,
8
+ deploySteps,
9
+ lambdaEnv,
10
+ mergeConfig,
11
+ normalizeConfig,
12
+ parseGitHubRemote,
13
+ repoVariables,
14
+ scaffoldFiles,
15
+ stackParameters,
16
+ templateUrl,
17
+ validateConfig,
18
+ renderCiWorkflow,
19
+ renderDeployWorkflow,
20
+ CI_WORKFLOW_PATH,
21
+ CONFIG_PATH,
22
+ DEPLOY_WORKFLOW_PATH,
23
+ } from "../src/deploy";
24
+
25
+ /** A complete, valid config to build variations off. */
26
+ function base(overrides: Partial<DeployConfig> = {}): DeployConfig {
27
+ return normalizeConfig({
28
+ app: "airline",
29
+ github: {
30
+ owner: "theserverkid",
31
+ repo: "reactapistack-demo",
32
+ branch: "master",
33
+ },
34
+ ...overrides,
35
+ });
36
+ }
37
+
38
+ describe("deploy config — normalize", () => {
39
+ it("fills defaults and derives the stack name from prefix + app", () => {
40
+ const cfg = base();
41
+ expect(cfg.namePrefix).toBe("rastack");
42
+ expect(cfg.region).toBe("af-south-1");
43
+ expect(cfg.stackName).toBe("rastack-airline");
44
+ expect(cfg.namespace).toBe("production");
45
+ expect(cfg.lambda).toEqual({
46
+ bin: "bootstrap",
47
+ arch: "arm64",
48
+ features: "lambda",
49
+ });
50
+ expect(cfg.resourcesDir).toBe("resources");
51
+ expect(cfg.outDir).toBe(".rastack");
52
+ expect(cfg.tokenUse).toBe("access");
53
+ });
54
+
55
+ it("honours an explicit stack name and prefix", () => {
56
+ const cfg = base({ namePrefix: "acme", stackName: "custom-stack" });
57
+ expect(cfg.stackName).toBe("custom-stack");
58
+ });
59
+
60
+ it("re-derives the stack name off a custom prefix when none is given", () => {
61
+ const cfg = normalizeConfig({
62
+ app: "airline",
63
+ namePrefix: "acme",
64
+ github: { owner: "o", repo: "r" },
65
+ });
66
+ expect(cfg.stackName).toBe("acme-airline");
67
+ expect(cfg.github.branch).toBe("master"); // branch default
68
+ });
69
+
70
+ it("drops empty optional buckets to undefined", () => {
71
+ const cfg = normalizeConfig({
72
+ app: "a",
73
+ templatesBucket: " ",
74
+ github: {},
75
+ });
76
+ expect(cfg.templatesBucket).toBeUndefined();
77
+ expect(cfg.artifactsBucket).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ describe("deploy config — merge", () => {
82
+ it("deep-merges nested github/lambda without dropping siblings", () => {
83
+ const cfg = mergeConfig(base(), {
84
+ github: { branch: "dev" },
85
+ lambda: { arch: "x86_64" },
86
+ });
87
+ expect(cfg.github.owner).toBe("theserverkid");
88
+ expect(cfg.github.branch).toBe("dev");
89
+ expect(cfg.lambda.arch).toBe("x86_64");
90
+ expect(cfg.lambda.bin).toBe("bootstrap");
91
+ });
92
+
93
+ it("merges stack outputs", () => {
94
+ const cfg = mergeConfig(base(), { outputs: { functionName: "fn-1" } });
95
+ const cfg2 = mergeConfig(cfg, { outputs: { apiUrl: "https://x" } });
96
+ expect(cfg2.outputs).toEqual({ functionName: "fn-1", apiUrl: "https://x" });
97
+ });
98
+ });
99
+
100
+ describe("deploy config — validate", () => {
101
+ it("passes a well-formed config", () => {
102
+ expect(validateConfig(base())).toEqual([]);
103
+ });
104
+
105
+ it("rejects a bad app slug (Cognito [a-z0-9] rule)", () => {
106
+ expect(validateConfig(base({ app: "My-App" })).join("\n")).toMatch(
107
+ /app "My-App"/,
108
+ );
109
+ expect(validateConfig(base({ app: "" })).join("\n")).toMatch(
110
+ /app is required/,
111
+ );
112
+ });
113
+
114
+ it("rejects a malformed region", () => {
115
+ expect(validateConfig(base({ region: "not-a-region" })).join()).toMatch(
116
+ /region/,
117
+ );
118
+ });
119
+
120
+ it("requires github owner + repo", () => {
121
+ const errs = validateConfig(normalizeConfig({ app: "a", github: {} }));
122
+ expect(errs.join("\n")).toMatch(/github.owner is required/);
123
+ expect(errs.join("\n")).toMatch(/github.repo is required/);
124
+ });
125
+
126
+ it("rejects an unknown lambda arch", () => {
127
+ const cfg = {
128
+ ...base(),
129
+ lambda: { ...base().lambda, arch: "riscv" as never },
130
+ };
131
+ expect(validateConfig(cfg).join()).toMatch(/lambda.arch/);
132
+ });
133
+ });
134
+
135
+ describe("parseGitHubRemote", () => {
136
+ it("parses ssh, https, and https+.git forms", () => {
137
+ expect(
138
+ parseGitHubRemote("git@github.com:theserverkid/reactapistack.git"),
139
+ ).toEqual({
140
+ owner: "theserverkid",
141
+ repo: "reactapistack",
142
+ });
143
+ expect(
144
+ parseGitHubRemote("https://github.com/theserverkid/reactapistack"),
145
+ ).toEqual({
146
+ owner: "theserverkid",
147
+ repo: "reactapistack",
148
+ });
149
+ expect(
150
+ parseGitHubRemote("https://github.com/theserverkid/reactapistack.git/"),
151
+ ).toEqual({
152
+ owner: "theserverkid",
153
+ repo: "reactapistack",
154
+ });
155
+ });
156
+
157
+ it("returns null for non-GitHub remotes", () => {
158
+ expect(parseGitHubRemote("git@gitlab.com:o/r.git")).toBeNull();
159
+ expect(parseGitHubRemote("not a url")).toBeNull();
160
+ });
161
+ });
162
+
163
+ describe("stack parameters + launch command", () => {
164
+ it("emits the app.yaml parameters in template order", () => {
165
+ const params = stackParameters(base({ artifactsBucket: "art-bucket" }));
166
+ expect(params.map((p) => p.key)).toEqual([
167
+ "AppName",
168
+ "NamePrefix",
169
+ "GitHubOwner",
170
+ "GitHubRepo",
171
+ "GitHubBranch",
172
+ "IcebergNamespace",
173
+ "ArtifactsBucket",
174
+ ]);
175
+ });
176
+
177
+ it("omits ArtifactsBucket when unset (it has no template default)", () => {
178
+ expect(stackParameters(base()).map((p) => p.key)).not.toContain(
179
+ "ArtifactsBucket",
180
+ );
181
+ });
182
+
183
+ it("builds the template URL only when the templates bucket is known", () => {
184
+ expect(templateUrl(base())).toBeNull();
185
+ expect(templateUrl(base({ templatesBucket: "tpl" }))).toBe(
186
+ "https://tpl.s3.af-south-1.amazonaws.com/app.yaml",
187
+ );
188
+ });
189
+
190
+ it("launches by URL when hosted, else from the in-repo template", () => {
191
+ const hosted = deployStackCommand(
192
+ base({ templatesBucket: "tpl", artifactsBucket: "art" }),
193
+ );
194
+ expect(hosted).toContain(
195
+ "--template-url https://tpl.s3.af-south-1.amazonaws.com/app.yaml",
196
+ );
197
+ expect(hosted).toContain("--stack-name rastack-airline");
198
+ expect(hosted).toContain("ArtifactsBucket=art");
199
+
200
+ const local = deployStackCommand(base());
201
+ expect(local).toContain("--template-file deploy/cloudformation/app.yaml");
202
+ });
203
+ });
204
+
205
+ describe("repo variables + lambda env", () => {
206
+ it("resolves repo Variables from stack outputs when present", () => {
207
+ const cfg = base({ outputs: { deployRoleArn: "arn:aws:iam::1:role/x" } });
208
+ const vars = repoVariables(cfg);
209
+ expect(vars.find((v) => v.name === "AWS_DEPLOY_ROLE_ARN")!.value).toBe(
210
+ "arn:aws:iam::1:role/x",
211
+ );
212
+ expect(vars.find((v) => v.name === "AWS_REGION")!.value).toBe("af-south-1");
213
+ });
214
+
215
+ it("fills the RASTACK_* env, using outputs where available", () => {
216
+ const cfg = base({
217
+ outputs: {
218
+ warehouseBucket: "wh-bucket",
219
+ userPoolId: "eu_x",
220
+ userPoolClientId: "cli",
221
+ },
222
+ });
223
+ const env = Object.fromEntries(lambdaEnv(cfg).map((e) => [e.key, e.value]));
224
+ expect(env.RASTACK_WAREHOUSE).toBe("s3://wh-bucket/warehouse");
225
+ expect(env.RASTACK_ISSUER).toBe(
226
+ "https://cognito-idp.af-south-1.amazonaws.com/eu_x",
227
+ );
228
+ expect(env.RASTACK_AUDIENCE).toBe("cli");
229
+ expect(env.RASTACK_TOKEN_USE).toBe("access");
230
+ });
231
+ });
232
+
233
+ describe("ci plan — check", () => {
234
+ it("validates then compiles, honouring the resources dir + bin", () => {
235
+ const steps = checkSteps(base({ resourcesDir: "res" }), "npx rastack");
236
+ expect(steps.map((s) => s.run)).toEqual([
237
+ "npx rastack check res",
238
+ "npx rastack compile res .rastack",
239
+ ]);
240
+ expect(ciPlan("check", base(), { bin: "rastack" })).toEqual(
241
+ checkSteps(base(), "rastack"),
242
+ );
243
+ });
244
+ });
245
+
246
+ describe("ci plan — build", () => {
247
+ it("uses --arm64 and bakes the JWKS from the stack output when unresolved", () => {
248
+ const steps = buildSteps(base());
249
+ const cross = steps.find((s) => s.name.startsWith("Cross-build"))!;
250
+ expect(cross.run).toContain("--arm64");
251
+ expect(cross.cwd).toBe("rust");
252
+ const bake = steps.find((s) => s.name.startsWith("Bake"))!;
253
+ expect(bake.run).toContain("describe-stacks");
254
+ expect(bake.run).toContain("OutputKey=='JwksUrl'");
255
+ });
256
+
257
+ it("uses --x86-64 for an x86 build and a literal JWKS when wired", () => {
258
+ const cfg = base({
259
+ lambda: { ...base().lambda, arch: "x86_64" },
260
+ outputs: { jwksUrl: "https://jwks" },
261
+ });
262
+ const steps = buildSteps(cfg);
263
+ expect(steps.find((s) => s.name.startsWith("Cross-build"))!.run).toContain(
264
+ "--x86-64",
265
+ );
266
+ const bake = steps.find((s) => s.name.startsWith("Bake"))!;
267
+ expect(bake.run).toContain('"https://jwks"');
268
+ expect(bake.run).not.toContain("describe-stacks");
269
+ });
270
+
271
+ it("stages the compiled manifest + openapi next to the binary", () => {
272
+ const steps = buildSteps(base());
273
+ const zip = steps.find((s) => s.name.startsWith("Assemble"))!;
274
+ expect(zip.run).toContain("rust/target/lambda/bootstrap/bootstrap.zip");
275
+ expect(zip.run).toContain("schema.rastack.json");
276
+ expect(zip.run).toContain("openapi.json");
277
+ expect(zip.run).toContain("jwks.json");
278
+ });
279
+ });
280
+
281
+ describe("ci plan — deploy", () => {
282
+ it("builds then ships, resolving the function name live when unwired", () => {
283
+ const steps = deploySteps(base());
284
+ const ship = steps.find((s) => s.name.startsWith("Ship"))!;
285
+ expect(ship.run).toContain("aws lambda update-function-code");
286
+ expect(ship.run).toContain("OutputKey=='FunctionName'");
287
+ expect(ship.run).toContain(
288
+ "fileb://rust/target/lambda/bootstrap/bootstrap.zip",
289
+ );
290
+ // build steps are included
291
+ expect(steps.some((s) => s.name.startsWith("Cross-build"))).toBe(true);
292
+ });
293
+
294
+ it("skips the build with skipBuild and uses the wired function name", () => {
295
+ const cfg = base({ outputs: { functionName: "airline-api" } });
296
+ const steps = deploySteps(cfg, { skipBuild: true });
297
+ expect(steps.some((s) => s.name.startsWith("Cross-build"))).toBe(false);
298
+ const ship = steps.find((s) => s.name.startsWith("Ship"))!;
299
+ expect(ship.run).toContain("--function-name airline-api");
300
+ expect(ship.run).not.toContain("describe-stacks");
301
+ });
302
+
303
+ it("ciPlan dispatches each phase", () => {
304
+ expect(ciPlan("build", base())).toEqual(buildSteps(base()));
305
+ expect(ciPlan("deploy", base())).toEqual(deploySteps(base()));
306
+ });
307
+ });
308
+
309
+ describe("workflow templates", () => {
310
+ it("renders valid YAML that pins the deploy branch", () => {
311
+ const ci = renderCiWorkflow(
312
+ base({ github: { owner: "o", repo: "r", branch: "main" } }),
313
+ );
314
+ const doc = yaml.load(ci) as any;
315
+ expect(doc.name).toBe("CI");
316
+ // `on:` parses to the boolean-ish key `true` in YAML 1.1 — assert via the raw text.
317
+ expect(ci).toContain("branches: [main]");
318
+ expect(ci).toContain("run: rastack ci check");
319
+ });
320
+
321
+ it("deploy workflow keeps the GitHub expressions intact and runs one line", () => {
322
+ const wf = renderDeployWorkflow(base());
323
+ const doc = yaml.load(wf) as any;
324
+ expect(doc.permissions["id-token"]).toBe("write");
325
+ expect(wf).toContain("${{ vars.AWS_DEPLOY_ROLE_ARN }}");
326
+ expect(wf).toContain("aws-actions/configure-aws-credentials@v4");
327
+ expect(wf).toContain("run: rastack ci deploy");
328
+ });
329
+
330
+ it("names the deploy job after the target architecture", () => {
331
+ expect(renderDeployWorkflow(base())).toContain("(arm64)");
332
+ expect(
333
+ renderDeployWorkflow(
334
+ base({ lambda: { ...base().lambda, arch: "x86_64" } }),
335
+ ),
336
+ ).toContain("(x86_64)");
337
+ });
338
+ });
339
+
340
+ describe("scaffold", () => {
341
+ it("writes the config + both workflows by default", () => {
342
+ const files = scaffoldFiles(base());
343
+ expect(files.map((f) => f.path).sort()).toEqual(
344
+ [CONFIG_PATH, CI_WORKFLOW_PATH, DEPLOY_WORKFLOW_PATH].sort(),
345
+ );
346
+ // the config file round-trips through normalize
347
+ const cfgFile = files.find((f) => f.path === CONFIG_PATH)!;
348
+ expect(normalizeConfig(JSON.parse(cfgFile.content))).toEqual(base());
349
+ });
350
+
351
+ it("omits the workflows with noWorkflows", () => {
352
+ const files = scaffoldFiles(base(), { noWorkflows: true });
353
+ expect(files.map((f) => f.path)).toEqual([CONFIG_PATH]);
354
+ });
355
+ });