@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 +43 -0
- package/src/index.test.ts +239 -0
- package/src/index.ts +505 -0
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 };
|