rastack 0.0.33 → 0.0.35
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/ci.d.ts +53 -0
- package/dist/deploy/ci.js +125 -0
- package/dist/deploy/config.d.ts +144 -0
- package/dist/deploy/config.js +237 -0
- package/dist/deploy/index.d.ts +9 -0
- package/dist/deploy/index.js +25 -0
- package/dist/deploy/io.d.ts +27 -0
- package/dist/deploy/io.js +121 -0
- package/dist/deploy/workflows.d.ts +46 -0
- package/dist/deploy/workflows.js +145 -0
- package/dist/rastack-ci.d.ts +18 -0
- package/dist/rastack-ci.js +118 -0
- package/dist/rastack-deploy.d.ts +20 -0
- package/dist/rastack-deploy.js +196 -0
- package/dist/rastack-init.d.ts +14 -0
- package/dist/rastack-init.js +183 -0
- package/dist/rastack.d.ts +3 -0
- package/dist/rastack.js +50 -9
- package/package.json +1 -1
- package/src/deploy/ci.ts +177 -0
- package/src/deploy/config.ts +329 -0
- package/src/deploy/index.ts +10 -0
- package/src/deploy/io.ts +90 -0
- package/src/deploy/workflows.ts +163 -0
- package/src/rastack-ci.ts +131 -0
- package/src/rastack-deploy.ts +257 -0
- package/src/rastack-init.ts +187 -0
- package/src/rastack.ts +51 -9
- package/test/deploy.spec.ts +355 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rastack deploy` config — the single source of truth for a repo's serverless
|
|
3
|
+
* deployment, written to `.rastack/deploy.json`.
|
|
4
|
+
*
|
|
5
|
+
* The deploy is split by *what changes* (see docs/serverless-deploy.md): the
|
|
6
|
+
* CloudFormation stack owns all durable infrastructure and is created once by a
|
|
7
|
+
* human, while CI does exactly one thing on every push —
|
|
8
|
+
* `aws lambda update-function-code`. This module models the small set of
|
|
9
|
+
* *authoring inputs* a human decides up front (app slug, region, the GitHub repo
|
|
10
|
+
* whose CI ships the code, the Lambda build shape) plus the *stack outputs* that
|
|
11
|
+
* only exist after the stack is created (function name, API URL, JWKS URL, …).
|
|
12
|
+
* CI resolves any missing output live from the stack, so the config stays a
|
|
13
|
+
* short, human-owned file.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is pure and side-effect-free — the same discipline as the
|
|
16
|
+
* resource compiler and the token compiler. `rastack-{init,deploy,ci}.ts` are the
|
|
17
|
+
* thin IO wrappers that read/write the file and shell out.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Values CI resolves from the CloudFormation stack Outputs (see app.yaml). */
|
|
21
|
+
export interface DeployOutputs {
|
|
22
|
+
/** Lambda function name — CI targets this with `update-function-code`. */
|
|
23
|
+
functionName?: string;
|
|
24
|
+
/** Base URL of the deployed HTTP API. */
|
|
25
|
+
apiUrl?: string;
|
|
26
|
+
/** OIDC role CI assumes — wire as the `AWS_DEPLOY_ROLE_ARN` repo Variable. */
|
|
27
|
+
deployRoleArn?: string;
|
|
28
|
+
/** Cognito User Pool id (identity / JWTs). */
|
|
29
|
+
userPoolId?: string;
|
|
30
|
+
/** App client id — the token audience `rastack-server` pins. */
|
|
31
|
+
userPoolClientId?: string;
|
|
32
|
+
/** JWKS URL baked into the Lambda bundle at build time. */
|
|
33
|
+
jwksUrl?: string;
|
|
34
|
+
/** S3 Iceberg warehouse bucket — the system of record. */
|
|
35
|
+
warehouseBucket?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LambdaBuild {
|
|
39
|
+
/** Cargo bin built for the custom runtime (`provided.al2023`). */
|
|
40
|
+
bin: string;
|
|
41
|
+
/** Target architecture — arm64 (Graviton, the default) or x86_64. */
|
|
42
|
+
arch: "arm64" | "x86_64";
|
|
43
|
+
/** Cargo features the Lambda build enables. */
|
|
44
|
+
features: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DeployConfig {
|
|
48
|
+
/** App slug — namespaces buckets/roles/SSM. Cognito-safe: `^[a-z0-9]{1,20}$`. */
|
|
49
|
+
app: string;
|
|
50
|
+
/** Resource-name prefix shared across a deployment. `^[a-z0-9]{1,20}$`. */
|
|
51
|
+
namePrefix: string;
|
|
52
|
+
/** AWS region the stack (and CI) target. */
|
|
53
|
+
region: string;
|
|
54
|
+
/** CloudFormation stack name — defaults to `${namePrefix}-${app}`. */
|
|
55
|
+
stackName: string;
|
|
56
|
+
/** The GitHub repo whose CI is trusted to ship the Lambda code. */
|
|
57
|
+
github: { owner: string; repo: string; branch: string };
|
|
58
|
+
/** Iceberg namespace the warehouse writes under. */
|
|
59
|
+
namespace: string;
|
|
60
|
+
/** How the Rust Lambda bundle is built. */
|
|
61
|
+
lambda: LambdaBuild;
|
|
62
|
+
/** Where the TypeScript resource definitions live. */
|
|
63
|
+
resourcesDir: string;
|
|
64
|
+
/** Where `rastack compile` writes the manifest + OpenAPI. */
|
|
65
|
+
outDir: string;
|
|
66
|
+
/** Bootstrap templates bucket that hosts `app.yaml` (for the launch command). */
|
|
67
|
+
templatesBucket?: string;
|
|
68
|
+
/** Bootstrap artifacts bucket holding the Lambda placeholder bundle. */
|
|
69
|
+
artifactsBucket?: string;
|
|
70
|
+
/** Whether the function verifies the `access` or `id` token. */
|
|
71
|
+
tokenUse: "access" | "id";
|
|
72
|
+
/** Values resolved from the stack after it is created (optional). */
|
|
73
|
+
outputs?: DeployOutputs;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const DEFAULT_LAMBDA: LambdaBuild = {
|
|
77
|
+
bin: "bootstrap",
|
|
78
|
+
arch: "arm64",
|
|
79
|
+
features: "lambda",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** The out-of-the-box config — everything a fresh repo can default sensibly. */
|
|
83
|
+
export const DEFAULTS: Omit<DeployConfig, "app" | "github"> = {
|
|
84
|
+
namePrefix: "rastack",
|
|
85
|
+
region: "af-south-1",
|
|
86
|
+
stackName: "",
|
|
87
|
+
namespace: "production",
|
|
88
|
+
lambda: DEFAULT_LAMBDA,
|
|
89
|
+
resourcesDir: "resources",
|
|
90
|
+
outDir: ".rastack",
|
|
91
|
+
tokenUse: "access",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** A recursively-optional view of the config, as read from disk or CLI flags. */
|
|
95
|
+
export type PartialDeployConfig = {
|
|
96
|
+
[K in keyof DeployConfig]?: DeployConfig[K] extends object
|
|
97
|
+
? Partial<DeployConfig[K]>
|
|
98
|
+
: DeployConfig[K];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Fill a partial config with defaults into a complete `DeployConfig`. Never
|
|
103
|
+
* throws — `validateConfig` reports what is still missing or malformed.
|
|
104
|
+
*/
|
|
105
|
+
export function normalizeConfig(
|
|
106
|
+
partial: PartialDeployConfig = {},
|
|
107
|
+
): DeployConfig {
|
|
108
|
+
const app = (partial.app ?? "").trim();
|
|
109
|
+
const namePrefix = (partial.namePrefix ?? DEFAULTS.namePrefix).trim();
|
|
110
|
+
const github = {
|
|
111
|
+
owner: (partial.github?.owner ?? "").trim(),
|
|
112
|
+
repo: (partial.github?.repo ?? "").trim(),
|
|
113
|
+
branch: (partial.github?.branch ?? "master").trim(),
|
|
114
|
+
};
|
|
115
|
+
const stackName =
|
|
116
|
+
(partial.stackName ?? "").trim() || (app ? `${namePrefix}-${app}` : "");
|
|
117
|
+
return {
|
|
118
|
+
app,
|
|
119
|
+
namePrefix,
|
|
120
|
+
region: (partial.region ?? DEFAULTS.region).trim(),
|
|
121
|
+
stackName,
|
|
122
|
+
github,
|
|
123
|
+
namespace: (partial.namespace ?? DEFAULTS.namespace).trim(),
|
|
124
|
+
lambda: {
|
|
125
|
+
bin: (partial.lambda?.bin ?? DEFAULT_LAMBDA.bin).trim(),
|
|
126
|
+
arch: partial.lambda?.arch ?? DEFAULT_LAMBDA.arch,
|
|
127
|
+
features: (partial.lambda?.features ?? DEFAULT_LAMBDA.features).trim(),
|
|
128
|
+
},
|
|
129
|
+
resourcesDir: (partial.resourcesDir ?? DEFAULTS.resourcesDir).trim(),
|
|
130
|
+
outDir: (partial.outDir ?? DEFAULTS.outDir).trim(),
|
|
131
|
+
templatesBucket: partial.templatesBucket?.trim() || undefined,
|
|
132
|
+
artifactsBucket: partial.artifactsBucket?.trim() || undefined,
|
|
133
|
+
tokenUse: partial.tokenUse ?? DEFAULTS.tokenUse,
|
|
134
|
+
outputs: partial.outputs ? { ...partial.outputs } : undefined,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const SLUG = /^[a-z0-9]{1,20}$/;
|
|
139
|
+
const REGION = /^[a-z]{2}-[a-z]+-\d+$/;
|
|
140
|
+
const GH_NAME = /^[A-Za-z0-9_.-]+$/;
|
|
141
|
+
const NAMESPACE = /^[a-z0-9_]+$/;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Return a list of human-readable problems, empty when the config is deployable.
|
|
145
|
+
* The slug/prefix patterns mirror app.yaml's `AllowedPattern`s exactly, so a
|
|
146
|
+
* config that passes here also passes CloudFormation's own validation.
|
|
147
|
+
*/
|
|
148
|
+
export function validateConfig(cfg: DeployConfig): string[] {
|
|
149
|
+
const errors: string[] = [];
|
|
150
|
+
if (!cfg.app) errors.push("app is required (a short slug, e.g. `airline`).");
|
|
151
|
+
else if (!SLUG.test(cfg.app))
|
|
152
|
+
errors.push(
|
|
153
|
+
`app "${cfg.app}" must match ${SLUG} (lowercase letters/digits, 1–20).`,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (!SLUG.test(cfg.namePrefix))
|
|
157
|
+
errors.push(`namePrefix "${cfg.namePrefix}" must match ${SLUG}.`);
|
|
158
|
+
|
|
159
|
+
if (!cfg.region) errors.push("region is required (e.g. `af-south-1`).");
|
|
160
|
+
else if (!REGION.test(cfg.region))
|
|
161
|
+
errors.push(
|
|
162
|
+
`region "${cfg.region}" does not look like an AWS region (e.g. af-south-1).`,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (!cfg.github.owner) errors.push("github.owner is required.");
|
|
166
|
+
else if (!GH_NAME.test(cfg.github.owner))
|
|
167
|
+
errors.push(
|
|
168
|
+
`github.owner "${cfg.github.owner}" is not a valid GitHub name.`,
|
|
169
|
+
);
|
|
170
|
+
if (!cfg.github.repo) errors.push("github.repo is required.");
|
|
171
|
+
else if (!GH_NAME.test(cfg.github.repo))
|
|
172
|
+
errors.push(`github.repo "${cfg.github.repo}" is not a valid GitHub name.`);
|
|
173
|
+
if (!cfg.github.branch) errors.push("github.branch is required.");
|
|
174
|
+
|
|
175
|
+
if (!NAMESPACE.test(cfg.namespace))
|
|
176
|
+
errors.push(`namespace "${cfg.namespace}" must match ${NAMESPACE}.`);
|
|
177
|
+
|
|
178
|
+
if (cfg.lambda.arch !== "arm64" && cfg.lambda.arch !== "x86_64")
|
|
179
|
+
errors.push(
|
|
180
|
+
`lambda.arch must be "arm64" or "x86_64" (got "${cfg.lambda.arch}").`,
|
|
181
|
+
);
|
|
182
|
+
if (!cfg.lambda.bin) errors.push("lambda.bin is required.");
|
|
183
|
+
|
|
184
|
+
return errors;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Deep-merge a patch over a base config (used by `deploy`/`init` reconfigure). */
|
|
188
|
+
export function mergeConfig(
|
|
189
|
+
base: DeployConfig,
|
|
190
|
+
patch: PartialDeployConfig,
|
|
191
|
+
): DeployConfig {
|
|
192
|
+
return normalizeConfig({
|
|
193
|
+
...base,
|
|
194
|
+
...patch,
|
|
195
|
+
github: { ...base.github, ...patch.github },
|
|
196
|
+
lambda: { ...base.lambda, ...patch.lambda },
|
|
197
|
+
outputs: { ...base.outputs, ...patch.outputs },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Recover `{ owner, repo }` from a GitHub remote URL — both the SSH
|
|
203
|
+
* (`git@github.com:owner/repo.git`) and HTTPS
|
|
204
|
+
* (`https://github.com/owner/repo(.git)`) forms — so `init` can default the
|
|
205
|
+
* GitHub coordinates from `git remote get-url origin`. Returns null for any
|
|
206
|
+
* non-GitHub or unparseable URL.
|
|
207
|
+
*/
|
|
208
|
+
export function parseGitHubRemote(
|
|
209
|
+
url: string,
|
|
210
|
+
): { owner: string; repo: string } | null {
|
|
211
|
+
const trimmed = url.trim();
|
|
212
|
+
const m =
|
|
213
|
+
/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/.exec(trimmed) ||
|
|
214
|
+
/^(?:https?:\/\/|ssh:\/\/git@)github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(
|
|
215
|
+
trimmed,
|
|
216
|
+
);
|
|
217
|
+
if (!m) return null;
|
|
218
|
+
const owner = m[1];
|
|
219
|
+
const repo = m[2];
|
|
220
|
+
if (!GH_NAME.test(owner) || !GH_NAME.test(repo)) return null;
|
|
221
|
+
return { owner, repo };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A `Key=Value` CloudFormation parameter override. */
|
|
225
|
+
export interface StackParameter {
|
|
226
|
+
key: string;
|
|
227
|
+
value: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The `--parameter-overrides` for `aws cloudformation deploy` of app.yaml,
|
|
232
|
+
* derived from the config. Order and keys match the template's `Parameters`.
|
|
233
|
+
* `ArtifactsBucket` (which has no template default) is included only when set.
|
|
234
|
+
*/
|
|
235
|
+
export function stackParameters(cfg: DeployConfig): StackParameter[] {
|
|
236
|
+
const params: StackParameter[] = [
|
|
237
|
+
{ key: "AppName", value: cfg.app },
|
|
238
|
+
{ key: "NamePrefix", value: cfg.namePrefix },
|
|
239
|
+
{ key: "GitHubOwner", value: cfg.github.owner },
|
|
240
|
+
{ key: "GitHubRepo", value: cfg.github.repo },
|
|
241
|
+
{ key: "GitHubBranch", value: cfg.github.branch },
|
|
242
|
+
{ key: "IcebergNamespace", value: cfg.namespace },
|
|
243
|
+
];
|
|
244
|
+
if (cfg.artifactsBucket)
|
|
245
|
+
params.push({ key: "ArtifactsBucket", value: cfg.artifactsBucket });
|
|
246
|
+
return params;
|
|
247
|
+
}
|
|
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`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 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`.
|
|
259
|
+
*/
|
|
260
|
+
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 ");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** GitHub repo Variable to wire (name + resolved value, if known from outputs). */
|
|
279
|
+
export interface RepoVariable {
|
|
280
|
+
name: string;
|
|
281
|
+
value: string;
|
|
282
|
+
from: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The GitHub repo **Variables** CI reads — `AWS_DEPLOY_ROLE_ARN` and
|
|
287
|
+
* `AWS_REGION` (see docs/serverless-deploy.md §3). Values come from the stack
|
|
288
|
+
* Outputs when already resolved, else an empty string to fill in from the
|
|
289
|
+
* Console.
|
|
290
|
+
*/
|
|
291
|
+
export function repoVariables(cfg: DeployConfig): RepoVariable[] {
|
|
292
|
+
return [
|
|
293
|
+
{
|
|
294
|
+
name: "AWS_DEPLOY_ROLE_ARN",
|
|
295
|
+
value: cfg.outputs?.deployRoleArn ?? "",
|
|
296
|
+
from: "DeployRoleArn stack output",
|
|
297
|
+
},
|
|
298
|
+
{ name: "AWS_REGION", value: cfg.region, from: "Region stack output" },
|
|
299
|
+
];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The `RASTACK_*` environment the Lambda reads (docs/serverless-deploy.md §1).
|
|
304
|
+
* The IaC sets these on the function; surfaced here for `rastack deploy` to
|
|
305
|
+
* print so the mapping between config and runtime env is inspectable.
|
|
306
|
+
*/
|
|
307
|
+
export function lambdaEnv(cfg: DeployConfig): StackParameter[] {
|
|
308
|
+
const warehouse = cfg.outputs?.warehouseBucket
|
|
309
|
+
? `s3://${cfg.outputs.warehouseBucket}/warehouse`
|
|
310
|
+
: "s3://{warehouse-bucket}/warehouse";
|
|
311
|
+
const manifest = `/var/task/schema.rastack.json`;
|
|
312
|
+
return [
|
|
313
|
+
{ key: "RASTACK_WAREHOUSE", value: warehouse },
|
|
314
|
+
{ key: "RASTACK_MANIFEST", value: manifest },
|
|
315
|
+
{ key: "RASTACK_OPENAPI", value: "/var/task/openapi.json" },
|
|
316
|
+
{ key: "RASTACK_JWKS", value: "/var/task/jwks.json" },
|
|
317
|
+
{
|
|
318
|
+
key: "RASTACK_ISSUER",
|
|
319
|
+
value: cfg.outputs?.userPoolId
|
|
320
|
+
? `https://cognito-idp.${cfg.region}.amazonaws.com/${cfg.outputs.userPoolId}`
|
|
321
|
+
: `https://cognito-idp.${cfg.region}.amazonaws.com/{userPoolId}`,
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
key: "RASTACK_AUDIENCE",
|
|
325
|
+
value: cfg.outputs?.userPoolClientId ?? "{appClientId}",
|
|
326
|
+
},
|
|
327
|
+
{ key: "RASTACK_TOKEN_USE", value: cfg.tokenUse },
|
|
328
|
+
];
|
|
329
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `rastack deploy | ci | init` core — the DevOps surface of React API Stack.
|
|
3
|
+
*
|
|
4
|
+
* Pure, testable building blocks (config, CI plan, workflow templates); the
|
|
5
|
+
* `rastack-{deploy,ci,init}.ts` entrypoints are the thin IO wrappers over them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * from "./config";
|
|
9
|
+
export * from "./ci";
|
|
10
|
+
export * from "./workflows";
|
package/src/deploy/io.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem + prompt helpers shared by the `rastack deploy | ci | init`
|
|
3
|
+
* entrypoints. Everything pure lives in the sibling modules; this is the small
|
|
4
|
+
* IO boundary they wrap.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from "child_process";
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import * as readline from "readline";
|
|
11
|
+
import { CONFIG_PATH } from "./workflows";
|
|
12
|
+
import { DeployConfig, PartialDeployConfig, normalizeConfig } from "./config";
|
|
13
|
+
|
|
14
|
+
/** Absolute path to the deploy config for a project root. */
|
|
15
|
+
export function configPath(cwd = process.cwd()): string {
|
|
16
|
+
return path.join(cwd, CONFIG_PATH);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Read + normalize the deploy config, or null when it does not exist yet. */
|
|
20
|
+
export function loadConfig(cwd = process.cwd()): DeployConfig | null {
|
|
21
|
+
const file = configPath(cwd);
|
|
22
|
+
if (!fs.existsSync(file)) return null;
|
|
23
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf-8")) as PartialDeployConfig;
|
|
24
|
+
return normalizeConfig(raw);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Write the deploy config, creating `.rastack/` if needed. */
|
|
28
|
+
export function saveConfig(cfg: DeployConfig, cwd = process.cwd()): string {
|
|
29
|
+
const file = configPath(cwd);
|
|
30
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
31
|
+
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
|
|
32
|
+
return file;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Best-effort GitHub `origin` remote URL, for defaulting the repo coordinates.
|
|
37
|
+
* Returns null when not a git repo or no origin.
|
|
38
|
+
*/
|
|
39
|
+
export function gitRemoteUrl(cwd = process.cwd()): string | null {
|
|
40
|
+
try {
|
|
41
|
+
// Reads the developer's own git config with a fixed command; no untrusted
|
|
42
|
+
// input, so there is no command-injection surface.
|
|
43
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
44
|
+
const out = execSync("git remote get-url origin", {
|
|
45
|
+
cwd,
|
|
46
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
47
|
+
});
|
|
48
|
+
const url = out.toString().trim();
|
|
49
|
+
return url || null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when we can prompt the user (a TTY, and not explicitly opted out). */
|
|
56
|
+
export function canPrompt(nonInteractive: boolean): boolean {
|
|
57
|
+
return !nonInteractive && Boolean(process.stdin.isTTY);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ask a single question with a default, echoed as `label [default]: `. Returns
|
|
62
|
+
* the trimmed answer, or the default on an empty line.
|
|
63
|
+
*/
|
|
64
|
+
export function ask(
|
|
65
|
+
rl: readline.Interface,
|
|
66
|
+
label: string,
|
|
67
|
+
def: string,
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
const suffix = def ? ` [${def}]` : "";
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
rl.question(` ${label}${suffix}: `, (answer) => {
|
|
72
|
+
resolve(answer.trim() || def);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run a callback with a readline interface, always closing it. */
|
|
78
|
+
export async function withPrompt<T>(
|
|
79
|
+
fn: (rl: readline.Interface) => Promise<T>,
|
|
80
|
+
): Promise<T> {
|
|
81
|
+
const rl = readline.createInterface({
|
|
82
|
+
input: process.stdin,
|
|
83
|
+
output: process.stdout,
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
return await fn(rl);
|
|
87
|
+
} finally {
|
|
88
|
+
rl.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files `rastack init` templates into a repo — the base GitHub Actions and
|
|
3
|
+
* the deploy config — so starting (or reconfiguring) a project is one command.
|
|
4
|
+
*
|
|
5
|
+
* The generated workflows lean on `rastack ci`: rather than inlining the build
|
|
6
|
+
* and deploy shell, each job runs one `rastack ci …` line, and the CLI owns the
|
|
7
|
+
* steps (tools/src/deploy/ci.ts). That keeps the YAML short and keeps the
|
|
8
|
+
* pipeline logic versioned with the CLI, testable, and inspectable with
|
|
9
|
+
* `rastack ci --plan`.
|
|
10
|
+
*
|
|
11
|
+
* Pure string builders — `rastack-init.ts` is the thin wrapper that writes them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { DeployConfig } from "./config";
|
|
15
|
+
|
|
16
|
+
/** A templated file: repo-relative path + its contents. */
|
|
17
|
+
export interface ScaffoldFile {
|
|
18
|
+
path: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Node version the templated workflows pin (LTS — safe for a fresh repo). */
|
|
23
|
+
const NODE_VERSION = "22";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `.github/workflows/ci.yml` — validate resources and compile the manifest on
|
|
27
|
+
* every push/PR. The clean replacement for hand-written compile/check steps.
|
|
28
|
+
*/
|
|
29
|
+
export function renderCiWorkflow(cfg: DeployConfig): string {
|
|
30
|
+
return `name: CI
|
|
31
|
+
|
|
32
|
+
# Validate the resource graph and compile the manifest on every change.
|
|
33
|
+
# The heavy lifting lives in \`rastack ci check\` (see tools/src/deploy/ci.ts),
|
|
34
|
+
# so this workflow stays a single command.
|
|
35
|
+
|
|
36
|
+
on:
|
|
37
|
+
push:
|
|
38
|
+
branches: [${cfg.github.branch}]
|
|
39
|
+
pull_request:
|
|
40
|
+
|
|
41
|
+
permissions:
|
|
42
|
+
contents: read
|
|
43
|
+
|
|
44
|
+
jobs:
|
|
45
|
+
validate:
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@v4
|
|
49
|
+
- uses: actions/setup-node@v4
|
|
50
|
+
with:
|
|
51
|
+
node-version: '${NODE_VERSION}'
|
|
52
|
+
- name: Install rastack
|
|
53
|
+
run: npm install -g rastack
|
|
54
|
+
- name: Validate resources & compile manifest
|
|
55
|
+
run: rastack ci check
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `.github/workflows/deploy-backend.yml` — ship the Rust Lambda on every push to
|
|
61
|
+
* the deploy branch. Authenticates with GitHub OIDC (no stored keys) and then
|
|
62
|
+
* runs one line, `rastack ci deploy`, which cross-builds the bundle, bakes the
|
|
63
|
+
* JWKS, and calls `update-function-code`. That single privileged call is the
|
|
64
|
+
* whole of CI's blast radius (docs/serverless-deploy.md §2).
|
|
65
|
+
*/
|
|
66
|
+
export function renderDeployWorkflow(cfg: DeployConfig): string {
|
|
67
|
+
const arch = cfg.lambda.arch === "arm64" ? "arm64" : "x86_64";
|
|
68
|
+
return `name: Deploy backend
|
|
69
|
+
|
|
70
|
+
# Ship the Rust API (rastack-server) to AWS Lambda. Infrastructure is created
|
|
71
|
+
# once by a human via CloudFormation (docs/serverless-deploy.md); this workflow
|
|
72
|
+
# only ever runs \`aws lambda update-function-code\`, wrapped by \`rastack ci\`.
|
|
73
|
+
#
|
|
74
|
+
# Wire these from the CloudFormation stack Outputs as GitHub repo Variables:
|
|
75
|
+
# AWS_DEPLOY_ROLE_ARN DeployRoleArn (the OIDC role this job assumes)
|
|
76
|
+
# AWS_REGION Region (${cfg.region})
|
|
77
|
+
|
|
78
|
+
on:
|
|
79
|
+
push:
|
|
80
|
+
branches: [${cfg.github.branch}]
|
|
81
|
+
|
|
82
|
+
# Least privilege: read the repo + mint an OIDC token to assume the deploy role.
|
|
83
|
+
permissions:
|
|
84
|
+
contents: read
|
|
85
|
+
id-token: write
|
|
86
|
+
|
|
87
|
+
concurrency:
|
|
88
|
+
group: deploy-backend
|
|
89
|
+
cancel-in-progress: true
|
|
90
|
+
|
|
91
|
+
jobs:
|
|
92
|
+
deploy:
|
|
93
|
+
name: Build & deploy the Lambda (${arch})
|
|
94
|
+
runs-on: ubuntu-latest
|
|
95
|
+
# Skip on forks / repos that never wired the deploy role, so the job does
|
|
96
|
+
# not fail red for contributors who cannot (and should not) deploy.
|
|
97
|
+
if: vars.AWS_DEPLOY_ROLE_ARN != ''
|
|
98
|
+
steps:
|
|
99
|
+
- uses: actions/checkout@v4
|
|
100
|
+
|
|
101
|
+
- uses: actions/setup-node@v4
|
|
102
|
+
with:
|
|
103
|
+
node-version: '${NODE_VERSION}'
|
|
104
|
+
- name: Install rastack
|
|
105
|
+
run: npm install -g rastack
|
|
106
|
+
|
|
107
|
+
- uses: dtolnay/rust-toolchain@stable
|
|
108
|
+
- name: Cache cargo
|
|
109
|
+
uses: Swatinem/rust-cache@v2
|
|
110
|
+
with:
|
|
111
|
+
workspaces: rust
|
|
112
|
+
# cargo-lambda cross-builds the custom-runtime binary; it auto-installs
|
|
113
|
+
# zig (via pip) for the cross-compile when needed.
|
|
114
|
+
- name: Install cargo-lambda
|
|
115
|
+
run: pip3 install cargo-lambda
|
|
116
|
+
|
|
117
|
+
- name: Configure AWS credentials (OIDC — no stored keys)
|
|
118
|
+
uses: aws-actions/configure-aws-credentials@v4
|
|
119
|
+
with:
|
|
120
|
+
role-to-assume: \${{ vars.AWS_DEPLOY_ROLE_ARN }}
|
|
121
|
+
aws-region: \${{ vars.AWS_REGION }}
|
|
122
|
+
|
|
123
|
+
# One line: compile the manifest, cross-build the bundle, bake the JWKS,
|
|
124
|
+
# and update-function-code. See \`rastack ci --plan deploy\`.
|
|
125
|
+
- name: Build & deploy
|
|
126
|
+
run: rastack ci deploy
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The deploy config written to `.rastack/deploy.json`, pretty-printed. */
|
|
131
|
+
export function renderConfigFile(cfg: DeployConfig): string {
|
|
132
|
+
return JSON.stringify(cfg, null, 2) + "\n";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const CONFIG_PATH = ".rastack/deploy.json";
|
|
136
|
+
export const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
|
|
137
|
+
export const DEPLOY_WORKFLOW_PATH = ".github/workflows/deploy-backend.yml";
|
|
138
|
+
|
|
139
|
+
export interface ScaffoldOptions {
|
|
140
|
+
/** Omit the GitHub Actions workflows (config only). */
|
|
141
|
+
noWorkflows?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The full set of files `rastack init` templates: the deploy config plus the two
|
|
146
|
+
* base workflows. Returned as data so the caller decides what to write, skip, or
|
|
147
|
+
* overwrite.
|
|
148
|
+
*/
|
|
149
|
+
export function scaffoldFiles(
|
|
150
|
+
cfg: DeployConfig,
|
|
151
|
+
opts: ScaffoldOptions = {},
|
|
152
|
+
): ScaffoldFile[] {
|
|
153
|
+
const files: ScaffoldFile[] = [
|
|
154
|
+
{ path: CONFIG_PATH, content: renderConfigFile(cfg) },
|
|
155
|
+
];
|
|
156
|
+
if (!opts.noWorkflows) {
|
|
157
|
+
files.push(
|
|
158
|
+
{ path: CI_WORKFLOW_PATH, content: renderCiWorkflow(cfg) },
|
|
159
|
+
{ path: DEPLOY_WORKFLOW_PATH, content: renderDeployWorkflow(cfg) },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return files;
|
|
163
|
+
}
|