@crewhaus/crewhaus-cloud 0.1.3 → 0.1.5

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,86 @@
1
+ import { type TargetShape, isTargetShape } from "@crewhaus/docker-images";
2
+ /**
3
+ * Section 32 — `@crewhaus/crewhaus-cloud`
4
+ *
5
+ * Composite "managed-as-a-service" recipe:
6
+ * - default tier: 1× gateway-server, 3× target-managed replicas, 1×
7
+ * studio replica, Postgres for spec-registry, Redis Streams for
8
+ * queue-protocol, S3 for hash-chained §20 audit log
9
+ * - `renderKustomizeOverlay({provider, region, tier})` produces a
10
+ * deterministic kustomize overlay layered on top of the §32
11
+ * helm-chart's rendered manifests
12
+ * - `renderTerraformModule({provider, region})` produces the
13
+ * Terraform HCL bringing up the cluster (GKE / EKS / AKS) and
14
+ * associated resources (RDS, ElastiCache, S3 bucket)
15
+ * - `deployCloud({provider, region, runner})` orchestrates the
16
+ * terraform → kubectl-apply pipeline; live `terraform apply` is
17
+ * gated on `TF_BIN` env or an injectable runner (the test pattern
18
+ * mirrors the §30 SQS adapter — ship the abstraction + injection
19
+ * point, gate live SDK calls on env)
20
+ *
21
+ * `crewhaus cloud deploy --provider aws --region us-east-1` and
22
+ * `crewhaus cloud teardown` (apps/cli/src/index.ts) call into this
23
+ * package.
24
+ */
25
+ import { CrewhausError } from "@crewhaus/errors";
26
+ export declare class CrewhausCloudError extends CrewhausError {
27
+ readonly name = "CrewhausCloudError";
28
+ constructor(message: string, cause?: unknown);
29
+ }
30
+ export declare const PROVIDERS: readonly ["aws", "gcp", "azure", "aws-localstack"];
31
+ export type CloudProvider = (typeof PROVIDERS)[number];
32
+ export declare const TIERS: readonly ["dev", "default", "production"];
33
+ export type Tier = (typeof TIERS)[number];
34
+ export declare function recipesRoot(): string;
35
+ export type CloudConfig = {
36
+ readonly provider: CloudProvider;
37
+ readonly region: string;
38
+ readonly tier: Tier;
39
+ readonly clusterName: string;
40
+ readonly imageTag: string;
41
+ };
42
+ export declare function defaultCloudConfig(provider: CloudProvider, region: string): CloudConfig;
43
+ export type TierShape = {
44
+ readonly target: TargetShape;
45
+ readonly replicas: number;
46
+ };
47
+ export declare function tierShapes(tier: Tier): readonly TierShape[];
48
+ /** Builds a kustomization.yaml + chart-rendered manifests directory. */
49
+ export declare function renderKustomizeOverlay(config: CloudConfig): {
50
+ kustomization: string;
51
+ manifests: Record<string, string>;
52
+ };
53
+ export declare function renderTerraformModule(config: CloudConfig): string;
54
+ export type CloudRunner = (argv: readonly string[], cwd: string) => Promise<{
55
+ exitCode: number;
56
+ stdout: string;
57
+ stderr: string;
58
+ }>;
59
+ export type DeployCloudOptions = {
60
+ readonly config: CloudConfig;
61
+ /** Working directory to write generated artefacts into; defaults to a temp dir. */
62
+ readonly workingDir?: string;
63
+ /** Override the terraform binary path; defaults to env TF_BIN or "terraform". */
64
+ readonly tfBin?: string;
65
+ /** Override the kubectl binary path. */
66
+ readonly kubectlBin?: string;
67
+ /** Test injection point. */
68
+ readonly runner?: CloudRunner;
69
+ };
70
+ export type DeployCloudResult = {
71
+ readonly workingDir: string;
72
+ readonly steps: ReadonlyArray<{
73
+ readonly name: string;
74
+ readonly skipped?: boolean;
75
+ }>;
76
+ readonly outputs: Readonly<Record<string, string>>;
77
+ };
78
+ export declare function deployCloud(opts: DeployCloudOptions): Promise<DeployCloudResult>;
79
+ export declare function teardownCloud(opts: DeployCloudOptions): Promise<void>;
80
+ /** Used by the CLI subcommand to enumerate provider choices. */
81
+ export declare function listProviders(): readonly CloudProvider[];
82
+ /** Used by the CLI subcommand for input validation. */
83
+ export declare function isCloudProvider(value: unknown): value is CloudProvider;
84
+ /** Sanity check used by the smoke test. */
85
+ export declare function summariseDeploy(result: DeployCloudResult): string;
86
+ export { isTargetShape };
package/dist/index.js ADDED
@@ -0,0 +1,423 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { isTargetShape } from "@crewhaus/docker-images";
6
+ /**
7
+ * Section 32 — `@crewhaus/crewhaus-cloud`
8
+ *
9
+ * Composite "managed-as-a-service" recipe:
10
+ * - default tier: 1× gateway-server, 3× target-managed replicas, 1×
11
+ * studio replica, Postgres for spec-registry, Redis Streams for
12
+ * queue-protocol, S3 for hash-chained §20 audit log
13
+ * - `renderKustomizeOverlay({provider, region, tier})` produces a
14
+ * deterministic kustomize overlay layered on top of the §32
15
+ * helm-chart's rendered manifests
16
+ * - `renderTerraformModule({provider, region})` produces the
17
+ * Terraform HCL bringing up the cluster (GKE / EKS / AKS) and
18
+ * associated resources (RDS, ElastiCache, S3 bucket)
19
+ * - `deployCloud({provider, region, runner})` orchestrates the
20
+ * terraform → kubectl-apply pipeline; live `terraform apply` is
21
+ * gated on `TF_BIN` env or an injectable runner (the test pattern
22
+ * mirrors the §30 SQS adapter — ship the abstraction + injection
23
+ * point, gate live SDK calls on env)
24
+ *
25
+ * `crewhaus cloud deploy --provider aws --region us-east-1` and
26
+ * `crewhaus cloud teardown` (apps/cli/src/index.ts) call into this
27
+ * package.
28
+ */
29
+ import { CrewhausError } from "@crewhaus/errors";
30
+ import { defaultValues, renderChart, validateValues } from "@crewhaus/helm-chart";
31
+ export class CrewhausCloudError extends CrewhausError {
32
+ name = "CrewhausCloudError";
33
+ constructor(message, cause) {
34
+ super("config", message, cause);
35
+ }
36
+ }
37
+ export const PROVIDERS = ["aws", "gcp", "azure", "aws-localstack"];
38
+ export const TIERS = ["dev", "default", "production"];
39
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
40
+ const RECIPES_ROOT = join(PACKAGE_ROOT, "recipes");
41
+ export function recipesRoot() {
42
+ return RECIPES_ROOT;
43
+ }
44
+ export function defaultCloudConfig(provider, region) {
45
+ if (!PROVIDERS.includes(provider)) {
46
+ throw new CrewhausCloudError(`unknown provider: ${provider}`);
47
+ }
48
+ if (!region || /\s/.test(region)) {
49
+ throw new CrewhausCloudError(`invalid region: ${JSON.stringify(region)}`);
50
+ }
51
+ return {
52
+ provider,
53
+ region,
54
+ tier: "default",
55
+ clusterName: `crewhaus-${provider}-${region}`.replace(/[^a-z0-9-]/g, "-"),
56
+ imageTag: "latest",
57
+ };
58
+ }
59
+ export function tierShapes(tier) {
60
+ switch (tier) {
61
+ case "dev":
62
+ return [{ target: "managed", replicas: 1 }];
63
+ case "default":
64
+ return [
65
+ { target: "managed", replicas: 3 },
66
+ // Studio v1 (§31) ships as a managed-shape side helper deployment
67
+ ];
68
+ case "production":
69
+ return [{ target: "managed", replicas: 5 }];
70
+ default: {
71
+ const _exhaustive = tier;
72
+ throw new CrewhausCloudError(`unhandled tier: ${String(_exhaustive)}`);
73
+ }
74
+ }
75
+ }
76
+ // ─── Kustomize overlay rendering ─────────────────────────────────────────────
77
+ /** Builds a kustomization.yaml + chart-rendered manifests directory. */
78
+ export function renderKustomizeOverlay(config) {
79
+ const shapes = tierShapes(config.tier);
80
+ const manifests = {};
81
+ const resourceFiles = [];
82
+ for (const shape of shapes) {
83
+ const values = {
84
+ ...defaultValues(),
85
+ target: shape.target,
86
+ replicas: shape.replicas,
87
+ image: { ...defaultValues().image, tag: config.imageTag },
88
+ };
89
+ validateValues(values);
90
+ const rendered = renderChart(values, `${config.clusterName}-${shape.target}`);
91
+ for (const [name, body] of Object.entries(rendered)) {
92
+ const fileName = `${shape.target}-${name}`;
93
+ manifests[fileName] = body;
94
+ resourceFiles.push(fileName);
95
+ }
96
+ }
97
+ const kustomization = `# Generated by @crewhaus/crewhaus-cloud (Section 32) — do not edit by hand.
98
+ apiVersion: kustomize.config.k8s.io/v1beta1
99
+ kind: Kustomization
100
+ namespace: ${config.clusterName}
101
+ commonLabels:
102
+ crewhaus.ai/cluster: ${config.clusterName}
103
+ crewhaus.ai/provider: ${config.provider}
104
+ crewhaus.ai/region: ${config.region}
105
+ crewhaus.ai/tier: ${config.tier}
106
+ resources:
107
+ ${resourceFiles.map((f) => ` - ${f}`).join("\n")}
108
+ `;
109
+ return { kustomization, manifests };
110
+ }
111
+ // ─── Terraform module rendering ──────────────────────────────────────────────
112
+ export function renderTerraformModule(config) {
113
+ validateValues({
114
+ ...defaultValues(),
115
+ target: "managed",
116
+ image: { ...defaultValues().image, tag: config.imageTag },
117
+ });
118
+ switch (config.provider) {
119
+ case "aws":
120
+ case "aws-localstack":
121
+ return renderAwsTerraform(config);
122
+ case "gcp":
123
+ return renderGcpTerraform(config);
124
+ case "azure":
125
+ return renderAzureTerraform(config);
126
+ default: {
127
+ const _exhaustive = config.provider;
128
+ throw new CrewhausCloudError(`unhandled provider: ${String(_exhaustive)}`);
129
+ }
130
+ }
131
+ }
132
+ function renderAwsTerraform(config) {
133
+ const localstackBlock = config.provider === "aws-localstack"
134
+ ? `\n # LocalStack overrides — wires every AWS service to the LocalStack endpoint
135
+ endpoints {
136
+ eks = var.localstack_endpoint
137
+ rds = var.localstack_endpoint
138
+ elasticache = var.localstack_endpoint
139
+ s3 = var.localstack_endpoint
140
+ ecr = var.localstack_endpoint
141
+ }
142
+ s3_use_path_style = true
143
+ skip_credentials_validation = true
144
+ skip_metadata_api_check = true
145
+ skip_requesting_account_id = true`
146
+ : "";
147
+ return `# Generated by @crewhaus/crewhaus-cloud — Terraform module for ${config.provider} (${config.region})
148
+ terraform {
149
+ required_providers {
150
+ aws = { source = "hashicorp/aws", version = "~> 5.0" }
151
+ }
152
+ }
153
+
154
+ variable "localstack_endpoint" {
155
+ type = string
156
+ default = "http://localhost:4566"
157
+ }
158
+
159
+ provider "aws" {
160
+ region = "${config.region}"${localstackBlock}
161
+ }
162
+
163
+ resource "aws_eks_cluster" "crewhaus" {
164
+ name = "${config.clusterName}"
165
+ role_arn = aws_iam_role.cluster.arn
166
+ version = "1.30"
167
+
168
+ vpc_config {
169
+ subnet_ids = aws_subnet.crewhaus[*].id
170
+ }
171
+ }
172
+
173
+ resource "aws_iam_role" "cluster" {
174
+ name = "${config.clusterName}-cluster-role"
175
+ assume_role_policy = jsonencode({
176
+ Version = "2012-10-17"
177
+ Statement = [{
178
+ Effect = "Allow"
179
+ Principal = { Service = "eks.amazonaws.com" }
180
+ Action = "sts:AssumeRole"
181
+ }]
182
+ })
183
+ }
184
+
185
+ resource "aws_subnet" "crewhaus" {
186
+ count = 2
187
+ vpc_id = aws_vpc.crewhaus.id
188
+ cidr_block = "10.0.\${count.index + 1}.0/24"
189
+ availability_zone = "${config.region}a"
190
+ }
191
+
192
+ resource "aws_vpc" "crewhaus" {
193
+ cidr_block = "10.0.0.0/16"
194
+ tags = { Name = "${config.clusterName}-vpc" }
195
+ }
196
+
197
+ resource "aws_db_instance" "spec_registry" {
198
+ identifier = "${config.clusterName}-spec-registry"
199
+ engine = "postgres"
200
+ engine_version = "15"
201
+ instance_class = "db.t3.micro"
202
+ allocated_storage = 20
203
+ username = "crewhaus"
204
+ password = var.spec_registry_password
205
+ skip_final_snapshot = true
206
+ }
207
+
208
+ resource "aws_elasticache_cluster" "queue" {
209
+ cluster_id = "${config.clusterName}-queue"
210
+ engine = "redis"
211
+ node_type = "cache.t3.micro"
212
+ num_cache_nodes = 1
213
+ parameter_group_name = "default.redis7"
214
+ port = 6379
215
+ }
216
+
217
+ resource "aws_s3_bucket" "audit_log" {
218
+ bucket = "${config.clusterName}-audit"
219
+ }
220
+
221
+ variable "spec_registry_password" {
222
+ type = string
223
+ sensitive = true
224
+ }
225
+
226
+ output "cluster_endpoint" {
227
+ value = aws_eks_cluster.crewhaus.endpoint
228
+ }
229
+
230
+ output "audit_bucket" {
231
+ value = aws_s3_bucket.audit_log.bucket
232
+ }
233
+ `;
234
+ }
235
+ function renderGcpTerraform(config) {
236
+ return `# Generated by @crewhaus/crewhaus-cloud — Terraform module for GCP (${config.region})
237
+ terraform {
238
+ required_providers {
239
+ google = { source = "hashicorp/google", version = "~> 5.0" }
240
+ }
241
+ }
242
+
243
+ provider "google" {
244
+ region = "${config.region}"
245
+ }
246
+
247
+ resource "google_container_cluster" "crewhaus" {
248
+ name = "${config.clusterName}"
249
+ location = "${config.region}"
250
+
251
+ initial_node_count = 3
252
+ remove_default_node_pool = false
253
+ }
254
+
255
+ resource "google_sql_database_instance" "spec_registry" {
256
+ name = "${config.clusterName}-spec-registry"
257
+ database_version = "POSTGRES_15"
258
+ region = "${config.region}"
259
+ settings { tier = "db-f1-micro" }
260
+ }
261
+
262
+ resource "google_storage_bucket" "audit_log" {
263
+ name = "${config.clusterName}-audit"
264
+ location = "${config.region}"
265
+ force_destroy = true
266
+ }
267
+
268
+ output "cluster_endpoint" {
269
+ value = google_container_cluster.crewhaus.endpoint
270
+ }
271
+ `;
272
+ }
273
+ function renderAzureTerraform(config) {
274
+ return `# Generated by @crewhaus/crewhaus-cloud — Terraform module for Azure (${config.region})
275
+ terraform {
276
+ required_providers {
277
+ azurerm = { source = "hashicorp/azurerm", version = "~> 3.0" }
278
+ }
279
+ }
280
+
281
+ provider "azurerm" {
282
+ features {}
283
+ }
284
+
285
+ resource "azurerm_resource_group" "crewhaus" {
286
+ name = "${config.clusterName}"
287
+ location = "${config.region}"
288
+ }
289
+
290
+ resource "azurerm_kubernetes_cluster" "crewhaus" {
291
+ name = "${config.clusterName}"
292
+ location = azurerm_resource_group.crewhaus.location
293
+ resource_group_name = azurerm_resource_group.crewhaus.name
294
+ dns_prefix = "${config.clusterName}"
295
+
296
+ default_node_pool {
297
+ name = "default"
298
+ node_count = 3
299
+ vm_size = "Standard_D2_v2"
300
+ }
301
+
302
+ identity { type = "SystemAssigned" }
303
+ }
304
+
305
+ resource "azurerm_postgresql_flexible_server" "spec_registry" {
306
+ name = "${config.clusterName}-spec-registry"
307
+ resource_group_name = azurerm_resource_group.crewhaus.name
308
+ location = azurerm_resource_group.crewhaus.location
309
+ administrator_login = "crewhaus"
310
+ administrator_password = var.spec_registry_password
311
+ version = "15"
312
+ sku_name = "B_Standard_B1ms"
313
+ storage_mb = 32768
314
+ backup_retention_days = 7
315
+ }
316
+
317
+ variable "spec_registry_password" {
318
+ type = string
319
+ sensitive = true
320
+ }
321
+
322
+ output "cluster_fqdn" {
323
+ value = azurerm_kubernetes_cluster.crewhaus.fqdn
324
+ }
325
+ `;
326
+ }
327
+ export async function deployCloud(opts) {
328
+ const { config } = opts;
329
+ const workingDir = opts.workingDir ?? join(RECIPES_ROOT, ".out", config.clusterName);
330
+ mkdirSync(join(workingDir, "terraform"), { recursive: true });
331
+ mkdirSync(join(workingDir, "kustomize"), { recursive: true });
332
+ // 1. Render the Terraform module + Kustomize overlay onto disk.
333
+ const tfModule = renderTerraformModule(config);
334
+ writeFileSync(join(workingDir, "terraform", "main.tf"), tfModule);
335
+ const overlay = renderKustomizeOverlay(config);
336
+ writeFileSync(join(workingDir, "kustomize", "kustomization.yaml"), overlay.kustomization);
337
+ for (const [name, body] of Object.entries(overlay.manifests)) {
338
+ writeFileSync(join(workingDir, "kustomize", name), body);
339
+ }
340
+ // 2. Run terraform apply (gated on tfBin existing). If unavailable, mark skipped.
341
+ const tfBin = opts.tfBin ?? process.env["TF_BIN"] ?? "terraform";
342
+ const runner = opts.runner;
343
+ const steps = [];
344
+ const outputs = {};
345
+ if (runner) {
346
+ const init = await runner([tfBin, "init"], join(workingDir, "terraform"));
347
+ if (init.exitCode !== 0) {
348
+ throw new CrewhausCloudError(`terraform init failed: ${init.stderr.slice(0, 1024)}`);
349
+ }
350
+ steps.push({ name: "terraform-init" });
351
+ const apply = await runner([tfBin, "apply", "-auto-approve", "-var", `spec_registry_password=${randomPassword()}`], join(workingDir, "terraform"));
352
+ if (apply.exitCode !== 0) {
353
+ throw new CrewhausCloudError(`terraform apply failed: ${apply.stderr.slice(0, 1024)}`);
354
+ }
355
+ steps.push({ name: "terraform-apply" });
356
+ const tfOut = await runner([tfBin, "output", "-json"], join(workingDir, "terraform"));
357
+ if (tfOut.exitCode === 0) {
358
+ try {
359
+ const parsed = JSON.parse(tfOut.stdout);
360
+ for (const [k, v] of Object.entries(parsed)) {
361
+ outputs[k] = v.value;
362
+ }
363
+ }
364
+ catch {
365
+ // Non-fatal — leave outputs empty.
366
+ }
367
+ }
368
+ const kubectlBin = opts.kubectlBin ?? "kubectl";
369
+ const kapply = await runner([kubectlBin, "apply", "-k", "."], join(workingDir, "kustomize"));
370
+ if (kapply.exitCode !== 0) {
371
+ throw new CrewhausCloudError(`kubectl apply failed: ${kapply.stderr.slice(0, 1024)}`);
372
+ }
373
+ steps.push({ name: "kubectl-apply" });
374
+ }
375
+ else {
376
+ steps.push({ name: "terraform-init", skipped: true });
377
+ steps.push({ name: "terraform-apply", skipped: true });
378
+ steps.push({ name: "kubectl-apply", skipped: true });
379
+ }
380
+ return { workingDir, steps, outputs };
381
+ }
382
+ export async function teardownCloud(opts) {
383
+ const tfBin = opts.tfBin ?? process.env["TF_BIN"] ?? "terraform";
384
+ const workingDir = opts.workingDir ?? join(RECIPES_ROOT, ".out", opts.config.clusterName);
385
+ if (!existsSync(workingDir)) {
386
+ throw new CrewhausCloudError(`no working directory at ${workingDir} (was deployCloud ever run?)`);
387
+ }
388
+ const runner = opts.runner;
389
+ if (!runner) {
390
+ return; // no-op when there's no runner available
391
+ }
392
+ const result = await runner([tfBin, "destroy", "-auto-approve", "-var", `spec_registry_password=${randomPassword()}`], join(workingDir, "terraform"));
393
+ if (result.exitCode !== 0) {
394
+ throw new CrewhausCloudError(`terraform destroy failed: ${result.stderr.slice(0, 1024)}`);
395
+ }
396
+ }
397
+ function randomPassword() {
398
+ // 24 hex chars — only used as a placeholder so the Terraform variable is set;
399
+ // production users override via -var-file. Sourced from a CSPRNG so that, if
400
+ // an operator does not override it, the provisioned DB admin credential is not
401
+ // derived from a predictable Math.random() stream (CWE-338).
402
+ return randomBytes(12).toString("hex");
403
+ }
404
+ /** Used by the CLI subcommand to enumerate provider choices. */
405
+ export function listProviders() {
406
+ return PROVIDERS;
407
+ }
408
+ /** Used by the CLI subcommand for input validation. */
409
+ export function isCloudProvider(value) {
410
+ return typeof value === "string" && PROVIDERS.includes(value);
411
+ }
412
+ /** Sanity check used by the smoke test. */
413
+ export function summariseDeploy(result) {
414
+ const lines = [`Working dir: ${result.workingDir}`];
415
+ for (const step of result.steps) {
416
+ lines.push(` ${step.skipped ? "skip" : "ok"} ${step.name}`);
417
+ }
418
+ for (const [k, v] of Object.entries(result.outputs)) {
419
+ lines.push(` out ${k} = ${v}`);
420
+ }
421
+ return lines.join("\n");
422
+ }
423
+ export { isTargetShape };
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "@crewhaus/crewhaus-cloud",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Managed-as-a-service composite recipe: target-managed + helm-chart Kustomize overlay + Terraform module for GKE/EKS/AKS provisioning (Section 32)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/docker-images": "0.1.3",
16
- "@crewhaus/errors": "0.1.3",
17
- "@crewhaus/helm-chart": "0.1.3"
18
+ "@crewhaus/docker-images": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5",
20
+ "@crewhaus/helm-chart": "0.1.5"
18
21
  },
