@rulebricks/cli 2.1.6 → 2.3.1

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.
Files changed (114) hide show
  1. package/README.md +75 -14
  2. package/cluster-setup/aws/README.md +123 -0
  3. package/cluster-setup/aws/check-aws-access.sh +242 -0
  4. package/cluster-setup/aws/parameters.json +13 -0
  5. package/cluster-setup/aws/rulebricks-cluster.cfn.yaml +355 -0
  6. package/cluster-setup/azure/README.md +141 -0
  7. package/cluster-setup/azure/check-aks-prereqs.sh +276 -0
  8. package/cluster-setup/azure/parameters.json +30 -0
  9. package/cluster-setup/azure/rulebricks-cluster.bicep +546 -0
  10. package/cluster-setup/gcp/README.md +189 -0
  11. package/cluster-setup/gcp/check-gke-prereqs.sh +260 -0
  12. package/dist/commands/backup.d.ts +5 -0
  13. package/dist/commands/backup.js +104 -0
  14. package/dist/commands/deploy.d.ts +3 -1
  15. package/dist/commands/deploy.js +226 -326
  16. package/dist/commands/destroy.d.ts +1 -1
  17. package/dist/commands/destroy.js +73 -123
  18. package/dist/commands/init.d.ts +5 -1
  19. package/dist/commands/init.js +78 -47
  20. package/dist/commands/list.d.ts +1 -0
  21. package/dist/commands/list.js +74 -0
  22. package/dist/commands/open.d.ts +1 -1
  23. package/dist/commands/open.js +4 -12
  24. package/dist/commands/redeploy.d.ts +6 -0
  25. package/dist/commands/redeploy.js +310 -0
  26. package/dist/commands/restore.d.ts +5 -0
  27. package/dist/commands/restore.js +338 -0
  28. package/dist/commands/status.js +62 -49
  29. package/dist/commands/upgrade.js +74 -51
  30. package/dist/components/DNSWaitScreen.d.ts +5 -1
  31. package/dist/components/DNSWaitScreen.js +47 -41
  32. package/dist/components/Wizard/WizardContext.d.ts +174 -29
  33. package/dist/components/Wizard/WizardContext.js +896 -91
  34. package/dist/components/Wizard/steps/CloudProviderStep.js +192 -102
  35. package/dist/components/Wizard/steps/DomainStep.js +5 -24
  36. package/dist/components/Wizard/steps/ExternalServicesStep.d.ts +6 -0
  37. package/dist/components/Wizard/steps/ExternalServicesStep.js +645 -0
  38. package/dist/components/Wizard/steps/FeatureConfigStep.d.ts +2 -1
  39. package/dist/components/Wizard/steps/FeatureConfigStep.js +959 -248
  40. package/dist/components/Wizard/steps/FeaturesStep.js +31 -35
  41. package/dist/components/Wizard/steps/ObservabilityStep.d.ts +6 -0
  42. package/dist/components/Wizard/steps/ObservabilityStep.js +137 -0
  43. package/dist/components/Wizard/steps/ReviewStep.d.ts +2 -1
  44. package/dist/components/Wizard/steps/ReviewStep.js +56 -7
  45. package/dist/components/Wizard/steps/StorageStep.d.ts +9 -0
  46. package/dist/components/Wizard/steps/StorageStep.js +592 -0
  47. package/dist/components/Wizard/steps/SupabaseCredentialsStep.js +20 -21
  48. package/dist/components/Wizard/steps/VersionStep.js +45 -23
  49. package/dist/components/Wizard/steps/index.d.ts +3 -3
  50. package/dist/components/Wizard/steps/index.js +3 -3
  51. package/dist/components/common/CommandApproval.d.ts +12 -0
  52. package/dist/components/common/CommandApproval.js +91 -0
  53. package/dist/components/common/DeploymentPicker.d.ts +14 -0
  54. package/dist/components/common/DeploymentPicker.js +16 -0
  55. package/dist/components/common/index.d.ts +2 -0
  56. package/dist/components/common/index.js +2 -0
  57. package/dist/index.js +94 -62
  58. package/dist/lib/cloudCli.d.ts +134 -63
  59. package/dist/lib/cloudCli.js +512 -220
  60. package/dist/lib/clusterSetupDefaults.d.ts +30 -0
  61. package/dist/lib/clusterSetupDefaults.js +64 -0
  62. package/dist/lib/commandApproval.d.ts +26 -0
  63. package/dist/lib/commandApproval.js +114 -0
  64. package/dist/lib/config.d.ts +12 -10
  65. package/dist/lib/config.js +91 -33
  66. package/dist/lib/configFixtures.d.ts +5 -0
  67. package/dist/lib/configFixtures.js +513 -0
  68. package/dist/lib/deploymentHealth.d.ts +32 -0
  69. package/dist/lib/deploymentHealth.js +157 -0
  70. package/dist/lib/dns.d.ts +1 -1
  71. package/dist/lib/dns.js +19 -1
  72. package/dist/lib/dns.test.d.ts +1 -0
  73. package/dist/lib/dns.test.js +27 -0
  74. package/dist/lib/dockerHub.d.ts +12 -1
  75. package/dist/lib/dockerHub.js +18 -8
  76. package/dist/lib/helm.d.ts +4 -0
  77. package/dist/lib/helm.js +16 -0
  78. package/dist/lib/helmValues.d.ts +25 -0
  79. package/dist/lib/helmValues.js +1937 -259
  80. package/dist/lib/helmValues.test.d.ts +1 -0
  81. package/dist/lib/helmValues.test.js +966 -0
  82. package/dist/lib/htpasswd.d.ts +1 -0
  83. package/dist/lib/htpasswd.js +15 -0
  84. package/dist/lib/kubernetes.d.ts +126 -13
  85. package/dist/lib/kubernetes.js +624 -134
  86. package/dist/lib/secrets.d.ts +23 -0
  87. package/dist/lib/secrets.js +158 -0
  88. package/dist/lib/validateValues.d.ts +31 -0
  89. package/dist/lib/validateValues.js +253 -0
  90. package/dist/lib/versions.d.ts +82 -11
  91. package/dist/lib/versions.js +131 -31
  92. package/dist/lib/versions.test.d.ts +1 -0
  93. package/dist/lib/versions.test.js +81 -0
  94. package/dist/lib/wizardSteps.d.ts +14 -0
  95. package/dist/lib/wizardSteps.js +23 -0
  96. package/dist/lib/workloadIdentity.d.ts +26 -0
  97. package/dist/lib/workloadIdentity.js +323 -0
  98. package/dist/lib/workloadIdentity.test.d.ts +1 -0
  99. package/dist/lib/workloadIdentity.test.js +57 -0
  100. package/dist/types/index.d.ts +2152 -95
  101. package/dist/types/index.js +554 -286
  102. package/package.json +10 -4
  103. package/schema/values.schema.json +1934 -0
  104. package/dist/components/Wizard/steps/CredentialsStep.d.ts +0 -6
  105. package/dist/components/Wizard/steps/CredentialsStep.js +0 -22
  106. package/dist/components/Wizard/steps/DeploymentModeStep.d.ts +0 -5
  107. package/dist/components/Wizard/steps/DeploymentModeStep.js +0 -26
  108. package/dist/components/Wizard/steps/TierStep.d.ts +0 -6
  109. package/dist/components/Wizard/steps/TierStep.js +0 -29
  110. package/dist/lib/terraform.d.ts +0 -66
  111. package/dist/lib/terraform.js +0 -754
  112. package/terraform/aws/main.tf +0 -355
  113. package/terraform/azure/main.tf +0 -371
  114. package/terraform/gcp/main.tf +0 -407
