@meshxdata/fops 0.1.44 → 0.1.46

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 (36) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/package.json +1 -1
  3. package/src/commands/lifecycle.js +101 -5
  4. package/src/commands/setup.js +45 -4
  5. package/src/plugins/bundled/fops-plugin-azure/index.js +29 -0
  6. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +1185 -0
  7. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +1180 -0
  8. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-ingress.js +393 -0
  9. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +104 -0
  10. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-network.js +296 -0
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +768 -0
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-reconcilers.js +538 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-secrets.js +849 -0
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-stacks.js +643 -0
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-state.js +145 -0
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +496 -0
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-terraform.js +1032 -0
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +155 -4245
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +186 -0
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +29 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +78 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
  23. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +758 -0
  24. package/src/plugins/bundled/fops-plugin-azure/lib/commands/registry-cmds.js +250 -0
  25. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +52 -1
  26. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +10 -0
  27. package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +3 -2
  28. package/src/plugins/bundled/fops-plugin-foundation/lib/helpers.js +21 -0
  29. package/src/plugins/bundled/fops-plugin-foundation/lib/tools-read.js +3 -5
  30. package/src/ui/tui/App.js +13 -13
  31. package/src/web/dist/assets/index-NXC8Hvnp.css +1 -0
  32. package/src/web/dist/assets/index-QH1N4ejK.js +112 -0
  33. package/src/web/dist/index.html +2 -2
  34. package/src/web/server.js +4 -4
  35. package/src/web/dist/assets/index-BphVaAUd.css +0 -1
  36. package/src/web/dist/assets/index-CSckLzuG.js +0 -129
@@ -337,6 +337,57 @@ export function registerInfraCommands(azure) {
337
337
  await keyvaultStatus({ profile: opts.profile });
338
338
  });
339
339
 
340
+ // ── Key Vault Network commands ────────────────────────────────────────────
341
+ const kvNetwork = keyvault
342
+ .command("network")
343
+ .description("Configure Key Vault network access (VNet, firewall)");
344
+
345
+ kvNetwork
346
+ .command("show", { isDefault: true })
347
+ .description("Show network configuration for a Key Vault")
348
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
349
+ .option("--profile <subscription>", "Azure subscription name or ID")
350
+ .action(async (opts) => {
351
+ const { networkShow } = await import("../azure-keyvault.js");
352
+ await networkShow({ vault: opts.vault, profile: opts.profile });
353
+ });
354
+
355
+ kvNetwork
356
+ .command("add-vnet")
357
+ .description("Add VNet/subnet access rule (also adds service endpoint)")
358
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
359
+ .requiredOption("--vnet <name>", "Virtual network name")
360
+ .requiredOption("--subnet <name>", "Subnet name")
361
+ .option("--resource-group <name>", "VNet resource group (if different from Key Vault)")
362
+ .option("--profile <subscription>", "Azure subscription name or ID")
363
+ .action(async (opts) => {
364
+ const { networkAddVnet } = await import("../azure-keyvault.js");
365
+ await networkAddVnet({
366
+ vault: opts.vault, vnet: opts.vnet, subnet: opts.subnet,
367
+ resourceGroup: opts.resourceGroup, profile: opts.profile,
368
+ });
369
+ });
370
+
371
+ kvNetwork
372
+ .command("private")
373
+ .description("Set Key Vault to private (deny public access, allow VNets)")
374
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
375
+ .option("--profile <subscription>", "Azure subscription name or ID")
376
+ .action(async (opts) => {
377
+ const { networkPrivate } = await import("../azure-keyvault.js");
378
+ await networkPrivate({ vault: opts.vault, profile: opts.profile });
379
+ });
380
+
381
+ kvNetwork
382
+ .command("public")
383
+ .description("Set Key Vault to public (allow all networks)")
384
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
385
+ .option("--profile <subscription>", "Azure subscription name or ID")
386
+ .action(async (opts) => {
387
+ const { networkPublic } = await import("../azure-keyvault.js");
388
+ await networkPublic({ vault: opts.vault, profile: opts.profile });
389
+ });
390
+
340
391
  const kvSecret = keyvault
341
392
  .command("secret")
342
393
  .description("Manage secrets in a Key Vault");
