@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.
- package/CHANGELOG.md +183 -0
- package/package.json +1 -1
- package/src/commands/lifecycle.js +101 -5
- package/src/commands/setup.js +45 -4
- package/src/plugins/bundled/fops-plugin-azure/index.js +29 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +1185 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +1180 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-ingress.js +393 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +104 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-network.js +296 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +768 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-reconcilers.js +538 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-secrets.js +849 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-stacks.js +643 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-state.js +145 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +496 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-terraform.js +1032 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +155 -4245
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +186 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +29 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +78 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +758 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/registry-cmds.js +250 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +52 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +10 -0
- package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +3 -2
- package/src/plugins/bundled/fops-plugin-foundation/lib/helpers.js +21 -0
- package/src/plugins/bundled/fops-plugin-foundation/lib/tools-read.js +3 -5
- package/src/ui/tui/App.js +13 -13
- package/src/web/dist/assets/index-NXC8Hvnp.css +1 -0
- package/src/web/dist/assets/index-QH1N4ejK.js +112 -0
- package/src/web/dist/index.html +2 -2
- package/src/web/server.js +4 -4
- package/src/web/dist/assets/index-BphVaAUd.css +0 -1
- 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
|
}
|