@@ -2,11 +2,12 @@
2
2
  * Cloud CLI detection and dynamic resource listing
3
3
  *
4
4
  * Detects installed cloud CLIs (AWS, GCP, Azure), checks authentication status,
5
- * and provides functions to list regions and buckets dynamically.
5
+ * and provides functions to list regions, clusters, and storage dynamically.
6
6
  */
7
7
  import { exec } from "child_process";
8
8
  import { promisify } from "util";
9
9
  import { CLOUD_REGIONS } from "../types/index.js";
10
+ import { approveCloudCommandOrThrow } from "./commandApproval.js";
10
11
  const execAsync = promisify(exec);
11
12
  // Timeout for CLI commands (in ms)
12
13
  const CLI_TIMEOUT = 15000;
@@ -23,11 +24,17 @@ function sortRegionsByPriority(regions, provider) {
23
24
  const otherRegions = regions.filter((r) => !prioritySet.has(r)).sort();
24
25
  return [...priorityRegions, ...otherRegions];
25
26
  }
26
- /**
27
- * Execute a CLI command with timeout
28
- */
29
- async function execCommand(command, timeout = CLI_TIMEOUT) {
27
+ async function execCommand(command, options = {}) {
28
+ const opts = typeof options === "number" ? { timeout: options } : options;
29
+ const timeout = opts.timeout ?? CLI_TIMEOUT;
30
30
  try {
31
+ await approveCloudCommandOrThrow({
32
+ intent: opts.intent ?? inferCommandIntent(command),
33
+ command,
34
+ description: opts.description,
35
+ provider: opts.provider ?? inferProvider(command),
36
+ mutating: opts.mutating,
37
+ });
31
38
  const result = await execAsync(command, { timeout });
32
39
  return result;
33
40
  }
@@ -43,6 +50,53 @@ async function execCommand(command, timeout = CLI_TIMEOUT) {
43
50
  throw error;
44
51
  }
45
52
  }
53
+ function inferProvider(command) {
54
+ if (command.startsWith("aws "))
55
+ return "aws";
56
+ if (command.startsWith("gcloud "))
57
+ return "gcp";
58
+ if (command.startsWith("az "))
59
+ return "azure";
60
+ return undefined;
61
+ }
62
+ function inferCommandIntent(command) {
63
+ if (command.includes("--version") ||
64
+ command.includes("get-caller-identity") ||
65
+ command.includes("gcloud config list") ||
66
+ command.includes("az account show")) {
67
+ return "Detect cloud CLIs";
68
+ }
69
+ if (command.includes("describe-regions") ||
70
+ command.includes("compute regions list") ||
71
+ command.includes("list-locations")) {
72
+ return "List available regions";
73
+ }
74
+ if (command.includes("eks list-clusters") ||
75
+ command.includes("eks describe-cluster") ||
76
+ command.includes("container clusters list") ||
77
+ command.includes("az aks list")) {
78
+ return "Discover clusters";
79
+ }
80
+ if (command.includes("update-kubeconfig") ||
81
+ command.includes("get-credentials")) {
82
+ return "Refresh kubeconfig";
83
+ }
84
+ if (command.includes("s3api") ||
85
+ command.includes("storage buckets") ||
86
+ command.includes("storage account") ||
87
+ command.includes("storage container")) {
88
+ return "Discover storage resources";
89
+ }
90
+ if (command.includes("iam list-roles") ||
91
+ command.includes("service-accounts list") ||
92
+ command.includes("identity list")) {
93
+ return "List workload identities";
94
+ }
95
+ if (command.includes("amp ") || command.includes("monitor data-collection")) {
96
+ return "Discover monitoring destinations";
97
+ }
98
+ return "Run cloud CLI command";
99
+ }
46
100
  // ============================================================================
47
101
  // AWS CLI
48
102
  // ============================================================================
@@ -128,11 +182,11 @@ export async function listS3Buckets() {
128
182
  }
129
183
  }
130
184
  /**
131
- * Static fallback for AWS regions (c8g Graviton4 ARM64 available or expected)
185
+ * Static fallback for common AWS regions.
132
186
  */
