@crewhaus/crewhaus-cloud 0.1.0

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