19
22
  "license": "Apache-2.0",
20
23
  "author": {
@@ -34,5 +37,5 @@
34
37
  "publishConfig": {
35
38
  "access": "public"
36
39
  },
37
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
40
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
38
41
  }
package/src/index.test.ts DELETED
@@ -1,320 +0,0 @@
1
- import { describe, expect, spyOn, test } from "bun:test";
2
- import * as nodeCrypto from "node:crypto";
3
- import { mkdtempSync, readFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
-
7
- import {
8
- type CloudConfig,
9
- type CloudRunner,
10
- CrewhausCloudError,
11
- PROVIDERS,
12
- TIERS,
13
- defaultCloudConfig,
14
- deployCloud,
15
- isCloudProvider,
16
- listProviders,
17
- recipesRoot,
18
- renderKustomizeOverlay,
19
- renderTerraformModule,
20
- summariseDeploy,
21
- teardownCloud,
22
- tierShapes,
23
- } from "./index";
24
-
25
- describe("PROVIDERS / TIERS / defaultCloudConfig", () => {
26
- test("listProviders returns canonical list", () => {
27
- expect(listProviders()).toEqual(PROVIDERS);
28
- });
29
-
30
- test("defaultCloudConfig sets sensible defaults", () => {
31
- const c = defaultCloudConfig("aws", "us-east-1");
32
- expect(c.provider).toBe("aws");
33
- expect(c.region).toBe("us-east-1");
34
- expect(c.tier).toBe("default");
35
- expect(c.imageTag).toBe("latest");
36
- expect(c.clusterName).toBe("crewhaus-aws-us-east-1");
37
- });
38
-
39
- test("rejects unknown provider", () => {
40
- expect(() => defaultCloudConfig("dropbox" as never, "us-east-1")).toThrow(CrewhausCloudError);
41
- });
42
-
43
- test("rejects whitespace region", () => {
44
- expect(() => defaultCloudConfig("aws", "us east 1")).toThrow();
45
- });
46
-
47
- test("isCloudProvider acts as a type guard", () => {
48
- expect(isCloudProvider("aws")).toBe(true);
49
- expect(isCloudProvider("gcp")).toBe(true);
50
- expect(isCloudProvider("digitalocean")).toBe(false);
51
- });
52
-
53
- test("tierShapes covers all tiers", () => {
54
- for (const t of TIERS) {
55
- const shapes = tierShapes(t);
56
- expect(shapes.length).toBeGreaterThan(0);
57
- for (const s of shapes) expect(s.replicas).toBeGreaterThan(0);
58
- }
59
- });
60
- });
61
-
62
- describe("renderKustomizeOverlay", () => {
63
- test("default config produces a well-formed kustomization.yaml", () => {
64
- const config = defaultCloudConfig("aws", "us-east-1");
65
- const overlay = renderKustomizeOverlay(config);
66
- expect(overlay.kustomization).toContain("kind: Kustomization");
67
- expect(overlay.kustomization).toContain(`namespace: ${config.clusterName}`);
68
- expect(overlay.kustomization).toContain("crewhaus.ai/provider: aws");
69
- expect(overlay.kustomization).toContain("crewhaus.ai/region: us-east-1");
70
- expect(Object.keys(overlay.manifests).length).toBeGreaterThan(0);
71
- expect(Object.keys(overlay.manifests).some((k) => k.startsWith("managed-"))).toBe(true);
72
- });
73
-
74
- test("each rendered manifest contains a Kubernetes kind", () => {
75
- const config = defaultCloudConfig("gcp", "us-central1");
76
- const overlay = renderKustomizeOverlay(config);
77
- for (const body of Object.values(overlay.manifests)) {
78
- // Some are blank if the if-gate evaluated false (e.g., ingress disabled);
79
- // we only require the non-empty ones to declare a kind.
80
- if (body.trim().length > 0) {
81
- expect(/^kind: \w+$/m.test(body)).toBe(true);
82
- }
83
- }
84
- });
85
-
86
- test("output is deterministic across two renders", () => {
87
- const config = defaultCloudConfig("aws", "us-east-1");
88
- const a = renderKustomizeOverlay(config);
89
- const b = renderKustomizeOverlay(config);
90
- expect(a.kustomization).toBe(b.kustomization);
91
- expect(Object.keys(a.manifests)).toEqual(Object.keys(b.manifests));
92
- });
93
- });
94
-
95
- describe("renderTerraformModule", () => {
96
- test("AWS module declares aws_eks_cluster + aws_db_instance + aws_s3_bucket", () => {
97
- const tf = renderTerraformModule(defaultCloudConfig("aws", "us-east-1"));
98
- expect(tf).toContain("hashicorp/aws");
99
- expect(tf).toContain('aws_eks_cluster" "crewhaus"');
100
- expect(tf).toContain('aws_db_instance" "spec_registry"');
101
- expect(tf).toContain('aws_s3_bucket" "audit_log"');
102
- });
103
-
104
- test("aws-localstack module wires endpoints to LocalStack", () => {
105
- const tf = renderTerraformModule(defaultCloudConfig("aws-localstack", "us-east-1"));
106
- expect(tf).toContain("var.localstack_endpoint");
107
- expect(tf).toContain("s3_use_path_style");
108
- });
109
-
110
- test("GCP module uses google_container_cluster + google_sql_database_instance", () => {
111
- const tf = renderTerraformModule(defaultCloudConfig("gcp", "us-central1"));
112
- expect(tf).toContain("hashicorp/google");
113
- expect(tf).toContain('google_container_cluster" "crewhaus"');
114
- expect(tf).toContain('google_sql_database_instance" "spec_registry"');
115
- });
116
-
117
- test("Azure module uses azurerm_kubernetes_cluster + azurerm_postgresql_flexible_server", () => {
118
- const tf = renderTerraformModule(defaultCloudConfig("azure", "eastus"));
119
- expect(tf).toContain("hashicorp/azurerm");
120
- expect(tf).toContain('azurerm_kubernetes_cluster" "crewhaus"');
121
- expect(tf).toContain('azurerm_postgresql_flexible_server" "spec_registry"');
122
- });
123
- });
124
-
125
- describe("deployCloud (T2 dry-run with fake runner)", () => {
126
- test("when no runner is supplied, all steps are marked skipped", async () => {
127
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-cloud-deploy-"));
128
- const result = await deployCloud({
129
- config: defaultCloudConfig("aws", "us-east-1"),
130
- workingDir: dir,
131
- });
132
- expect(result.workingDir).toBe(dir);
133
- for (const step of result.steps) {
134
- expect(step.skipped).toBe(true);
135
- }
136
- // Files were still written
137
- expect(readFileSync(join(dir, "terraform", "main.tf"), "utf8")).toContain("aws_eks_cluster");
138
- expect(readFileSync(join(dir, "kustomize", "kustomization.yaml"), "utf8")).toContain(
139
- "kind: Kustomization",
140
- );
141
- });
142
-
143
- test("with a fake runner, the documented argv sequence is invoked", async () => {
144
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-cloud-deploy-"));
145
- const calls: string[][] = [];
146
- const runner: CloudRunner = async (argv) => {
147
- calls.push([...argv]);
148
- // Simulate `terraform output -json` returning two outputs
149
- if (argv.includes("output")) {
150
- return {
151
- exitCode: 0,
152
- stdout: JSON.stringify({
153
- cluster_endpoint: { value: "https://example.eks" },
154
- audit_bucket: { value: "crewhaus-aws-us-east-1-audit" },
155
- }),
156
- stderr: "",
157
- };
158
- }
159
- return { exitCode: 0, stdout: "", stderr: "" };
160
- };
161
- const result = await deployCloud({
162
- config: defaultCloudConfig("aws", "us-east-1"),
163
- workingDir: dir,
164
- tfBin: "terraform",
165
- runner,
166
- });
167
- const argvs = calls.map((c) => c[1]);
168
- expect(argvs).toContain("init");
169
- expect(argvs).toContain("apply");
170
- expect(argvs).toContain("output");
171
- // kubectl apply step
172
- const kubectlApply = calls.find((c) => c[0] === "kubectl");
173
- expect(kubectlApply).toBeDefined();
174
- expect(kubectlApply).toContain("apply");
175
- expect(result.outputs["cluster_endpoint"]).toBe("https://example.eks");
176
- });
177
-
178
- test("non-zero exit on terraform apply propagates as CrewhausCloudError", async () => {
179
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-cloud-deploy-"));
180
- const runner: CloudRunner = async (argv) =>
181
- argv.includes("apply")
182
- ? { exitCode: 1, stdout: "", stderr: "no AWS credentials" }
183
- : { exitCode: 0, stdout: "", stderr: "" };
184
- await expect(
185
- deployCloud({
186
- config: defaultCloudConfig("aws", "us-east-1"),
187
- workingDir: dir,
188
- runner,
189
- }),
190
- ).rejects.toThrow(/no AWS credentials/);
191
- });
192
- });
193
-
194
- describe("randomPassword (CWE-338 regression — CSPRNG, not Math.random)", () => {
195
- function passwordFromCalls(calls: string[][]): string | undefined {
196
- for (const argv of calls) {
197
- const varArg = argv.find((a) => a.startsWith("spec_registry_password="));
198
- if (varArg) return varArg.slice("spec_registry_password=".length);
199
- }
200
- return undefined;
201
- }
202
-
203
- test("deploy injects a 24-char hex password sourced from node:crypto.randomBytes", async () => {
204
- const randomBytesSpy = spyOn(nodeCrypto, "randomBytes");
205
- try {
206
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-cloud-pw-"));
207
- const calls: string[][] = [];
208
- const runner: CloudRunner = async (argv) => {
209
- calls.push([...argv]);
210
- return { exitCode: 0, stdout: "", stderr: "" };
211
- };
212
- await deployCloud({
213
- config: defaultCloudConfig("aws", "us-east-1"),
214
- workingDir: dir,
215
- runner,
216
- });
217
- const pw = passwordFromCalls(calls);
218
- expect(pw).toBeDefined();
219
- // Same shape as before the fix: exactly 24 lowercase-hex characters.
220
- expect(pw).toMatch(/^[0-9a-f]{24}$/);
221
- // Proves the placeholder is drawn from the CSPRNG, not Math.random().
222
- expect(randomBytesSpy).toHaveBeenCalled();
223
- } finally {
224
- randomBytesSpy.mockRestore();
225
- }
226
- });
227
-
228
- test("two separate deploys produce different passwords (not a constant)", async () => {
229
- const collect = async (): Promise<string | undefined> => {
230
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-cloud-pw-"));
231
- const calls: string[][] = [];
232
- const runner: CloudRunner = async (argv) => {
233
- calls.push([...argv]);
234
- return { exitCode: 0, stdout: "", stderr: "" };
235
- };
236
- await deployCloud({
237
- config: defaultCloudConfig("gcp", "us-central1"),
238
- workingDir: dir,
239
- runner,
240
- });
241
- return passwordFromCalls(calls);
242
- };
243
- const [a, b] = [await collect(), await collect()];
244
- expect(a).toMatch(/^[0-9a-f]{24}$/);
245
- expect(b).toMatch(/^[0-9a-f]{24}$/);
246
- expect(a).not.toBe(b);
247
- });
248
-
249
- test("teardown also injects a CSPRNG-sourced 24-char hex password", async () => {
250
- const randomBytesSpy = spyOn(nodeCrypto, "randomBytes");
251
- try {
252
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-cloud-pw-td-"));
253
- await deployCloud({ config: defaultCloudConfig("aws", "us-east-1"), workingDir: dir });
254
- randomBytesSpy.mockClear();
255
- const calls: string[][] = [];
256
- const runner: CloudRunner = async (argv) => {
257
- calls.push([...argv]);
258
- return { exitCode: 0, stdout: "", stderr: "" };
259
- };
260
- await teardownCloud({
261
- config: defaultCloudConfig("aws", "us-east-1"),
262
- workingDir: dir,
263
- runner,
264
- });
265
- const pw = passwordFromCalls(calls);
266
- expect(pw).toMatch(/^[0-9a-f]{24}$/);
267
- expect(randomBytesSpy).toHaveBeenCalled();
268
- } finally {
269
- randomBytesSpy.mockRestore();
270
- }
271
- });
272
- });
273
-
274
- describe("teardownCloud", () => {
275
- test("refuses if working dir does not exist", async () => {
276
- await expect(
277
- teardownCloud({
278
- config: defaultCloudConfig("aws", "us-east-1"),
279
- workingDir: "/nonexistent/path",
280
- }),
281
- ).rejects.toThrow(/no working directory/);
282
- });
283
-
284
- test("calls terraform destroy with the right cwd", async () => {
285
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-cloud-teardown-"));
286
- // Bootstrap a working dir first
287
- await deployCloud({ config: defaultCloudConfig("aws", "us-east-1"), workingDir: dir });
288
- const calls: string[][] = [];
289
- const runner: CloudRunner = async (argv) => {
290
- calls.push([...argv]);
291
- return { exitCode: 0, stdout: "", stderr: "" };
292
- };
293
- await teardownCloud({
294
- config: defaultCloudConfig("aws", "us-east-1"),
295
- workingDir: dir,
296
- runner,
297
- });
298
- expect(calls.length).toBe(1);
299
- expect(calls[0]).toContain("destroy");
300
- expect(calls[0]).toContain("-auto-approve");
301
- });
302
- });
303
-
304
- describe("recipesRoot + summariseDeploy + sanity", () => {
305
- test("recipesRoot lives inside the package", () => {
306
- expect(recipesRoot()).toMatch(/crewhaus-cloud\/recipes$/);
307
- });
308
-
309
- test("summariseDeploy emits readable lines", () => {
310
- const summary = summariseDeploy({
311
- workingDir: "/tmp/x",
312
- steps: [{ name: "terraform-init" }, { name: "terraform-apply", skipped: true }],
313
- outputs: { cluster_endpoint: "https://eks.example" },
314
- });
315
- expect(summary).toContain("/tmp/x");
316
- expect(summary).toContain("ok terraform-init");
317
- expect(summary).toContain("skip terraform-apply");
318
- expect(summary).toContain("cluster_endpoint = https://eks.example");
319
- });
320
- });
package/src/index.ts DELETED
@@ -1,508 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { dirname, join, resolve } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { type TargetShape, isTargetShape } from "@crewhaus/docker-images";
6
- /**
7
- * Section 32 — `@crewhaus/crewhaus-cloud`
8
- *
9
- * Composite "managed-as-a-service" recipe:
10
- * - default tier: 1× gateway-server, 3× target-managed replicas, 1×
11
- * studio replica, Postgres for spec-registry, Redis Streams for
12
- * queue-protocol, S3 for hash-chained §20 audit log
13
- * - `renderKustomizeOverlay({provider, region, tier})` produces a
14
- * deterministic kustomize overlay layered on top of the §32
15
- * helm-chart's rendered manifests
16
- * - `renderTerraformModule({provider, region})` produces the
17
- * Terraform HCL bringing up the cluster (GKE / EKS / AKS) and
18
- * associated resources (RDS, ElastiCache, S3 bucket)
19
- * - `deployCloud({provider, region, runner})` orchestrates the
20
- * terraform → kubectl-apply pipeline; live `terraform apply` is
21
- * gated on `TF_BIN` env or an injectable runner (the test pattern
22
- * mirrors the §30 SQS adapter — ship the abstraction + injection
23
- * point, gate live SDK calls on env)
24
- *
25
- * `crewhaus cloud deploy --provider aws --region us-east-1` and
26
- * `crewhaus cloud teardown` (apps/cli/src/index.ts) call into this
27
- * package.
28
- */
29
- import { CrewhausError } from "@crewhaus/errors";
30
- import { type ChartValues, defaultValues, renderChart, validateValues } from "@crewhaus/helm-chart";
31
-
32
- export class CrewhausCloudError extends CrewhausError {
33
- override readonly name = "CrewhausCloudError";
34
- constructor(message: string, cause?: unknown) {
35
- super("config", message, cause);
36
- }
37
- }
38
-
39
- export const PROVIDERS = ["aws", "gcp", "azure", "aws-localstack"] as const;
40
- export type CloudProvider = (typeof PROVIDERS)[number];
41
-
42
- export const TIERS = ["dev", "default", "production"] as const;
43
- export type Tier = (typeof TIERS)[number];
44
-
45
- const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
46
- const RECIPES_ROOT = join(PACKAGE_ROOT, "recipes");
47
-
48
- export function recipesRoot(): string {
49
- return RECIPES_ROOT;
50
- }
51
-
52
- export type CloudConfig = {
53
- readonly provider: CloudProvider;
54
- readonly region: string;
55
- readonly tier: Tier;
56
- readonly clusterName: string;
57
- readonly imageTag: string;
58
- };
59
-
60
- export function defaultCloudConfig(provider: CloudProvider, region: string): CloudConfig {
61
- if (!(PROVIDERS as readonly string[]).includes(provider)) {
62
- throw new CrewhausCloudError(`unknown provider: ${provider}`);
63
- }
64
- if (!region || /\s/.test(region)) {
65
- throw new CrewhausCloudError(`invalid region: ${JSON.stringify(region)}`);
66
- }
67
- return {
68
- provider,
69
- region,
70
- tier: "default",
71
- clusterName: `crewhaus-${provider}-${region}`.replace(/[^a-z0-9-]/g, "-"),
72
- imageTag: "latest",
73
- };
74
- }
75
-
76
- // ─── Tier sizing ─────────────────────────────────────────────────────────────
77
-
78
- export type TierShape = {
79
- readonly target: TargetShape;
80
- readonly replicas: number;
81
- };
82
-
83
- export function tierShapes(tier: Tier): readonly TierShape[] {
84
- switch (tier) {
85
- case "dev":
86
- return [{ target: "managed", replicas: 1 }];
87
- case "default":
88
- return [
89
- { target: "managed", replicas: 3 },
90
- // Studio v1 (§31) ships as a managed-shape side helper deployment
91
- ];
92
- case "production":
93
- return [{ target: "managed", replicas: 5 }];
94
- default: {
95
- const _exhaustive: never = tier;
96
- throw new CrewhausCloudError(`unhandled tier: ${String(_exhaustive)}`);
97
- }
98
- }
99
- }
100
-
101
- // ─── Kustomize overlay rendering ─────────────────────────────────────────────
102
-
103
- /** Builds a kustomization.yaml + chart-rendered manifests directory. */
104
- export function renderKustomizeOverlay(config: CloudConfig): {
105
- kustomization: string;
106
- manifests: Record<string, string>;
107
- } {
108
- const shapes = tierShapes(config.tier);
109
- const manifests: Record<string, string> = {};
110
- const resourceFiles: string[] = [];
111
-
112
- for (const shape of shapes) {
113
- const values: ChartValues = {
114
- ...defaultValues(),
115
- target: shape.target,
116
- replicas: shape.replicas,
117
- image: { ...defaultValues().image, tag: config.imageTag },
118
- };
119
- validateValues(values);
120
- const rendered = renderChart(values, `${config.clusterName}-${shape.target}`);
121
- for (const [name, body] of Object.entries(rendered)) {
122
- const fileName = `${shape.target}-${name}`;
123
- manifests[fileName] = body;
124
- resourceFiles.push(fileName);
125
- }
126
- }
127
-
128
- const kustomization = `# Generated by @crewhaus/crewhaus-cloud (Section 32) — do not edit by hand.
129
- apiVersion: kustomize.config.k8s.io/v1beta1
130
- kind: Kustomization
131
- namespace: ${config.clusterName}
132
- commonLabels:
133
- crewhaus.ai/cluster: ${config.clusterName}
134
- crewhaus.ai/provider: ${config.provider}
135
- crewhaus.ai/region: ${config.region}
136
- crewhaus.ai/tier: ${config.tier}
137
- resources:
138
- ${resourceFiles.map((f) => ` - ${f}`).join("\n")}
139
- `;
140
-
141
- return { kustomization, manifests };
142
- }
143
-
144
- // ─── Terraform module rendering ──────────────────────────────────────────────
145
-
146
- export function renderTerraformModule(config: CloudConfig): string {
147
- validateValues({
148
- ...defaultValues(),
149
- target: "managed",
150
- image: { ...defaultValues().image, tag: config.imageTag },
151
- });
152
- switch (config.provider) {
153
- case "aws":
154
- case "aws-localstack":
155
- return renderAwsTerraform(config);
156
- case "gcp":
157
- return renderGcpTerraform(config);
158
- case "azure":
159
- return renderAzureTerraform(config);
160
- default: {
161
- const _exhaustive: never = config.provider;
162
- throw new CrewhausCloudError(`unhandled provider: ${String(_exhaustive)}`);
163
- }
164
- }
165
- }
166
-
167
- function renderAwsTerraform(config: CloudConfig): string {
168
- const localstackBlock =
169
- config.provider === "aws-localstack"
170
- ? `\n # LocalStack overrides — wires every AWS service to the LocalStack endpoint
171
- endpoints {
172
- eks = var.localstack_endpoint
173
- rds = var.localstack_endpoint
174
- elasticache = var.localstack_endpoint
175
- s3 = var.localstack_endpoint
176
- ecr = var.localstack_endpoint
177
- }
178
- s3_use_path_style = true
179
- skip_credentials_validation = true
180
- skip_metadata_api_check = true
181
- skip_requesting_account_id = true`
182
- : "";
183
-
184
- return `# Generated by @crewhaus/crewhaus-cloud — Terraform module for ${config.provider} (${config.region})
185
- terraform {
186
- required_providers {
187
- aws = { source = "hashicorp/aws", version = "~> 5.0" }
188
- }
189
- }
190
-
191
- variable "localstack_endpoint" {
192
- type = string
193
- default = "http://localhost:4566"
194
- }
195
-
196
- provider "aws" {
197
- region = "${config.region}"${localstackBlock}
198
- }
199
-
200
- resource "aws_eks_cluster" "crewhaus" {
201
- name = "${config.clusterName}"
202
- role_arn = aws_iam_role.cluster.arn
203
- version = "1.30"
204
-
205
- vpc_config {
206
- subnet_ids = aws_subnet.crewhaus[*].id
207
- }
208
- }
209
-
210
- resource "aws_iam_role" "cluster" {
211
- name = "${config.clusterName}-cluster-role"
212
- assume_role_policy = jsonencode({
213
- Version = "2012-10-17"
214
- Statement = [{
215
- Effect = "Allow"
216
- Principal = { Service = "eks.amazonaws.com" }
217
- Action = "sts:AssumeRole"
218
- }]
219
- })
220
- }
221
-
222
- resource "aws_subnet" "crewhaus" {
223
- count = 2
224
- vpc_id = aws_vpc.crewhaus.id
225
- cidr_block = "10.0.\${count.index + 1}.0/24"
226
- availability_zone = "${config.region}a"
227
- }
228
-
229
- resource "aws_vpc" "crewhaus" {
230
- cidr_block = "10.0.0.0/16"
231
- tags = { Name = "${config.clusterName}-vpc" }
232
- }
233
-
234
- resource "aws_db_instance" "spec_registry" {
235
- identifier = "${config.clusterName}-spec-registry"
236
- engine = "postgres"
237
- engine_version = "15"
238
- instance_class = "db.t3.micro"
239
- allocated_storage = 20
240
- username = "crewhaus"
241
- password = var.spec_registry_password
242
- skip_final_snapshot = true
243
- }
244
-
245
- resource "aws_elasticache_cluster" "queue" {
246
- cluster_id = "${config.clusterName}-queue"
247
- engine = "redis"
248
- node_type = "cache.t3.micro"
249
- num_cache_nodes = 1
250
- parameter_group_name = "default.redis7"
251
- port = 6379
252
- }
253
-
254
- resource "aws_s3_bucket" "audit_log" {
255
- bucket = "${config.clusterName}-audit"
256
- }
257
-
258
- variable "spec_registry_password" {
259
- type = string
260
- sensitive = true
261
- }
262
-
263
- output "cluster_endpoint" {
264
- value = aws_eks_cluster.crewhaus.endpoint
265
- }
266
-
267
- output "audit_bucket" {
268
- value = aws_s3_bucket.audit_log.bucket
269
- }
270
- `;
271
- }
272
-
273
- function renderGcpTerraform(config: CloudConfig): string {
274
- return `# Generated by @crewhaus/crewhaus-cloud — Terraform module for GCP (${config.region})
275
- terraform {
276
- required_providers {
277
- google = { source = "hashicorp/google", version = "~> 5.0" }
278
- }
279
- }
280
-
281
- provider "google" {
282
- region = "${config.region}"
283
- }
284
-
285
- resource "google_container_cluster" "crewhaus" {
286
- name = "${config.clusterName}"
287
- location = "${config.region}"
288
-
289
- initial_node_count = 3
290
- remove_default_node_pool = false
291
- }
292
-
293
- resource "google_sql_database_instance" "spec_registry" {
294
- name = "${config.clusterName}-spec-registry"
295
- database_version = "POSTGRES_15"
296
- region = "${config.region}"
297
- settings { tier = "db-f1-micro" }
298
- }
299
-
300
- resource "google_storage_bucket" "audit_log" {
301
- name = "${config.clusterName}-audit"
302
- location = "${config.region}"
303
- force_destroy = true
304
- }
305
-
306
- output "cluster_endpoint" {
307
- value = google_container_cluster.crewhaus.endpoint
308
- }
309
- `;
310
- }
311
-
312
- function renderAzureTerraform(config: CloudConfig): string {
313
- return `# Generated by @crewhaus/crewhaus-cloud — Terraform module for Azure (${config.region})
314
- terraform {
315
- required_providers {
316
- azurerm = { source = "hashicorp/azurerm", version = "~> 3.0" }
317
- }
318
- }
319
-
320
- provider "azurerm" {
321
- features {}
322
- }
323
-
324
- resource "azurerm_resource_group" "crewhaus" {
325
- name = "${config.clusterName}"
326
- location = "${config.region}"
327
- }
328
-
329
- resource "azurerm_kubernetes_cluster" "crewhaus" {
330
- name = "${config.clusterName}"
331
- location = azurerm_resource_group.crewhaus.location
332
- resource_group_name = azurerm_resource_group.crewhaus.name
333
- dns_prefix = "${config.clusterName}"
334
-
335
- default_node_pool {
336
- name = "default"
337
- node_count = 3
338
- vm_size = "Standard_D2_v2"
339
- }
340
-
341
- identity { type = "SystemAssigned" }
342
- }
343
-
344
- resource "azurerm_postgresql_flexible_server" "spec_registry" {
345
- name = "${config.clusterName}-spec-registry"
346
- resource_group_name = azurerm_resource_group.crewhaus.name
347
- location = azurerm_resource_group.crewhaus.location
348
- administrator_login = "crewhaus"
349
- administrator_password = var.spec_registry_password
350
- version = "15"
351
- sku_name = "B_Standard_B1ms"
352
- storage_mb = 32768
353
- backup_retention_days = 7
354
- }
355
-
356
- variable "spec_registry_password" {
357
- type = string
358
- sensitive = true
359
- }
360
-
361
- output "cluster_fqdn" {
362
- value = azurerm_kubernetes_cluster.crewhaus.fqdn
363
- }
364
- `;
365
- }
366
-
367
- // ─── Deploy / teardown orchestration ─────────────────────────────────────────
368
-
369
- export type CloudRunner = (
370
- argv: readonly string[],
371
- cwd: string,
372
- ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
373
-
374
- export type DeployCloudOptions = {
375
- readonly config: CloudConfig;
376
- /** Working directory to write generated artefacts into; defaults to a temp dir. */
377
- readonly workingDir?: string;
378
- /** Override the terraform binary path; defaults to env TF_BIN or "terraform". */
379
- readonly tfBin?: string;
380
- /** Override the kubectl binary path. */
381
- readonly kubectlBin?: string;
382
- /** Test injection point. */
383
- readonly runner?: CloudRunner;
384
- };
385
-
386
- export type DeployCloudResult = {
387
- readonly workingDir: string;
388
- readonly steps: ReadonlyArray<{ readonly name: string; readonly skipped?: boolean }>;
389
- readonly outputs: Readonly<Record<string, string>>;
390
- };
391
-
392
- export async function deployCloud(opts: DeployCloudOptions): Promise<DeployCloudResult> {
393
- const { config } = opts;
394
- const workingDir = opts.workingDir ?? join(RECIPES_ROOT, ".out", config.clusterName);
395
- mkdirSync(join(workingDir, "terraform"), { recursive: true });
396
- mkdirSync(join(workingDir, "kustomize"), { recursive: true });
397
-
398
- // 1. Render the Terraform module + Kustomize overlay onto disk.
399
- const tfModule = renderTerraformModule(config);
400
- writeFileSync(join(workingDir, "terraform", "main.tf"), tfModule);
401
-
402
- const overlay = renderKustomizeOverlay(config);
403
- writeFileSync(join(workingDir, "kustomize", "kustomization.yaml"), overlay.kustomization);
404
- for (const [name, body] of Object.entries(overlay.manifests)) {
405
- writeFileSync(join(workingDir, "kustomize", name), body);
406
- }
407
-
408
- // 2. Run terraform apply (gated on tfBin existing). If unavailable, mark skipped.
409
- const tfBin = opts.tfBin ?? process.env["TF_BIN"] ?? "terraform";
410
- const runner = opts.runner;
411
- const steps: Array<{ name: string; skipped?: boolean }> = [];
412
- const outputs: Record<string, string> = {};
413
-
414
- if (runner) {
415
- const init = await runner([tfBin, "init"], join(workingDir, "terraform"));
416
- if (init.exitCode !== 0) {
417
- throw new CrewhausCloudError(`terraform init failed: ${init.stderr.slice(0, 1024)}`);
418
- }
419
- steps.push({ name: "terraform-init" });
420
-
421
- const apply = await runner(
422
- [tfBin, "apply", "-auto-approve", "-var", `spec_registry_password=${randomPassword()}`],
423
- join(workingDir, "terraform"),
424
- );
425
- if (apply.exitCode !== 0) {
426
- throw new CrewhausCloudError(`terraform apply failed: ${apply.stderr.slice(0, 1024)}`);
427
- }
428
- steps.push({ name: "terraform-apply" });
429
-
430
- const tfOut = await runner([tfBin, "output", "-json"], join(workingDir, "terraform"));
431
- if (tfOut.exitCode === 0) {
432
- try {
433
- const parsed = JSON.parse(tfOut.stdout) as Record<string, { value: string }>;
434
- for (const [k, v] of Object.entries(parsed)) {
435
- outputs[k] = v.value;
436
- }
437
- } catch {
438
- // Non-fatal — leave outputs empty.
439
- }
440
- }
441
-
442
- const kubectlBin = opts.kubectlBin ?? "kubectl";
443
- const kapply = await runner([kubectlBin, "apply", "-k", "."], join(workingDir, "kustomize"));
444
- if (kapply.exitCode !== 0) {
445
- throw new CrewhausCloudError(`kubectl apply failed: ${kapply.stderr.slice(0, 1024)}`);
446
- }
447
- steps.push({ name: "kubectl-apply" });
448
- } else {
449
- steps.push({ name: "terraform-init", skipped: true });
450
- steps.push({ name: "terraform-apply", skipped: true });
451
- steps.push({ name: "kubectl-apply", skipped: true });
452
- }
453
-
454
- return { workingDir, steps, outputs };
455
- }
456
-
457
- export async function teardownCloud(opts: DeployCloudOptions): Promise<void> {
458
- const tfBin = opts.tfBin ?? process.env["TF_BIN"] ?? "terraform";
459
- const workingDir = opts.workingDir ?? join(RECIPES_ROOT, ".out", opts.config.clusterName);
460
- if (!existsSync(workingDir)) {
461
- throw new CrewhausCloudError(
462
- `no working directory at ${workingDir} (was deployCloud ever run?)`,
463
- );
464
- }
465
- const runner = opts.runner;
466
- if (!runner) {
467
- return; // no-op when there's no runner available
468
- }
469
- const result = await runner(
470
- [tfBin, "destroy", "-auto-approve", "-var", `spec_registry_password=${randomPassword()}`],
471
- join(workingDir, "terraform"),
472
- );
473
- if (result.exitCode !== 0) {
474
- throw new CrewhausCloudError(`terraform destroy failed: ${result.stderr.slice(0, 1024)}`);
475
- }
476
- }
477
-
478
- function randomPassword(): string {
479
- // 24 hex chars — only used as a placeholder so the Terraform variable is set;
480
- // production users override via -var-file. Sourced from a CSPRNG so that, if
481
- // an operator does not override it, the provisioned DB admin credential is not
482
- // derived from a predictable Math.random() stream (CWE-338).
483
- return randomBytes(12).toString("hex");
484
- }
485
-
486
- /** Used by the CLI subcommand to enumerate provider choices. */
487
- export function listProviders(): readonly CloudProvider[] {
488
- return PROVIDERS;
489
- }
490
-
491
- /** Used by the CLI subcommand for input validation. */
492
- export function isCloudProvider(value: unknown): value is CloudProvider {
493
- return typeof value === "string" && (PROVIDERS as readonly string[]).includes(value);
494
- }
495
-
496
- /** Sanity check used by the smoke test. */
497
- export function summariseDeploy(result: DeployCloudResult): string {
498
- const lines = [`Working dir: ${result.workingDir}`];
499
- for (const step of result.steps) {
500
- lines.push(` ${step.skipped ? "skip" : "ok"} ${step.name}`);
501
- }
502
- for (const [k, v] of Object.entries(result.outputs)) {
503
- lines.push(` out ${k} = ${v}`);
504
- }
505
- return lines.join("\n");
506
- }
507
-
508
- export { isTargetShape };