133
187
  function getStaticAwsRegions() {
134
188
  return [
135
- // US regions (c8g Graviton4 available)
189
+ // US regions
136
190
  "us-east-1",
137
191
  "us-east-2",
138
192
  "us-west-1",
@@ -140,7 +194,7 @@ function getStaticAwsRegions() {
140
194
  // Canada
141
195
  "ca-central-1",
142
196
  "ca-west-1",
143
- // Europe (c8g available)
197
+ // Europe
144
198
  "eu-west-1",
145
199
  "eu-west-2",
146
200
  "eu-west-3",
@@ -149,7 +203,7 @@ function getStaticAwsRegions() {
149
203
  "eu-north-1",
150
204
  "eu-south-1",
151
205
  "eu-south-2",
152
- // Asia Pacific (c8g available)
206
+ // Asia Pacific
153
207
  "ap-northeast-1",
154
208
  "ap-northeast-2",
155
209
  "ap-northeast-3",
@@ -176,7 +230,10 @@ function getStaticAwsRegions() {
176
230
  */
177
231
  export async function listEksClusters(region) {
178
232
  try {
179
- const result = await execCommand(`aws eks list-clusters --region ${region} --output json`);
233
+ const result = await execCommand(`aws eks list-clusters --region ${region} --output json`, {
234
+ intent: `Discover clusters in ${region}`,
235
+ provider: "aws",
236
+ });
180
237
  if (result.stderr && !result.stdout) {
181
238
  return [];
182
239
  }
@@ -187,16 +244,135 @@ export async function listEksClusters(region) {
187
244
  return [];
188
245
  }
189
246
  }
247
+ /**
248
+ * Best-effort preflight for an external AWS Aurora Postgres cluster: Supabase
249
+ * Realtime needs logical replication (wal_level=logical), which on Aurora is the
250
+ * STATIC cluster parameter rds.logical_replication - it lives in a custom DB
251
+ * cluster parameter group and needs a reboot, so bootstrap.sql can't set it and
252
+ * Realtime crashloops without it. Parses the cluster id + region from the writer
253
+ * endpoint and reads the attached parameter group. Fails OPEN ("unknown") on any
254
+ * ambiguity (non-Aurora host, denied describe, unexpected value) so it never
255
+ * blocks a deploy spuriously.
256
+ */
257
+ export async function checkAuroraLogicalReplication(host, fallbackRegion) {
258
+ // <cluster>.cluster[-ro]-<hash>.<region>.rds.amazonaws.com
259
+ const match = /^([^.]+)\.cluster(?:-ro)?-[^.]+\.([^.]+)\.rds\.amazonaws\.com$/i.exec(host.trim());
260
+ if (!match)
261
+ return { status: "unknown" };
262
+ const clusterId = match[1];
263
+ const region = match[2] || fallbackRegion;
264
+ if (!region)
265
+ return { status: "unknown" };
266
+ try {
267
+ const pgRes = await execCommand(`aws rds describe-db-clusters --db-cluster-identifier ${clusterId} ` +
268
+ `--region ${region} --query "DBClusters[0].DBClusterParameterGroup" --output text`, {
269
+ intent: `Check Aurora logical replication (${clusterId})`,
270
+ provider: "aws",
271
+ });
272
+ const parameterGroup = pgRes.stdout.trim();
273
+ if (!parameterGroup || parameterGroup === "None") {
274
+ return { status: "unknown" };
275
+ }
276
+ const valRes = await execCommand(`aws rds describe-db-cluster-parameters ` +
277
+ `--db-cluster-parameter-group-name ${parameterGroup} --region ${region} ` +
278
+ `--query "Parameters[?ParameterName=='rds.logical_replication'].ParameterValue | [0]" ` +
279
+ `--output text`, { intent: "Read rds.logical_replication", provider: "aws" });
280
+ const value = valRes.stdout.trim();
281
+ if (value === "1")
282
+ return { status: "enabled", parameterGroup };
283
+ if (value === "0" || value === "" || value === "None") {
284
+ return { status: "disabled", parameterGroup };
285
+ }
286
+ return { status: "unknown", parameterGroup };
287
+ }
288
+ catch {
289
+ return { status: "unknown" };
290
+ }
291
+ }
292
+ async function describeEksCluster(name, region) {
293
+ try {
294
+ const result = await execCommand(`aws eks describe-cluster --name ${name} --region ${region} --query "cluster.{name:name,status:status,version:version}" --output json`, {
295
+ intent: `Discover clusters in ${region}`,
296
+ provider: "aws",
297
+ });
298
+ if (result.stderr && !result.stdout) {
299
+ return null;
300
+ }
301
+ const cluster = JSON.parse(result.stdout);
302
+ if (cluster.status !== "ACTIVE") {
303
+ return null;
304
+ }
305
+ return {
306
+ provider: "aws",
307
+ name: cluster.name,
308
+ region,
309
+ status: cluster.status,
310
+ version: cluster.version,
311
+ };
312
+ }
313
+ catch {
314
+ return null;
315
+ }
316
+ }
317
+ /**
318
+ * List EKS clusters across all accessible AWS regions.
319
+ */
320
+ export async function listAllEksClusters() {
321
+ const regions = await listAwsRegions();
322
+ const clustersByRegion = await Promise.all(regions.map(async (region) => {
323
+ const names = await listEksClusters(region);
324
+ return Promise.all(names.map((name) => describeEksCluster(name, region)));
325
+ }));
326
+ return clustersByRegion
327
+ .flat()
328
+ .filter((cluster) => cluster !== null)
329
+ .sort((a, b) => a.region.localeCompare(b.region) || a.name.localeCompare(b.name));
330
+ }
331
+ /**
332
+ * Discover active EKS clusters in one region.
333
+ */
334
+ export async function discoverEksClustersInRegion(region) {
335
+ const names = await listEksClusters(region);
336
+ const clusters = await Promise.all(names.map((name) => describeEksCluster(name, region)));
337
+ return clusters
338
+ .filter((cluster) => cluster !== null)
339
+ .sort((a, b) => a.name.localeCompare(b.name));
340
+ }
341
+ /**
342
+ * List IAM roles for selection (e.g. IRSA roles for S3 / AMP). Returns an empty
343
+ * list on any failure so callers can fall back to manual entry.
344
+ */
345
+ export async function listIamRoles() {
346
+ try {
347
+ const result = await execCommand('aws iam list-roles --query "Roles[].{name:RoleName,arn:Arn}" --output json');
348
+ if (result.stderr && !result.stdout) {
349
+ return [];
350
+ }
351
+ const roles = JSON.parse(result.stdout);
352
+ return roles.sort((a, b) => a.name.localeCompare(b.name));
353
+ }
354
+ catch {
355
+ return [];
356
+ }
357
+ }
358
+ /**
359
+ * Get the active AWS account ID (useful for constructing/validating ARNs).
360
+ */
361
+ export async function getAwsAccountId() {
362
+ try {
363
+ const result = await execCommand("aws sts get-caller-identity --query Account --output text");
364
+ const accountId = result.stdout.trim();
365
+ return accountId || null;
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
190
371
  // ============================================================================
191
372
  // GCP CLI (gcloud)
192
373
  // ============================================================================
193
374
  /**
194
- * Check if gcloud CLI is installed and fully authenticated
195
- *
196
- * For GCP to be considered "authenticated", the user must have:
197
- * 1. Logged in with `gcloud auth login`
198
- * 2. Set a default project with `gcloud config set project PROJECT_ID`
199
- * 3. Configured Application Default Credentials with `gcloud auth application-default login`
375
+ * Check if gcloud CLI is installed and authenticated enough to list clusters.
200
376
  */
201
377
  export async function checkGcloudCli() {
202
378
  const status = {
@@ -221,25 +397,15 @@ export async function checkGcloudCli() {
221
397
  const config = JSON.parse(configResult.stdout);
222
398
  const account = config.core?.account;
223
399
  const project = config.core?.project;
224
- // Step 1: Check if logged in
225
400
  if (!account) {
226
401
  status.error = 'Not authenticated - run "gcloud auth login"';
227
402
  return status;
228
403
  }
229
- // Step 2: Check if project is set
230
404
  if (!project) {
231
405
  status.error =
232
406
  'No default project set - run "gcloud config set project PROJECT_ID"';
233
407
  return status;
234
408
  }
235
- // Step 3: Check Application Default Credentials
236
- const adcResult = await checkGcpApplicationDefaultCredentials();
237
- if (!adcResult.configured) {
238
- status.error =
239
- 'Application Default Credentials not configured - run "gcloud auth application-default login"';
240
- return status;
241
- }
242
- // All checks passed
243
409
  status.authenticated = true;
244
410
  status.identity = `Project: ${project}`;
245
411
  }
@@ -265,39 +431,6 @@ export async function getGcpProjectId() {
265
431
  return null;
266
432
  }
267
433
  }
268
- /**
269
- * Check if GCP Application Default Credentials (ADC) are configured
270
- * ADC is required for Terraform to authenticate with Google Cloud
271
- */
272
- export async function checkGcpApplicationDefaultCredentials() {
273
- try {
274
- // Try to get an access token using ADC
275
- const result = await execCommand("gcloud auth application-default print-access-token");
276
- if (result.stdout && result.stdout.trim().length > 0) {
277
- return { configured: true };
278
- }
279
- return {
280
- configured: false,
281
- error: "Application Default Credentials not configured",
282
- };
283
- }
284
- catch (error) {
285
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
286
- // Check if it's specifically an authentication error
287
- if (errorMessage.includes("not found") ||
288
- errorMessage.includes("not configured") ||
289
- errorMessage.includes("Could not automatically determine credentials")) {
290
- return {
291
- configured: false,
292
- error: "Application Default Credentials not configured",
293
- };
294
- }
295
- return {
296
- configured: false,
297
- error: errorMessage,
298
- };
299
- }
300
- }
301
434
  /**
302
435
  * List available GCP regions
303
436
  */
@@ -335,33 +468,53 @@ export async function listGcsBuckets() {
335
468
  }
336
469
  }
337
470
  /**
338
- * Static fallback for GCP regions (C4A/Google Axion ARM64 confirmed availability)
339
- * Only includes regions with full C4A zone coverage for GKE regional clusters
471
+ * List GCP service accounts for selection (e.g. for GKE Workload Identity).
472
+ * Returns an empty list on any failure so callers can fall back to manual entry.
473
+ */
474
+ export async function listGcpServiceAccounts() {
475
+ try {
476
+ const result = await execCommand('gcloud iam service-accounts list --format="json(email,displayName)"');
477
+ if (result.stderr && !result.stdout) {
478
+ return [];
479
+ }
480
+ const accounts = JSON.parse(result.stdout);
481
+ return accounts
482
+ .map((account) => ({
483
+ email: account.email,
484
+ displayName: account.displayName,
485
+ }))
486
+ .sort((a, b) => a.email.localeCompare(b.email));
487
+ }
488
+ catch {
489
+ return [];
490
+ }
491
+ }
492
+ /**
493
+ * Static fallback for common GCP regions.
340
494
  */
341
495
  function getStaticGcpRegions() {
342
496
  return [
343
- // Tier 1: Full C4A (Google Axion ARM64) availability - 3+ zones confirmed
344
497
  // US regions
345
- "us-central1", // C4A in zones a, b, c, f (best availability)
346
- "us-east1", // C4A in zones b, c, d
347
- "us-east4", // C4A in zones a, b, c
348
- "us-west1", // C4A in zones a, b, c
349
- "us-west4", // C4A in zones a, b, c
498
+ "us-central1",
499
+ "us-east1",
500
+ "us-east4",
501
+ "us-west1",
502
+ "us-west4",
350
503
  // North America
351
- "northamerica-south1", // C4A in zones a, b, c (Mexico)
504
+ "northamerica-south1",
352
505
  // Europe
353
- "europe-west1", // C4A in zones b, c, d
354
- "europe-west2", // C4A in zones a, b, c
355
- "europe-west3", // C4A in zones a, b, c
356
- "europe-west4", // C4A in zones a, b, c
357
- "europe-north1", // C4A in zones a, b
506
+ "europe-west1",
507
+ "europe-west2",
508
+ "europe-west3",
509
+ "europe-west4",
510
+ "europe-north1",
358
511
  // Asia Pacific
359
- "asia-east1", // C4A in zones a, b, c
360
- "asia-northeast1", // C4A in zones b, c
361
- "asia-south1", // C4A in zones a, b, c
362
- "asia-southeast1", // C4A in zones a, b, c
512
+ "asia-east1",
513
+ "asia-northeast1",
514
+ "asia-south1",
515
+ "asia-southeast1",
363
516
  // Australia
364
- "australia-southeast2", // C4A in zones a, b, c
517
+ "australia-southeast2",
365
518
  ];
366
519
  }
367
520
  /**
@@ -371,7 +524,10 @@ function getStaticGcpRegions() {
371
524
  export async function listGkeClusters(region) {
372
525
  try {
373
526
  // List clusters in the specified region (includes both regional and zonal clusters in that region)
374
- const result = await execCommand(`gcloud container clusters list --region ${region} --format="json(name)" 2>/dev/null || gcloud container clusters list --filter="location~^${region}" --format="json(name)"`);
527
+ const result = await execCommand(`gcloud container clusters list --region ${region} --format="json(name)" 2>/dev/null || gcloud container clusters list --filter="location~^${region}" --format="json(name)"`, {
528
+ intent: `Discover clusters in ${region}`,
529
+ provider: "gcp",
530
+ });
375
531
  if (result.stderr && !result.stdout) {
376
532
  return [];
377
533
  }
@@ -382,17 +538,70 @@ export async function listGkeClusters(region) {
382
538
  return [];
383
539
  }
384
540
  }
541
+ /**
542
+ * List GKE clusters across the active GCP project.
543
+ */
544
+ export async function listAllGkeClusters() {
545
+ const projectId = await getGcpProjectId();
546
+ try {
547
+ const result = await execCommand('gcloud container clusters list --format="json(name,location,status,currentMasterVersion,currentNodeCount)"');
548
+ if (result.stderr && !result.stdout) {
549
+ return [];
550
+ }
551
+ const clusters = JSON.parse(result.stdout);
552
+ return clusters
553
+ .filter((cluster) => cluster.status === "RUNNING")
554
+ .map((cluster) => ({
555
+ provider: "gcp",
556
+ name: cluster.name,
557
+ region: cluster.location,
558
+ projectId: projectId || undefined,
559
+ status: cluster.status,
560
+ version: cluster.currentMasterVersion,
561
+ nodeCount: cluster.currentNodeCount,
562
+ }))
563
+ .sort((a, b) => a.region.localeCompare(b.region) || a.name.localeCompare(b.name));
564
+ }
565
+ catch {
566
+ return [];
567
+ }
568
+ }
569
+ /**
570
+ * Discover running GKE clusters in a selected region/location.
571
+ */
572
+ export async function discoverGkeClustersInRegion(region) {
573
+ const projectId = await getGcpProjectId();
574
+ try {
575
+ const result = await execCommand(`gcloud container clusters list --region ${region} --format="json(name,location,status,currentMasterVersion,currentNodeCount)" 2>/dev/null || gcloud container clusters list --filter="location~^${region}" --format="json(name,location,status,currentMasterVersion,currentNodeCount)"`, {
576
+ intent: `Discover clusters in ${region}`,
577
+ provider: "gcp",
578
+ });
579
+ if (result.stderr && !result.stdout) {
580
+ return [];
581
+ }
582
+ const clusters = JSON.parse(result.stdout);
583
+ return clusters
584
+ .filter((cluster) => cluster.status === "RUNNING")
585
+ .map((cluster) => ({
586
+ provider: "gcp",
587
+ name: cluster.name,
588
+ region: cluster.location,
589
+ projectId: projectId || undefined,
590
+ status: cluster.status,
591
+ version: cluster.currentMasterVersion,
592
+ nodeCount: cluster.currentNodeCount,
593
+ }))
594
+ .sort((a, b) => a.name.localeCompare(b.name));
595
+ }
596
+ catch {
597
+ return [];
598
+ }
599
+ }
385
600
  // ============================================================================
386
601
  // Azure CLI
387
602
  // ============================================================================
388
603
  /**
389
- * Check if Azure CLI is installed and fully authenticated
390
- *
391
- * For Azure to be considered "authenticated", the user must have:
392
- * 1. Logged in with `az login`
393
- * 2. An active subscription in "Enabled" state
394
- * 3. Required resource providers registered (Microsoft.ContainerService, etc.)
395
- * 4. Sufficient vCPU quota for at least the small tier (8 cores)
604
+ * Check if Azure CLI is installed and authenticated enough to list clusters.
396
605
  */
397
606
  export async function checkAzureCli() {
398
607
  const status = {
@@ -411,7 +620,6 @@ export async function checkAzureCli() {
411
620
  // Extract version (e.g., "azure-cli 2.51.0")
412
621
  const versionMatch = versionResult.stdout.match(/azure-cli\s+([\d.]+)/);
413
622
  status.version = versionMatch ? versionMatch[1] : undefined;
414
- // Step 1: Check authentication and subscription
415
623
  const accountResult = await execCommand("az account show --output json");
416
624
  if (accountResult.stderr && accountResult.stderr.includes("Please run")) {
417
625
  status.error = 'Not authenticated - run "az login"';
@@ -421,7 +629,6 @@ export async function checkAzureCli() {
421
629
  try {
422
630
  const account = JSON.parse(accountResult.stdout);
423
631
  subscriptionName = account.name;
424
- // Step 2: Check subscription state is Enabled
425
632
  if (account.state !== "Enabled") {
426
633
  status.error = `Subscription "${account.name}" is not enabled (state: ${account.state})`;
427
634
  return status;
@@ -434,23 +641,6 @@ export async function checkAzureCli() {
434
641
  status.error = "Failed to parse account info";
435
642
  return status;
436
643
  }
437
- // Step 3: Check required resource providers are registered
438
- const providerCheck = await checkAzureResourceProviders();
439
- if (!providerCheck.allRegistered) {
440
- status.error =
441
- `Resource providers not registered: ${providerCheck.missing.join(", ")}. ` +
442
- `Run: ${providerCheck.missing.map((p) => `az provider register --namespace ${p}`).join(" && ")}`;
443
- return status;
444
- }
445
- // Step 4: Check minimum vCPU quota (small tier = 8 cores)
446
- const quotaCheck = await checkAzureVmQuota(AZURE_DEFAULT_QUOTA_CHECK_REGION, AZURE_TIER_CORES.small);
447
- if (!quotaCheck.sufficient) {
448
- status.error =
449
- `Insufficient vCPU quota (${quotaCheck.available}/${quotaCheck.limit} available in ${AZURE_DEFAULT_QUOTA_CHECK_REGION}). ` +
450
- "Request increase at Azure portal > Subscriptions > Usage + quotas";
451
- return status;
452
- }
453
- // All checks passed
454
644
  status.authenticated = true;
455
645
  }
456
646
  catch (error) {
@@ -470,6 +660,99 @@ export async function getAzureSubscriptionId() {
470
660
  return null;
471
661
  }
472
662
  }
663
+ /**
664
+ * Get the active Azure tenant ID. Used to auto-fill workload-identity tenant
665
+ * fields so users don't have to look it up manually.
666
+ */
667
+ export async function getAzureTenantId() {
668
+ try {
669
+ const result = await execCommand("az account show --query tenantId --output tsv");
670
+ return result.stdout.trim() || null;
671
+ }
672
+ catch {
673
+ return null;
674
+ }
675
+ }
676
+ /**
677
+ * List Azure user-assigned managed identities for selection (workload identity
678
+ * client IDs). Returns an empty list on any failure so callers can fall back to
679
+ * manual entry.
680
+ */
681
+ export async function listAzureManagedIdentities() {
682
+ try {
683
+ const result = await execCommand('az identity list --query "[].{name:name,clientId:clientId,resourceGroup:resourceGroup}" --output json');
684
+ if (result.stderr && !result.stdout) {
685
+ return [];
686
+ }
687
+ const identities = JSON.parse(result.stdout);
688
+ return identities.sort((a, b) => a.name.localeCompare(b.name));
689
+ }
690
+ catch {
691
+ return [];
692
+ }
693
+ }
694
+ /**
695
+ * Discovers Azure Monitor Prometheus remote_write targets: every Data Collection
696
+ * Rule that ingests the Microsoft-PrometheusMetrics stream, paired with its Data
697
+ * Collection Endpoint's metrics-ingestion endpoint, assembled into the exact
698
+ * remote_write URL. Works for any DCR the caller can see (not just ones we made).
699
+ */
700
+ export async function listAzurePrometheusTargets() {
701
+ try {
702
+ const dceResult = await execCommand('az monitor data-collection endpoint list --query "[].{id:id,endpoint:metricsIngestion.endpoint}" --output json');
703
+ const dces = JSON.parse(dceResult.stdout || "[]");
704
+ const endpointById = new Map();
705
+ for (const dce of dces) {
706
+ if (dce.id && dce.endpoint) {
707
+ endpointById.set(dce.id.toLowerCase(), dce.endpoint);
708
+ }
709
+ }
710
+ const dcrResult = await execCommand('az monitor data-collection rule list --query "[].{name:name,immutableId:immutableId,dce:dataCollectionEndpointId,streams:dataFlows[].streams[]}" --output json');
711
+ const dcrs = JSON.parse(dcrResult.stdout || "[]");
712
+ const targets = [];
713
+ for (const dcr of dcrs) {
714
+ if (!dcr.immutableId || !dcr.dce)
715
+ continue;
716
+ if (!(dcr.streams || []).includes("Microsoft-PrometheusMetrics"))
717
+ continue;
718
+ const endpoint = endpointById.get(dcr.dce.toLowerCase());
719
+ if (!endpoint)
720
+ continue;
721
+ const url = `${endpoint.replace(/\/+$/, "")}/dataCollectionRules/${dcr.immutableId}/streams/Microsoft-PrometheusMetrics/api/v1/write?api-version=2023-04-24`;
722
+ targets.push({ name: dcr.name, url });
723
+ }
724
+ return targets.sort((a, b) => a.name.localeCompare(b.name));
725
+ }
726
+ catch {
727
+ return [];
728
+ }
729
+ }
730
+ /**
731
+ * Discovers AWS Managed Prometheus (AMP) workspaces in a region and assembles the
732
+ * remote_write URL (<prometheusEndpoint>api/v1/remote_write) for each.
733
+ */
734
+ export async function listAwsPrometheusWorkspaces(region) {
735
+ try {
736
+ const listResult = await execCommand(`aws amp list-workspaces --region ${region} --query "workspaces[].{id:workspaceId,alias:alias}" --output json`);
737
+ const workspaces = JSON.parse(listResult.stdout || "[]");
738
+ const targets = [];
739
+ for (const ws of workspaces) {
740
+ const descResult = await execCommand(`aws amp describe-workspace --workspace-id ${ws.id} --region ${region} --query "workspace.prometheusEndpoint" --output text`);
741
+ const endpoint = descResult.stdout.trim();
742
+ if (!endpoint || endpoint === "None")
743
+ continue;
744
+ const url = `${endpoint.replace(/\/+$/, "")}/api/v1/remote_write`;
745
+ targets.push({
746
+ name: ws.alias ? `${ws.alias} (${ws.id})` : ws.id,
747
+ url,
748
+ });
749
+ }
750
+ return targets.sort((a, b) => a.name.localeCompare(b.name));
751
+ }
752
+ catch {
753
+ return [];
754
+ }
755
+ }
473
756
  /**
474
757
  * List available Azure regions (locations)
475
758
  */
@@ -519,11 +802,11 @@ export async function listAzureBlobContainers(storageAccount) {
519
802
  }
520
803
  }
521
804
  /**
522
- * Static fallback for Azure regions (Dpsv5 ARM64 available or expected)
805
+ * Static fallback for common Azure regions.
523
806
  */
524
807
  function getStaticAzureRegions() {
525
808
  return [
526
- // US regions (Dpsv5 ARM64 available)
809
+ // US regions
527
810
  "eastus",
528
811
  "eastus2",
529
812
  "westus",
@@ -538,7 +821,7 @@ function getStaticAzureRegions() {
538
821
  "canadaeast",
539
822
  // South America
540
823
  "brazilsouth",
541
- // Europe (Dpsv5 available)
824
+ // Europe
542
825
  "northeurope",
543
826
  "westeurope",
544
827
  "uksouth",
@@ -594,101 +877,60 @@ export async function listAksClusters(resourceGroup) {
594
877
  }
595
878
  }
596
879
  /**
597
- * Required Azure resource providers for AKS deployment
598
- */
599
- const AZURE_REQUIRED_PROVIDERS = [
600
- "Microsoft.ContainerService", // For AKS
601
- "Microsoft.Network", // For VNets, NSGs
602
- "Microsoft.ManagedIdentity", // For managed identities
603
- "Microsoft.Compute", // For VMs
604
- ];
605
- /**
606
- * Azure tier to vCPU core requirements mapping
607
- */
608
- export const AZURE_TIER_CORES = {
609
- small: 8, // 4 nodes × 2 vCPU
610
- medium: 16, // 4 nodes × 4 vCPU
611
- large: 40, // 5 nodes × 8 vCPU
612
- };
613
- /**
614
- * Default region used for baseline quota checks when region is not yet selected
615
- */
616
- const AZURE_DEFAULT_QUOTA_CHECK_REGION = "eastus";
617
- /**
618
- * Check if required Azure resource providers are registered
880
+ * List AKS clusters across the active Azure subscription.
619
881
  */
620
- export async function checkAzureResourceProviders() {
621
- const missing = [];
882
+ export async function listAllAksClusters() {
622
883
  try {
623
- for (const provider of AZURE_REQUIRED_PROVIDERS) {
624
- const result = await execCommand(`az provider show --namespace ${provider} --query "registrationState" --output tsv`);
625
- const state = result.stdout.trim();
626
- if (state !== "Registered") {
627
- missing.push(provider);
628
- }
884
+ const result = await execCommand('az aks list --query "[].{name:name,resourceGroup:resourceGroup,location:location,kubernetesVersion:kubernetesVersion,powerState:powerState,agentPoolProfiles:agentPoolProfiles}" --output json');
885
+ if (result.stderr && !result.stdout) {
886
+ return [];
629
887
  }
630
- return {
631
- allRegistered: missing.length === 0,
632
- missing,
633
- };
888
+ const clusters = JSON.parse(result.stdout);
889
+ return clusters
890
+ .filter((cluster) => cluster.powerState?.code === "Running")
891
+ .map((cluster) => ({
892
+ provider: "azure",
893
+ name: cluster.name,
894
+ region: cluster.location,
895
+ resourceGroup: cluster.resourceGroup,
896
+ status: cluster.powerState?.code,
897
+ version: cluster.kubernetesVersion,
898
+ nodeCount: cluster.agentPoolProfiles?.reduce((sum, pool) => sum + (pool.count || 0), 0),
899
+ }))
900
+ .sort((a, b) => a.region.localeCompare(b.region) || a.name.localeCompare(b.name));
634
901
  }
635
902
  catch {
636
- // If we can't check, assume they're not registered
637
- return {
638
- allRegistered: false,
639
- missing: AZURE_REQUIRED_PROVIDERS,
640
- };
903
+ return [];
641
904
  }
642
905
  }
643
906
  /**
644
- * Check Azure VM quota for a specific region
645
- *
646
- * @param region - Azure region to check quota for
647
- * @param requiredCores - Number of vCPUs required
648
- * @returns Quota check result with availability info
907
+ * Discover running AKS clusters in a selected Azure location.
649
908
  */
650
- export async function checkAzureVmQuota(region, requiredCores) {
909
+ export async function discoverAksClustersInRegion(region) {
651
910
  try {
652
- const result = await execCommand(`az vm list-usage --location ${region} --output json`);
911
+ const result = await execCommand('az aks list --query "[].{name:name,resourceGroup:resourceGroup,location:location,kubernetesVersion:kubernetesVersion,powerState:powerState,agentPoolProfiles:agentPoolProfiles}" --output json', {
912
+ intent: `Discover clusters in ${region}`,
913
+ provider: "azure",
914
+ });
653
915
  if (result.stderr && !result.stdout) {
654
- return {
655
- sufficient: false,
656
- available: 0,
657
- limit: 0,
658
- used: 0,
659
- error: "Failed to check VM quota",
660
- };
661
- }
662
- const usageList = JSON.parse(result.stdout);
663
- // Find total regional vCPU quota
664
- const regionalQuota = usageList.find((u) => u.name.value === "cores" || u.name.localizedValue === "Total Regional vCPUs");
665
- if (!regionalQuota) {
666
- return {
667
- sufficient: false,
668
- available: 0,
669
- limit: 0,
670
- used: 0,
671
- error: "Could not find regional vCPU quota",
672
- };
916
+ return [];
673
917
  }
674
- const used = regionalQuota.currentValue;
675
- const limit = regionalQuota.limit;
676
- const available = limit - used;
677
- return {
678
- sufficient: available >= requiredCores,
679
- available,
680
- limit,
681
- used,
682
- };
918
+ const clusters = JSON.parse(result.stdout);
919
+ return clusters
920
+ .filter((cluster) => cluster.location === region && cluster.powerState?.code === "Running")
921
+ .map((cluster) => ({
922
+ provider: "azure",
923
+ name: cluster.name,
924
+ region: cluster.location,
925
+ resourceGroup: cluster.resourceGroup,
926
+ status: cluster.powerState?.code,
927
+ version: cluster.kubernetesVersion,
928
+ nodeCount: cluster.agentPoolProfiles?.reduce((sum, pool) => sum + (pool.count || 0), 0),
929
+ }))
930
+ .sort((a, b) => a.name.localeCompare(b.name));
683
931
  }
684
- catch (error) {
685
- return {
686
- sufficient: false,
687
- available: 0,
688
- limit: 0,
689
- used: 0,
690
- error: error instanceof Error ? error.message : "Failed to check VM quota",
691
- };
932
+ catch {
933
+ return [];
692
934
  }
693
935
  }
694
936
  // ============================================================================
@@ -752,6 +994,86 @@ export async function listClusters(provider, region, options) {
752
994
  return [];
753
995
  }
754
996
  }
997
+ /**
998
+ * List managed Kubernetes clusters discoverable through a provider CLI.
999
+ */
1000
+ export async function listManagedClusters(provider) {
1001
+ switch (provider) {
1002
+ case "aws":
1003
+ return listAllEksClusters();
1004
+ case "gcp":
1005
+ return listAllGkeClusters();
1006
+ case "azure":
1007
+ return listAllAksClusters();
1008
+ default:
1009
+ return [];
1010
+ }
1011
+ }
1012
+ /**
1013
+ * List managed Kubernetes clusters discoverable through a provider CLI in a
1014
+ * selected region/location. This is used by init to avoid account-wide fan-out.
1015
+ */
1016
+ export async function discoverClustersInRegion(provider, region) {
1017
+ switch (provider) {
1018
+ case "aws":
1019
+ return discoverEksClustersInRegion(region);
1020
+ case "gcp":
1021
+ return discoverGkeClustersInRegion(region);
1022
+ case "azure":
1023
+ return discoverAksClustersInRegion(region);
1024
+ default:
1025
+ return [];
1026
+ }
1027
+ }
1028
+ /**
1029
+ * Refresh kubeconfig credentials for a selected managed Kubernetes cluster.
1030
+ */
1031
+ export async function updateKubeconfig(provider, clusterName, region, options = {}) {
1032
+ switch (provider) {
1033
+ case "aws":
1034
+ {
1035
+ const result = await execCommand(`aws eks update-kubeconfig --name ${clusterName} --region ${region}`, {
1036
+ timeout: 30000,
1037
+ intent: `Refresh kubeconfig for ${clusterName}`,
1038
+ provider: "aws",
1039
+ mutating: true,
1040
+ });
1041
+ if (result.stderr && !result.stdout)
1042
+ throw new Error(result.stderr);
1043
+ }
1044
+ return;
1045
+ case "gcp":
1046
+ if (!options.gcpProjectId) {
1047
+ throw new Error("GCP project ID is required to refresh kubeconfig");
1048
+ }
1049
+ {
1050
+ const result = await execCommand(`gcloud container clusters get-credentials ${clusterName} --location ${region} --project ${options.gcpProjectId}`, {
1051
+ timeout: 30000,
1052
+ intent: `Refresh kubeconfig for ${clusterName}`,
1053
+ provider: "gcp",
1054
+ mutating: true,
1055
+ });
1056
+ if (result.stderr && !result.stdout)
1057
+ throw new Error(result.stderr);
1058
+ }
1059
+ return;
1060
+ case "azure":
1061
+ if (!options.azureResourceGroup) {
1062
+ throw new Error("Azure resource group is required to refresh kubeconfig");
1063
+ }
1064
+ {
1065
+ const result = await execCommand(`az aks get-credentials --name ${clusterName} --resource-group ${options.azureResourceGroup} --overwrite-existing`, {
1066
+ timeout: 30000,
1067
+ intent: `Refresh kubeconfig for ${clusterName}`,
1068
+ provider: "azure",
1069
+ mutating: true,
1070
+ });
1071
+ if (result.stderr && !result.stdout)
1072
+ throw new Error(result.stderr);
1073
+ }
1074
+ return;
1075
+ }
1076
+ }
755
1077
  /**
756
1078
  * Get installation URLs for cloud CLIs
757
1079
  */
@@ -780,42 +1102,12 @@ export const CLI_LOGIN_COMMANDS = {
780
1102
  gcp: [
781
1103
  "gcloud auth login",
782
1104
  "gcloud config set project PROJECT_ID",
783
- "gcloud auth application-default login",
784
1105
  ],
785
1106
  azure: [
786
1107
  "az login",
787
1108
  "az account set --subscription YOUR_SUBSCRIPTION_ID",
788
- "az provider register --namespace Microsoft.ContainerService",
789
1109
  ],
790
1110
  };
791
- /**
792
- * Check if Terraform is installed
793
- */
794
- export async function checkTerraform() {
795
- try {
796
- const result = await execCommand("terraform --version");
797
- if (result.stderr && !result.stdout) {
798
- return { installed: false, error: "Terraform not found" };
799
- }
800
- // Extract version (e.g., "Terraform v1.5.0")
801
- const versionMatch = result.stdout.match(/Terraform v([\d.]+)/);
802
- return {
803
- installed: true,
804
- version: versionMatch ? versionMatch[1] : undefined,
805
- };
806
- }
807
- catch {
808
- return { installed: false, error: "Terraform not found" };
809
- }
810
- }
811
- /**
812
- * Terraform installation info
813
- */
814
- export const TERRAFORM_INSTALL_INFO = {
815
- name: "Terraform",
816
- url: "https://developer.hashicorp.com/terraform/downloads",
817
- installCmd: "brew install terraform",
818
- };
819
1111
  // ============================================================================
820
1112
  // Region-filtered bucket listing
821
1113
  // ============================================================================