@@ -506,9 +557,23 @@ export function registerInfraCommands(azure) {
506
557
  .option("--flux-path <path>", "Path in repo for cluster manifests (default: clusters/<name>)")
507
558
  .option("--flux-branch <branch>", "Git branch for Flux (default: main)")
508
559
  .option("--github-token <token>", "GitHub PAT for Flux + GHCR pull (default: $GITHUB_TOKEN)")
560
+ .option("--template-repo <repo>", "Template repo name (default: platform-flux-template)")
561
+ .option("--template-owner <owner>", "Template repo owner (default: meshxdata)")
562
+ .option("--template-branch <branch>", "Template repo branch (default: main)")
563
+ .option("--environment <env>", "Environment: demo | staging | live (default: demo)")
564
+ .option("--keyvault-url <url>", "Override KeyVault URL")
509
565
  .option("--no-flux", "Skip Flux bootstrap")
566
+ .option("--no-template", "Skip template rendering, use legacy Flux bootstrap")
567
+ .option("--no-commit", "Render template locally only, don't push to flux repo")
568
+ .option("--dry-run", "Show what would be committed without actually committing")
510
569
  .option("--no-postgres", "Skip Postgres Flexible Server provisioning")
570
+ .option("--managed-kafka", "Use Azure Event Hubs (Kafka) instead of in-cluster Kafka")
511
571
  .option("--dai", "Include DAI (Dashboards AI) workloads")
572
+ .option("--zones", "Enable availability zone redundancy (default when nodes >= 3)")
573
+ .option("--no-zones", "Disable availability zone redundancy")
574
+ .option("--geo-replica", "Create Postgres geo-replica for DR (default when in UAE)")
575
+ .option("--no-geo-replica", "Skip Postgres geo-replica creation")
576
+ .option("--reprovision", "Re-render and push cluster template (for existing clusters)")
512
577
  .action(async (name, opts) => {
513
578
  const { aksUp } = await import("../azure-aks.js");
514
579
  await aksUp({
@@ -522,9 +587,19 @@ export function registerInfraCommands(azure) {
522
587
  fluxRepo: opts.fluxRepo, fluxOwner: opts.fluxOwner,
523
588
  fluxPath: opts.fluxPath, fluxBranch: opts.fluxBranch,
524
589
  githubToken: opts.githubToken,
590
+ templateRepo: opts.templateRepo, templateOwner: opts.templateOwner,
591
+ templateBranch: opts.templateBranch, environment: opts.environment,
592
+ keyvaultUrl: opts.keyvaultUrl,
525
593
  noFlux: opts.flux === false,
594
+ noTemplate: opts.template === false,
595
+ noCommit: opts.commit === false,
596
+ dryRun: opts.dryRun === true,
526
597
  noPostgres: opts.postgres === false,
598
+ managedKafka: opts.managedKafka === true,
527
599
  dai: opts.dai === true,
600
+ zones: opts.zones,
601
+ geoReplica: opts.geoReplica,
602
+ reprovision: opts.reprovision === true,
528
603
  });
529
604
  });
530
605
 
@@ -556,6 +631,16 @@ export function registerInfraCommands(azure) {
556
631
  await aksStatus({ clusterName: name, profile: opts.profile });
557
632
  });
558
633
 
634
+ aks
635
+ .command("doctor [name]")
636
+ .description("Diagnose and fix common AKS cluster issues (GHCR secrets, SecretStore, etc.)")
637
+ .option("--profile <subscription>", "Azure subscription name or ID")
638
+ .option("--github-token <token>", "GitHub PAT for GHCR pull secret (default: $GITHUB_TOKEN)")
639
+ .action(async (name, opts) => {
640
+ const { aksDoctor } = await import("../azure-aks.js");
641
+ await aksDoctor({ clusterName: name, profile: opts.profile, githubToken: opts.githubToken });
642
+ });
643
+
559
644
  const aksConfig = aks
560
645
  .command("config")
561
646
  .description("Manage service versions on the AKS cluster");
@@ -773,10 +858,20 @@ export function registerInfraCommands(azure) {
773
858
  await aksKubeconfig({ clusterName: name, profile: opts.profile, admin: opts.admin });
774
859
  });
775
860
 
861
+ aks
862
+ .command("whitelist-me [name]")
863
+ .description("Add your current public IP to the AKS API server authorized IP ranges")
864
+ .option("--profile <subscription>", "Azure subscription name or ID")
865
+ .action(async (name, opts) => {
866
+ const { aksWhitelistMe } = await import("../azure-aks.js");
867
+ await aksWhitelistMe({ clusterName: name, profile: opts.profile });
868
+ });
869
+
776
870
  aks
777
871
  .command("bootstrap [clusterName]")
778
872
  .description("Create demo data mesh on the cluster (same as fops bootstrap, targeting AKS backend)")
779
873
  .option("--api-url <url>", "Foundation backend API URL (e.g. https://foundation.example.com/api)")
874
+ .option("--bearer-token <token>", "Bearer token for API authentication")
780
875
  .option("--yes", "Use credentials from env or ~/.fops.json, skip prompt")
781
876
  .option("--profile <subscription>", "Azure subscription name or ID")
782
877
  .action(async (clusterName, opts) => {
@@ -785,10 +880,165 @@ export function registerInfraCommands(azure) {
785
880
  clusterName,
786
881
  profile: opts.profile,
787
882
  apiUrl: opts.apiUrl,
883
+ bearerToken: opts.bearerToken,
788
884
  yes: opts.yes,
789
885
  });
790
886
  });
791
887
 
