@crewhaus/crewhaus-cloud 0.1.4 → 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.
- package/dist/index.d.ts +86 -0
- package/dist/index.js +423 -0
- package/package.json +11 -8
- package/src/index.test.ts +0 -320
- package/src/index.ts +0 -508
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
"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": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
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.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
17
|
-
"@crewhaus/helm-chart": "0.1.
|
|
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": ["
|
|
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 };
|