888
+ // Note: locust command group is defined later in the file
889
+ // This block is kept for reference but disabled to avoid duplicate command error
890
+ // Use: fops azure aks locust run [name]
891
+ const _locustRunCmd = aks
892
+ .command("locust-run [name]", { hidden: true })
893
+ .description("Run Locust load tests against an AKS cluster (use 'locust run' instead)")
894
+ .option("-u, --users <n>", "Number of concurrent users", "10")
895
+ .option("-r, --spawn-rate <n>", "User spawn rate per second", "2")
896
+ .option("-t, --run-time <duration>", "Run time (e.g. 60s, 5m)", "30s")
897
+ .option("--bearer-token <token>", "Bearer token for API authentication")
898
+ .option("--cf-client-id <id>", "Cloudflare Access client ID")
899
+ .option("--cf-client-secret <secret>", "Cloudflare Access client secret")
900
+ .option("--web", "Start web UI instead of CLI mode")
901
+ .option("--web-port <port>", "Web UI port", "8089")
902
+ .action(async (name, opts) => {
903
+ const { readClusterState, clusterDomain } = await import("../azure-aks.js");
904
+ const { resolveCliSrc } = await import("../azure-helpers.js");
905
+ const { rootDir } = await import(resolveCliSrc("project.js"));
906
+ const fsp = await import("node:fs/promises");
907
+ const path = await import("node:path");
908
+
909
+ const cluster = readClusterState(name);
910
+ if (!cluster?.clusterName) {
911
+ console.error(chalk.red(`\n No AKS cluster tracked: "${name || "(none active)"}"`));
912
+ console.error(chalk.dim(" Create one: fops azure aks up <name>\n"));
913
+ process.exit(1);
914
+ }
915
+
916
+ const domain = cluster.domain || clusterDomain(cluster.clusterName);
917
+ const apiUrl = `https://api.${domain}`;
918
+
919
+ const root = rootDir();
920
+ if (!root) {
921
+ console.error(chalk.red("\n Foundation project root not found.\n"));
922
+ process.exit(1);
923
+ }
924
+
925
+ const qaDir = path.join(root, "foundation-qa-automation");
926
+ const locustFile = path.join(qaDir, "locustfile.py");
927
+ try { await fsp.access(locustFile); } catch {
928
+ console.error(chalk.red("\n locustfile.py not found in foundation-qa-automation/"));
929
+ process.exit(1);
930
+ }
931
+
932
+ const { execa: execaFn } = await import("execa");
933
+
934
+ const os = await import("node:os");
935
+
936
+ // Read .env file for credentials
937
+ let envCreds = {};
938
+ try {
939
+ const envPath = path.join(root, ".env");
940
+ const envContent = await fsp.readFile(envPath, "utf8");
941
+ for (const line of envContent.split("\n")) {
942
+ const match = line.match(/^(CF_ACCESS_CLIENT_ID|CF_ACCESS_CLIENT_SECRET|BEARER_TOKEN)=(.*)$/);
943
+ if (match) envCreds[match[1]] = match[2].trim().replace(/^["']|["']$/g, "");
944
+ }
945
+ } catch { /* no .env */ }
946
+
947
+ // Read ~/.fops.json for credentials
948
+ let fopsCreds = {};
949
+ try {
950
+ const fopsPath = path.join(os.homedir(), ".fops.json");
951
+ const fopsContent = JSON.parse(await fsp.readFile(fopsPath, "utf8"));
952
+ const cfg = fopsContent?.plugins?.entries?.["fops-plugin-foundation"]?.config || fopsContent || {};
953
+ fopsCreds = {
954
+ CF_ACCESS_CLIENT_ID: cfg.cfAccessClientId || cfg.CF_ACCESS_CLIENT_ID,
955
+ CF_ACCESS_CLIENT_SECRET: cfg.cfAccessClientSecret || cfg.CF_ACCESS_CLIENT_SECRET,
956
+ BEARER_TOKEN: cfg.bearerToken || cfg.BEARER_TOKEN,
957
+ };
958
+ } catch { /* no fops.json */ }
959
+
960
+ // Resolve Cloudflare Access credentials (from options, env, .env, fops.json, or cluster state)
961
+ let cfClientId = opts.cfClientId || process.env.CF_ACCESS_CLIENT_ID || envCreds.CF_ACCESS_CLIENT_ID || fopsCreds.CF_ACCESS_CLIENT_ID || cluster.cfAccessClientId || "";
962
+ let cfClientSecret = opts.cfClientSecret || process.env.CF_ACCESS_CLIENT_SECRET || envCreds.CF_ACCESS_CLIENT_SECRET || fopsCreds.CF_ACCESS_CLIENT_SECRET || cluster.cfAccessClientSecret || "";
963
+
964
+ // Use explicit token if provided, otherwise resolve via auth chain
965
+ let bearerToken = opts.bearerToken || process.env.BEARER_TOKEN || envCreds.BEARER_TOKEN || fopsCreds.BEARER_TOKEN || "";
966
+ let qaUser = "compose@meshx.io";
967
+
968
+ if (cfClientId && cfClientSecret) {
969
+ console.log(chalk.green(` ✓ Using Cloudflare Access credentials`));
970
+ } else if (bearerToken) {
971
+ console.log(chalk.green(` ✓ Using provided bearer token`));
972
+ } else {
973
+ const { listVms: lv, sshCmd: sc, knockForVm: kfv } = await import("../azure.js");
974
+ const { vms: allVms } = lv();
975
+ const vmEntry = Object.entries(allVms).find(([, v]) => v.publicIp);
976
+
977
+ console.log(chalk.dim(` Authenticating against ${apiUrl}…`));
978
+ const auth = await resolveRemoteAuth({
979
+ apiUrl,
980
+ ip: vmEntry?.[1]?.publicIp,
981
+ vmState: vmEntry?.[1],
982
+ execaFn, sshCmd: sc, knockForVm: kfv, suppressTlsWarning,
983
+ });
984
+ bearerToken = auth.bearerToken;
985
+ qaUser = auth.qaUser || qaUser;
986
+ }
987
+
988
+ if (!bearerToken && !cfClientId) {
989
+ console.error(chalk.red("\n No credentials found."));
990
+ console.error(chalk.dim(" Use --bearer-token, --cf-client-id/--cf-client-secret, or set CF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRET env vars\n"));
991
+ process.exit(1);
992
+ }
993
+
994
+ const locustEnv = {
995
+ ...process.env,
996
+ OWNER_EMAIL: qaUser || "compose@meshx.io",
997
+ };
998
+ if (bearerToken) locustEnv.BEARER_TOKEN = bearerToken;
999
+ if (cfClientId) locustEnv.CF_ACCESS_CLIENT_ID = cfClientId;
1000
+ if (cfClientSecret) locustEnv.CF_ACCESS_CLIENT_SECRET = cfClientSecret;
1001
+
1002
+ // Ensure venv + deps (including locust)
1003
+ try { await fsp.access(path.join(qaDir, "venv")); } catch {
1004
+ console.log(chalk.cyan(" Setting up QA automation environment…"));
1005
+ await execaFn("python3", ["-m", "venv", "venv"], { cwd: qaDir, stdio: "inherit" });
1006
+ await execaFn("bash", ["-c", "source venv/bin/activate && pip install -r requirements.txt"], { cwd: qaDir, stdio: "inherit" });
1007
+ }
1008
+
1009
+ // Ensure locust is installed
1010
+ const locustCheck = await execaFn("bash", ["-c", "source venv/bin/activate && python -c 'import locust'"], { cwd: qaDir, reject: false });
1011
+ if (locustCheck.exitCode !== 0) {
1012
+ console.log(chalk.cyan(" Installing locust…"));
1013
+ await execaFn("bash", ["-c", "source venv/bin/activate && pip install locust"], { cwd: qaDir, stdio: "inherit" });
1014
+ }
1015
+
1016
+ const locustArgs = [
1017
+ "-f", locustFile,
1018
+ "--host", apiUrl,
1019
+ ];
1020
+
1021
+ if (opts.web) {
1022
+ locustArgs.push("--web-port", opts.webPort);
1023
+ console.log(chalk.cyan(`\n Starting Locust web UI at http://localhost:${opts.webPort}`));
1024
+ console.log(chalk.dim(` Target: ${apiUrl}\n`));
1025
+ } else {
1026
+ locustArgs.push("--headless", "-u", opts.users, "-r", opts.spawnRate, "-t", opts.runTime);
1027
+ console.log(chalk.cyan(`\n Locust: ${opts.users} users, ${opts.spawnRate}/s, ${opts.runTime}`));
1028
+ console.log(chalk.dim(` Target: ${apiUrl}\n`));
1029
+ }
1030
+
1031
+ const locustCmd = `source venv/bin/activate && locust ${locustArgs.join(" ")}`;
1032
+ const proc = execaFn("bash", ["-c", locustCmd], {
1033
+ cwd: qaDir,
1034
+ env: locustEnv,
1035
+ stdio: "inherit",
1036
+ reject: false,
1037
+ });
1038
+
1039
+ await proc;
1040
+ });
1041
+
792
1042
  // ── Node pool management ───────────────────────────────────────────────
793
1043
 
794
1044
  const nodePool = aks
@@ -806,6 +1056,7 @@ export function registerInfraCommands(azure) {
806
1056
  .option("--labels <labels>", "Node labels (key=value pairs)")
807
1057
  .option("--taints <taints>", "Node taints (key=value:effect)")
808
1058
  .option("--max-pods <count>", "Max pods per node")
1059
+ .option("--zones <zones>", "Availability zones (e.g. 1,2,3)")
809
1060
  .action(async (clusterName, opts) => {
810
1061
  const { aksNodePoolAdd } = await import("../azure-aks.js");
811
1062
  await aksNodePoolAdd({
@@ -814,6 +1065,7 @@ export function registerInfraCommands(azure) {
814
1065
  nodeVmSize: opts.nodeVmSize, mode: opts.mode,
815
1066
  labels: opts.labels, taints: opts.taints,
816
1067
  maxPods: opts.maxPods ? Number(opts.maxPods) : undefined,
1068
+ zones: opts.zones,
817
1069
  });
818
1070
  });
819
1071
 
@@ -827,6 +1079,73 @@ export function registerInfraCommands(azure) {
827
1079
  await aksNodePoolRemove({ clusterName, profile: opts.profile, poolName: opts.poolName });
828
1080
  });
829
1081
 
1082
+ // ── Postgres subcommands (replicas, HA) ─────────────────────────────────
1083
+
1084
+ const postgres = aks
1085
+ .command("postgres")
1086
+ .description("Manage PostgreSQL Flexible Server (replicas, HA)");
1087
+
1088
+ const pgReplica = postgres
1089
+ .command("replica")
1090
+ .description("Manage cross-region read replicas for disaster recovery");
1091
+
1092
+ pgReplica
1093
+ .command("create [clusterName]")
1094
+ .description("Create a read replica in another region (UAE → EU)")
1095
+ .option("--profile <subscription>", "Azure subscription name or ID")
1096
+ .option("--region <region>", "Target region for replica (default: westeurope)")
1097
+ .option("--replica-name <name>", "Custom replica name")
1098
+ .action(async (clusterName, opts) => {
1099
+ const { aksPostgresReplicaCreate } = await import("../azure-aks.js");
1100
+ await aksPostgresReplicaCreate({
1101
+ clusterName,
1102
+ profile: opts.profile,
1103
+ region: opts.region,
1104
+ replicaName: opts.replicaName,
1105
+ });
1106
+ });
1107
+
1108
+ pgReplica
1109
+ .command("list [clusterName]")
1110
+ .description("List all read replicas")
1111
+ .option("--profile <subscription>", "Azure subscription name or ID")
1112
+ .action(async (clusterName, opts) => {
1113
+ const { aksPostgresReplicaList } = await import("../azure-aks.js");
1114
+ await aksPostgresReplicaList({ clusterName, profile: opts.profile });
1115
+ });
1116
+
1117
+ pgReplica
1118
+ .command("promote [clusterName]")
1119
+ .description("Promote replica to standalone (DR failover)")
1120
+ .requiredOption("--replica-name <name>", "Replica name to promote")
1121
+ .option("--profile <subscription>", "Azure subscription name or ID")
1122
+ .option("--yes", "Skip confirmation")
1123
+ .action(async (clusterName, opts) => {
1124
+ const { aksPostgresReplicaPromote } = await import("../azure-aks.js");
1125
+ await aksPostgresReplicaPromote({
1126
+ clusterName,
1127
+ profile: opts.profile,
1128
+ replicaName: opts.replicaName,
1129
+ yes: opts.yes,
1130
+ });
1131
+ });
1132
+
1133
+ pgReplica
1134
+ .command("delete [clusterName]")
1135
+ .description("Delete a read replica")
1136
+ .requiredOption("--replica-name <name>", "Replica name to delete")
1137
+ .option("--profile <subscription>", "Azure subscription name or ID")
1138
+ .option("--yes", "Skip confirmation")
1139
+ .action(async (clusterName, opts) => {
1140
+ const { aksPostgresReplicaDelete } = await import("../azure-aks.js");
1141
+ await aksPostgresReplicaDelete({
1142
+ clusterName,
1143
+ profile: opts.profile,
1144
+ replicaName: opts.replicaName,
1145
+ yes: opts.yes,
1146
+ });
1147
+ });
1148
+
830
1149
  // ── Flux subcommands ───────────────────────────────────────────────────
831
1150
 
832
1151
  const flux = aks
@@ -887,4 +1206,443 @@ export function registerInfraCommands(azure) {
887
1206
  const { aksFluxReconcile } = await import("../azure-aks.js");
888
1207
  await aksFluxReconcile({ clusterName, source: opts.source });
889
1208
  });
1209
+
1210
+ // ── Vault management ────────────────────────────────────────────────────
1211
+
1212
+ const vault = aks
1213
+ .command("vault")
1214
+ .description("Manage HashiCorp Vault on the AKS cluster");
1215
+
1216
+ vault
1217
+ .command("init [clusterName]")
1218
+ .description("Bootstrap Vault with Azure Key Vault auto-unseal")
1219
+ .option("--profile <subscription>", "Azure subscription name or ID")
1220
+ .action(async (clusterName, opts) => {
1221
+ const { aksVaultInit } = await import("../azure-aks-secrets.js");
1222
+ await aksVaultInit({ clusterName, profile: opts.profile });
1223
+ });
1224
+
1225
+ // ── Stack management ────────────────────────────────────────────────────
1226
+
1227
+ const stack = aks
1228
+ .command("stack")
1229
+ .description("Manage Foundation stacks in cluster (multi-tenant namespaces)");
1230
+
1231
+ stack
1232
+ .command("up <namespace>")
1233
+ .description("Deploy a Foundation stack to a namespace")
1234
+ .option("--cluster <name>", "Target cluster (default: active cluster)")
1235
+ .option("--profile <subscription>", "Azure subscription name or ID")
1236
+ .action(async (namespace, opts) => {
1237
+ const { aksStackUp } = await import("../azure-aks.js");
1238
+ await aksStackUp({
1239
+ namespace,
1240
+ clusterName: opts.cluster,
1241
+ profile: opts.profile,
1242
+ });
1243
+ });
1244
+
1245
+ stack
1246
+ .command("down <namespace>")
1247
+ .description("Remove a Foundation stack from a namespace")
1248
+ .option("--cluster <name>", "Target cluster (default: active cluster)")
1249
+ .option("--profile <subscription>", "Azure subscription name or ID")
1250
+ .option("--yes", "Skip confirmation prompt")
1251
+ .action(async (namespace, opts) => {
1252
+ const { aksStackDown } = await import("../azure-aks.js");
1253
+ await aksStackDown({
1254
+ namespace,
1255
+ clusterName: opts.cluster,
1256
+ profile: opts.profile,
1257
+ yes: opts.yes,
1258
+ });
1259
+ });
1260
+
1261
+ stack
1262
+ .command("list")
1263
+ .description("List all deployed stacks in a cluster")
1264
+ .option("--cluster <name>", "Target cluster (default: active cluster)")
1265
+ .option("--profile <subscription>", "Azure subscription name or ID")
1266
+ .action(async (opts) => {
1267
+ const { aksStackList } = await import("../azure-aks.js");
1268
+ await aksStackList({
1269
+ clusterName: opts.cluster,
1270
+ profile: opts.profile,
1271
+ });
1272
+ });
1273
+
1274
+ stack
1275
+ .command("status <namespace>")
1276
+ .description("Show status of a deployed stack")
1277
+ .option("--cluster <name>", "Target cluster (default: active cluster)")
1278
+ .option("--profile <subscription>", "Azure subscription name or ID")
1279
+ .action(async (namespace, opts) => {
1280
+ const { aksStackStatus } = await import("../azure-aks.js");
1281
+ await aksStackStatus({
1282
+ namespace,
1283
+ clusterName: opts.cluster,
1284
+ profile: opts.profile,
1285
+ });
1286
+ });
1287
+
1288
+ // ── Locust load testing ───────────────────────────────────────────────────
1289
+
1290
+ const locust = aks
1291
+ .command("locust")
1292
+ .description("Run Locust load tests against Foundation clusters");
1293
+
1294
+ locust
1295
+ .command("demo")
1296
+ .description("Run Locust load test against demo.meshx.app")
1297
+ .option("--users <count>", "Number of concurrent users (default: 10)", "10")
1298
+ .option("--spawn-rate <rate>", "Users spawned per second (default: 2)", "2")
1299
+ .option("--run-time <duration>", "Test duration e.g. 1m, 5m, 30s (default: 1m)", "1m")
1300
+ .option("--headless", "Run without web UI (default: true)", true)
1301
+ .option("--web", "Run with web UI on http://localhost:8089")
1302
+ .option("--html <file>", "Generate HTML report to file")
1303
+ .action(async (opts) => {
1304
+ const { resolveCliSrc } = await import("../azure-helpers.js");
1305
+ const { rootDir } = await import(resolveCliSrc("project.js"));
1306
+ const fsp = await import("node:fs/promises");
1307
+ const path = await import("node:path");
1308
+
1309
+ const root = rootDir();
1310
+ if (!root) {
1311
+ console.error(chalk.red("\n Foundation project root not found. Run from the compose repo or set FOUNDATION_ROOT.\n"));
1312
+ process.exit(1);
1313
+ }
1314
+
1315
+ const qaDir = path.join(root, "foundation-qa-automation");
1316
+ const locustFile = path.join(qaDir, "locustfile.py");
1317
+ try { await fsp.access(locustFile); } catch {
1318
+ console.error(chalk.red("\n locustfile.py not found in foundation-qa-automation/"));
1319
+ console.error(chalk.dim(" Expected: " + locustFile + "\n"));
1320
+ process.exit(1);
1321
+ }
1322
+
1323
+ const apiUrl = "https://demo.meshx.app/api";
1324
+ console.log(chalk.dim(` Authenticating against ${apiUrl}…`));
1325
+
1326
+ const { execa: execaFn } = await import("execa");
1327
+ const { listVms: lv, sshCmd: sc, knockForVm: kfv } = await import("../azure.js");
1328
+ const { vms: allVms } = lv();
1329
+ const vmEntry = Object.entries(allVms).find(([, v]) => v.publicIp);
1330
+ const ip = vmEntry?.[1]?.publicIp;
1331
+ const vmState = vmEntry?.[1];
1332
+
1333
+ const auth = await resolveRemoteAuth({
1334
+ apiUrl, ip, vmState,
1335
+ execaFn, sshCmd: sc, knockForVm: kfv, suppressTlsWarning,
1336
+ });
1337
+ const { bearerToken, qaUser, qaPass } = auth;
1338
+
1339
+ if (!bearerToken && !qaUser) {
1340
+ console.error(chalk.red("\n No credentials found (local or remote)."));
1341
+ console.error(chalk.dim(" Set BEARER_TOKEN or QA_USERNAME/QA_PASSWORD in env or ~/.fops.json\n"));
1342
+ process.exit(1);
1343
+ }
1344
+
1345
+ // Resolve tokens from qa-automation .env file
1346
+ let cfAccessClientId = process.env.CF_ACCESS_CLIENT_ID || "";
1347
+ let cfAccessClientSecret = process.env.CF_ACCESS_CLIENT_SECRET || "";
1348
+ let envBearerToken = "";
1349
+
1350
+ try {
1351
+ const qaEnv = await fsp.readFile(path.join(qaDir, ".env"), "utf8");
1352
+ for (const line of qaEnv.split("\n")) {
1353
+ const cfMatch = line.match(/^CF_ACCESS_CLIENT_(ID|SECRET)=(.+)$/);
1354
+ if (cfMatch?.[1] === "ID") cfAccessClientId = cfMatch[2].trim().replace(/^["']|["']$/g, "");
1355
+ if (cfMatch?.[1] === "SECRET") cfAccessClientSecret = cfMatch[2].trim().replace(/^["']|["']$/g, "");
1356
+ const tokenMatch = line.match(/^(TOKEN_AUTH0|BEARER_TOKEN)=(.+)$/);
1357
+ if (tokenMatch && !envBearerToken) envBearerToken = tokenMatch[2].trim().replace(/^["']|["']$/g, "");
1358
+ }
1359
+ if (cfAccessClientId) console.log(chalk.dim(" ✓ Found CF Access tokens in qa .env"));
1360
+ if (envBearerToken) console.log(chalk.dim(" ✓ Found bearer token in qa .env"));
1361
+ } catch { /* no .env */ }
1362
+
1363
+ // Use token from .env if available, otherwise use Auth0 token
1364
+ const finalToken = envBearerToken || bearerToken;
1365
+
1366
+ // Fall back to SSH fetching from VM
1367
+ if (!cfAccessClientId && ip) {
1368
+ try {
1369
+ const sshUser = vmState?.adminUser || "azureuser";
1370
+ const { stdout: cfOut } = await sc(execaFn, ip, sshUser,
1371
+ "grep -E '^CF_ACCESS_CLIENT_(ID|SECRET)=' /opt/foundation-compose/.env",
1372
+ 10_000,
1373
+ );
1374
+ for (const line of (cfOut || "").split("\n")) {
1375
+ const m = line.match(/^CF_ACCESS_CLIENT_(ID|SECRET)=(.+)$/);
1376
+ if (m?.[1] === "ID") cfAccessClientId = m[2].trim();
1377
+ if (m?.[1] === "SECRET") cfAccessClientSecret = m[2].trim();
1378
+ }
1379
+ if (cfAccessClientId) console.log(chalk.dim(" ✓ Retrieved CF Access tokens from VM"));
1380
+ } catch { /* no CF tokens on VM */ }
1381
+ }
1382
+
1383
+ // Ensure venv + locust
1384
+ const venvPath = path.join(qaDir, "venv");
1385
+ try { await fsp.access(venvPath); } catch {
1386
+ console.log(chalk.cyan(" Setting up Python virtual environment…"));
1387
+ await execaFn("python3", ["-m", "venv", "venv"], { cwd: qaDir, stdio: "inherit" });
1388
+ }
1389
+
1390
+ // Check/install locust
1391
+ const checkLocust = await execaFn("bash", ["-c", "source venv/bin/activate && pip show locust"], { cwd: qaDir, reject: false });
1392
+ if (checkLocust.exitCode !== 0) {
1393
+ console.log(chalk.cyan(" Installing locust…"));
1394
+ await execaFn("bash", ["-c", "source venv/bin/activate && pip install locust"], { cwd: qaDir, stdio: "inherit" });
1395
+ }
1396
+
1397
+ // Pass token + CF Access tokens
1398
+ const locustEnv = {
1399
+ ...process.env,
1400
+ BEARER_TOKEN: finalToken || "",
1401
+ CF_ACCESS_CLIENT_ID: cfAccessClientId,
1402
+ CF_ACCESS_CLIENT_SECRET: cfAccessClientSecret,
1403
+ };
1404
+
1405
+ const locustArgs = ["-f", locustFile, "--host", apiUrl];
1406
+
1407
+ if (opts.web) {
1408
+ console.log(chalk.cyan(`\n Starting Locust web UI at http://localhost:8089`));
1409
+ console.log(chalk.dim(` Target: ${apiUrl}\n`));
1410
+ } else {
1411
+ locustArgs.push("--headless");
1412
+ locustArgs.push("--users", opts.users);
1413
+ locustArgs.push("--spawn-rate", opts.spawnRate);
1414
+ locustArgs.push("--run-time", opts.runTime);
1415
+ console.log(chalk.cyan(`\n Running Locust load test against demo.meshx.app`));
1416
+ console.log(chalk.dim(` Users: ${opts.users}, Spawn rate: ${opts.spawnRate}/s, Duration: ${opts.runTime}\n`));
1417
+ }
1418
+
1419
+ if (opts.html) {
1420
+ locustArgs.push("--html", opts.html);
1421
+ }
1422
+
1423
+ const locustProc = execaFn(
1424
+ "bash",
1425
+ ["-c", `source venv/bin/activate && locust ${locustArgs.map(a => `"${a}"`).join(" ")}`],
1426
+ { cwd: qaDir, stdio: "inherit", env: locustEnv, reject: false },
1427
+ );
1428
+
1429
+ const result = await locustProc;
1430
+ if (result.signal === "SIGINT") {
1431
+ console.log(chalk.yellow("\n Locust test interrupted\n"));
1432
+ } else {
1433
+ // Locust exits 1 if there were failed requests, but this is normal for load testing
1434
+ console.log(chalk.green("\n ✓ Locust test completed\n"));
1435
+ if (opts.html) {
1436
+ console.log(chalk.dim(` Report: ${opts.html}\n`));
1437
+ }
1438
+ }
1439
+ });
1440
+
1441
+ locust
1442
+ .command("run [clusterName]")
1443
+ .description("Run Locust load test against an AKS cluster")
1444
+ .option("--users <count>", "Number of concurrent users (default: 10)", "10")
1445
+ .option("--spawn-rate <rate>", "Users spawned per second (default: 2)", "2")
1446
+ .option("--run-time <duration>", "Test duration e.g. 1m, 5m, 30s (default: 1m)", "1m")
1447
+ .option("--headless", "Run without web UI (default: true)", true)
1448
+ .option("--web", "Run with web UI on http://localhost:8089")
1449
+ .option("--html <file>", "Generate HTML report to file")
1450
+ .option("--profile <subscription>", "Azure subscription name or ID")
1451
+ .action(async (name, opts) => {
1452
+ const { readClusterState, clusterDomain } = await import("../azure-aks.js");
1453
+ const { resolveCliSrc } = await import("../azure-helpers.js");
1454
+ const { rootDir } = await import(resolveCliSrc("project.js"));
1455
+ const fsp = await import("node:fs/promises");
1456
+ const path = await import("node:path");
1457
+
1458
+ const cluster = readClusterState(name);
1459
+ if (!cluster?.clusterName) {
1460
+ console.error(chalk.red(`\n No AKS cluster tracked: "${name || "(none active)"}"`));
1461
+ console.error(chalk.dim(" Create one: fops azure aks up <name>\n"));
1462
+ process.exit(1);
1463
+ }
1464
+
1465
+ const domain = cluster.domain || clusterDomain(cluster.clusterName);
1466
+ const apiUrl = `https://${domain}/api`;
1467
+
1468
+ const root = rootDir();
1469
+ if (!root) {
1470
+ console.error(chalk.red("\n Foundation project root not found.\n"));
1471
+ process.exit(1);
1472
+ }
1473
+
1474
+ const qaDir = path.join(root, "foundation-qa-automation");
1475
+ const locustFile = path.join(qaDir, "locustfile.py");
1476
+ try { await fsp.access(locustFile); } catch {
1477
+ console.error(chalk.red("\n locustfile.py not found in foundation-qa-automation/\n"));
1478
+ process.exit(1);
1479
+ }
1480
+
1481
+ console.log(chalk.dim(` Authenticating against ${apiUrl}…`));
1482
+
1483
+ const { execa: execaFn } = await import("execa");
1484
+ const { listVms: lv, sshCmd: sc, knockForVm: kfv } = await import("../azure.js");
1485
+ const { vms: allVms } = lv();
1486
+ const vmEntry = Object.entries(allVms).find(([, v]) => v.publicIp);
1487
+
1488
+ const auth = await resolveRemoteAuth({
1489
+ apiUrl,
1490
+ ip: vmEntry?.[1]?.publicIp,
1491
+ vmState: vmEntry?.[1],
1492
+ execaFn, sshCmd: sc, knockForVm: kfv, suppressTlsWarning,
1493
+ });
1494
+ const { bearerToken, qaUser, qaPass } = auth;
1495
+
1496
+ if (!bearerToken && !qaUser) {
1497
+ console.error(chalk.red("\n No credentials found.\n"));
1498
+ process.exit(1);
1499
+ }
1500
+
1501
+ const venvPath = path.join(qaDir, "venv");
1502
+ try { await fsp.access(venvPath); } catch {
1503
+ console.log(chalk.cyan(" Setting up Python virtual environment…"));
1504
+ await execaFn("python3", ["-m", "venv", "venv"], { cwd: qaDir, stdio: "inherit" });
1505
+ }
1506
+
1507
+ const checkLocust = await execaFn("bash", ["-c", "source venv/bin/activate && pip show locust"], { cwd: qaDir, reject: false });
1508
+ if (checkLocust.exitCode !== 0) {
1509
+ console.log(chalk.cyan(" Installing locust…"));
1510
+ await execaFn("bash", ["-c", "source venv/bin/activate && pip install locust"], { cwd: qaDir, stdio: "inherit" });
1511
+ }
1512
+
1513
+ // Pass credentials only - Locust authenticates via /iam/login
1514
+ const locustEnv = { ...process.env, QA_USERNAME: qaUser || "", QA_PASSWORD: qaPass || "" };
1515
+ delete locustEnv.BEARER_TOKEN;
1516
+
1517
+ const locustArgs = ["-f", locustFile, "--host", apiUrl];
1518
+
1519
+ if (opts.web) {
1520
+ console.log(chalk.cyan(`\n Starting Locust web UI at http://localhost:8089`));
1521
+ console.log(chalk.dim(` Target: ${apiUrl}\n`));
1522
+ } else {
1523
+ locustArgs.push("--headless");
1524
+ locustArgs.push("--users", opts.users);
1525
+ locustArgs.push("--spawn-rate", opts.spawnRate);
1526
+ locustArgs.push("--run-time", opts.runTime);
1527
+ console.log(chalk.cyan(`\n Running Locust load test against ${cluster.clusterName}`));
1528
+ console.log(chalk.dim(` Users: ${opts.users}, Spawn rate: ${opts.spawnRate}/s, Duration: ${opts.runTime}\n`));
1529
+ }
1530
+
1531
+ if (opts.html) {
1532
+ locustArgs.push("--html", opts.html);
1533
+ }
1534
+
1535
+ const locustProc = execaFn(
1536
+ "bash",
1537
+ ["-c", `source venv/bin/activate && locust ${locustArgs.map(a => `"${a}"`).join(" ")}`],
1538
+ { cwd: qaDir, stdio: "inherit", env: locustEnv },
1539
+ );
1540
+
1541
+ try {
1542
+ await locustProc;
1543
+ console.log(chalk.green("\n ✓ Locust test completed\n"));
1544
+ } catch (err) {
1545
+ if (err.signal === "SIGINT") {
1546
+ console.log(chalk.yellow("\n Locust test interrupted\n"));
1547
+ } else {
1548
+ console.error(chalk.red(`\n Locust test failed: ${err.message}\n`));
1549
+ process.exitCode = 1;
1550
+ }
1551
+ }
1552
+ });
1553
+
1554
+ // ── Grant subcommands ─────────────────────────────────────────────────────
1555
+
1556
+ const grant = aks
1557
+ .command("grant")
1558
+ .description("Grant permissions to users");
1559
+
1560
+ grant
1561
+ .command("admin [clusterName]")
1562
+ .description("Grant admin role to a user in the Foundation backend")
1563
+ .requiredOption("--email <email>", "User email to grant admin")
1564
+ .option("--profile <subscription>", "Azure subscription name or ID")
1565
+ .action(async (clusterName, opts) => {
1566
+ const { aksGrantAdmin } = await import("../azure-aks.js");
1567
+ await aksGrantAdmin({
1568
+ clusterName,
1569
+ profile: opts.profile,
1570
+ email: opts.email,
1571
+ });
1572
+ });
1573
+
1574
+ // ── Agent (Claude Code with cluster context) ─────────────────────────────────
1575
+
1576
+ aks
1577
+ .command("agent [clusterName]")
1578
+ .description("Launch Claude Code agent with cluster context")
1579
+ .option("--profile <subscription>", "Azure subscription name or ID")
1580
+ .option("--port-forward", "Port-forward backend API to localhost:65432")
1581
+ .action(async (clusterName, opts) => {
1582
+ const { readClusterState, requireCluster, clusterDomain } = await import("../azure-aks.js");
1583
+ const { execa } = await import("execa");
1584
+ const path = await import("node:path");
1585
+
1586
+ const { clusterName: name } = requireCluster(clusterName);
1587
+ const state = readClusterState(name);
1588
+ if (!state) {
1589
+ console.error(chalk.red(`\n Cluster "${name}" not found. Run: fops azure aks list\n`));
1590
+ process.exit(1);
1591
+ }
1592
+
1593
+ // Ensure kubeconfig is available
1594
+ console.log(chalk.dim(` Ensuring kubeconfig for ${name}…`));
1595
+ const { getCredentials } = await import("../azure-aks-core.js");
1596
+ await getCredentials(execa, {
1597
+ clusterName: name,
1598
+ rg: state.resourceGroup,
1599
+ sub: opts.profile,
1600
+ });
1601
+
1602
+ // Get cluster API URL
1603
+ const domain = clusterDomain(name);
1604
+ const apiUrl = `https://${domain}/api`;
1605
+
1606
+ // Build environment for Claude
1607
+ const agentEnv = {
1608
+ ...process.env,
1609
+ KUBECONFIG: process.env.KUBECONFIG || path.join(process.env.HOME, ".kube", "config"),
1610
+ KUBE_CONTEXT: name,
1611
+ FOUNDATION_API_URL: apiUrl,
1612
+ FOUNDATION_CLUSTER: name,
1613
+ };
1614
+
1615
+ // Start port-forward in background if requested
1616
+ let portForward = null;
1617
+ if (opts.portForward) {
1618
+ console.log(chalk.dim(` Port-forwarding backend to localhost:65432…`));
1619
+ portForward = execa("kubectl", [
1620
+ "--context", name,
1621
+ "port-forward", "svc/foundation-backend", "65432:65432",
1622
+ "-n", "foundation",
1623
+ ], { reject: false, stdio: "ignore" });
1624
+ agentEnv.FOUNDATION_API_URL = "http://localhost:65432";
1625
+ await new Promise(r => setTimeout(r, 2000));
1626
+ }
1627
+
1628
+ console.log(chalk.cyan(`\n Launching Claude Code agent for cluster: ${name}`));
1629
+ console.log(chalk.dim(` API: ${agentEnv.FOUNDATION_API_URL}`));
1630
+ console.log(chalk.dim(` Context: kubectl --context ${name}\n`));
1631
+
1632
+ try {
1633
+ await execa("claude", [], {
1634
+ env: agentEnv,
1635
+ stdio: "inherit",
1636
+ cwd: process.cwd(),
1637
+ });
1638
+ } catch (err) {
1639
+ if (err.exitCode !== 0 && err.signal !== "SIGINT") {
1640
+ console.error(chalk.red(`\n Claude exited with code ${err.exitCode}\n`));
1641
+ }
1642
+ } finally {
1643
+ if (portForward) {
1644
+ portForward.kill("SIGTERM");
1645
+ }
1646
+ }
1647
+ });
890
1648
  }