@meshxdata/fops 0.1.50 → 0.1.52

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 CHANGED
@@ -1,5 +1,13 @@
1
- ## [0.1.50] - 2026-03-24
2
-
1
+ ## [0.1.52] - 2026-03-24
2
+
3
+ - fix(doctor): set KUBECONFIG for k3s kubectl commands (db9359b)
4
+ - fix(azure): move --landscape to test run command, not separate subcommand (4b9b089)
5
+ - feat(azure): add test integration command with landscape support (b2990a0)
6
+ - fix(fleet): skip VMs without public IPs in fleet exec (39acbaa)
7
+ - feat(azure): detect and fix External Secrets identity issues (f907d11)
8
+ - operator cli bump 0.1.51 (db55bdc)
9
+ - feat: add postgres-exporter and Azure tray menu improvements (2a337ac)
10
+ - operator cli plugin fix (4dae908)
3
11
  - operator cli plugin fix (25620cc)
4
12
  - operator cli test fixes (1d1c18f)
5
13
  - feat(test): add setup-users command for QA test user creation (b929507)
@@ -171,14 +179,6 @@
171
179
  - azure packer (12175b8)
172
180
  - init hashed pwd (db8523c)
173
181
  - packer (5b5c7c4)
174
- - doctor for azure vm (ed524fa)
175
- - packer and 1pwd (c6d053e)
176
- - split big index.js (dc85a1b)
177
- - kafka volume update (21815ec)
178
- - fix openai azure tools confirmation and flow (0118cd1)
179
- - nighly fixx, test fix (5e0d04f)
180
- - open ai training (cdc494a)
181
- - openai integration in azure (1ca1475)
182
182
 
183
183
  # Changelog
184
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meshxdata/fops",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "description": "CLI to install and manage data mesh platforms",
5
5
  "keywords": [
6
6
  "fops",
package/src/doctor.js CHANGED
@@ -22,6 +22,9 @@ const KEY_PORTS = {
22
22
  18201: "Vault",
23
23
  };
24
24
 
25
+ // K3s kubectl requires explicit KUBECONFIG inside the container
26
+ const K3S_KUBECTL = ["exec", "-e", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "k3s-server", "kubectl"];
27
+
25
28
  const LABEL_WIDTH = 36;
26
29
 
27
30
  function header(title) {
@@ -1187,7 +1190,7 @@ export async function runDoctor(opts = {}, registry = null) {
1187
1190
  // 1. Cluster reachable
1188
1191
  try {
1189
1192
  const { stdout: nodesOut, exitCode: nodesExit } = await execa("docker", [
1190
- "exec", "k3s-server", "kubectl", "get", "nodes",
1193
+ ...K3S_KUBECTL, "get", "nodes",
1191
1194
  ], { timeout: 10000, reject: false });
1192
1195
  if (nodesExit === 0 && /Ready/.test(nodesOut)) {
1193
1196
  ok("K3s cluster reachable", nodesOut.trim().split("\n").find((l) => /Ready/.test(l))?.trim().replace(/\s+/g, " ") || "ready");
@@ -1216,11 +1219,11 @@ export async function runDoctor(opts = {}, registry = null) {
1216
1219
  }
1217
1220
  console.log(chalk.cyan(" ▶ Deleting old ghcr-secret…"));
1218
1221
  await execa("docker", [
1219
- "exec", "k3s-server", "kubectl", "delete", "secret", "ghcr-secret", "-n", "spark-jobs",
1222
+ ...K3S_KUBECTL, "delete", "secret", "ghcr-secret", "-n", "spark-jobs",
1220
1223
  ], { timeout: 10000, reject: false });
1221
1224
  console.log(chalk.cyan(" ▶ Creating new ghcr-secret…"));
1222
1225
  await execa("docker", [
1223
- "exec", "k3s-server", "kubectl", "create", "secret", "docker-registry", "ghcr-secret",
1226
+ ...K3S_KUBECTL, "create", "secret", "docker-registry", "ghcr-secret",
1224
1227
  "--docker-server=ghcr.io", "--docker-username=x-access-token", `--docker-password=${freshToken}`,
1225
1228
  "--namespace=spark-jobs",
1226
1229
  ], { timeout: 10000 });
@@ -1229,7 +1232,7 @@ export async function runDoctor(opts = {}, registry = null) {
1229
1232
  const patchJson = '{"imagePullSecrets":[{"name":"ghcr-secret"}]}';
1230
1233
  for (const sa of ["spark-operator-spark", "spark", "default"]) {
1231
1234
  await execa("docker", [
1232
- "exec", "k3s-server", "kubectl", "patch", "serviceaccount", sa,
1235
+ ...K3S_KUBECTL, "patch", "serviceaccount", sa,
1233
1236
  "-n", "spark-jobs", "-p", patchJson,
1234
1237
  ], { timeout: 10000, reject: false });
1235
1238
  }
@@ -1238,7 +1241,7 @@ export async function runDoctor(opts = {}, registry = null) {
1238
1241
 
1239
1242
  try {
1240
1243
  const { exitCode: secretExit } = await execa("docker", [
1241
- "exec", "k3s-server", "kubectl", "get", "secret", "ghcr-secret", "-n", "spark-jobs",
1244
+ ...K3S_KUBECTL, "get", "secret", "ghcr-secret", "-n", "spark-jobs",
1242
1245
  ], { timeout: 10000, reject: false });
1243
1246
 
1244
1247
  if (secretExit !== 0) {
@@ -1248,7 +1251,7 @@ export async function runDoctor(opts = {}, registry = null) {
1248
1251
  let ghcrTokenValid = false;
1249
1252
  try {
1250
1253
  const { stdout: b64Data } = await execa("docker", [
1251
- "exec", "k3s-server", "kubectl", "get", "secret", "ghcr-secret",
1254
+ ...K3S_KUBECTL, "get", "secret", "ghcr-secret",
1252
1255
  "-n", "spark-jobs", "-o", "jsonpath={.data.\\.dockerconfigjson}",
1253
1256
  ], { timeout: 10000 });
1254
1257
  if (b64Data?.trim()) {
@@ -1280,7 +1283,7 @@ export async function runDoctor(opts = {}, registry = null) {
1280
1283
  // 3. ECR / regcred secret (informational)
1281
1284
  try {
1282
1285
  const { exitCode: ecrExit } = await execa("docker", [
1283
- "exec", "k3s-server", "kubectl", "get", "secret", "ecr-secret", "-n", "spark-jobs",
1286
+ ...K3S_KUBECTL, "get", "secret", "ecr-secret", "-n", "spark-jobs",
1284
1287
  ], { timeout: 10000, reject: false });
1285
1288
  if (ecrExit === 0) {
1286
1289
  ok("ECR pull secret (ecr-secret)", "exists in spark-jobs");
@@ -1291,7 +1294,7 @@ export async function runDoctor(opts = {}, registry = null) {
1291
1294
 
1292
1295
  try {
1293
1296
  const { exitCode: regExit } = await execa("docker", [
1294
- "exec", "k3s-server", "kubectl", "get", "secret", "regcred", "-n", "spark-jobs",
1297
+ ...K3S_KUBECTL, "get", "secret", "regcred", "-n", "spark-jobs",
1295
1298
  ], { timeout: 10000, reject: false });
1296
1299
  if (regExit === 0) {
1297
1300
  ok("regcred secret", "exists in spark-jobs");
@@ -1250,6 +1250,15 @@ export async function aksStatus(opts = {}) {
1250
1250
  hint(" Flux CLI not available — skipping Flux status.");
1251
1251
  }
1252
1252
 
1253
+ // External Secrets health check
1254
+ console.log(`\n ${LABEL("External Secrets")}`);
1255
+ try {
1256
+ const { validateExternalSecretsHealth } = await import("./azure-aks-secrets.js");
1257
+ await validateExternalSecretsHealth({ execa, clusterName, rg, sub });
1258
+ } catch (e) {
1259
+ hint(` Could not check External Secrets: ${e.message}`);
1260
+ }
1261
+
1253
1262
  console.log("");
1254
1263
  }
1255
1264
 
@@ -172,6 +172,32 @@ export async function reconcileSecretStore(ctx) {
172
172
  }
173
173
  }
174
174
 
175
+ // 2c. Check for External Secrets managed identity (ext-* prefix) and grant Key Vault access
176
+ const extSecretsIdentity = await detectExternalSecretsIdentity(execa, clusterName, sub);
177
+ if (extSecretsIdentity && kvId) {
178
+ const { stdout: hasExtRole } = await execa("az", [
179
+ "role", "assignment", "list",
180
+ "--assignee", extSecretsIdentity.clientId,
181
+ "--role", "Key Vault Secrets User",
182
+ "--scope", kvId,
183
+ "--query", "[0].id", "-o", "tsv",
184
+ ...subArgs(sub),
185
+ ], { reject: false, timeout: 30000 });
186
+
187
+ if (!hasExtRole?.trim()) {
188
+ await execa("az", [
189
+ "role", "assignment", "create",
190
+ "--assignee", extSecretsIdentity.clientId,
191
+ "--role", "Key Vault Secrets User",
192
+ "--scope", kvId,
193
+ ...subArgs(sub),
194
+ ], { reject: false, timeout: 30000 });
195
+ console.log(OK(` ✓ External Secrets identity granted Key Vault Secrets User role`));
196
+ }
197
+ // Store the identity ID for SecretStore configuration
198
+ ctx.extSecretsIdentityId = extSecretsIdentity.clientId;
199
+ }
200
+
175
201
  // 3. Ensure azure-secret-sp exists in each target namespace
176
202
  const { stdout: spSecretJson } = await kubectl([
177
203
  "get", "secret", "azure-secret-sp", "-n", "foundation", "-o", "json",
@@ -579,6 +605,131 @@ export async function detectEsApiVersion(kubectl) {
579
605
  return "external-secrets.io/v1";
580
606
  }
581
607
 
608
+ /**
609
+ * Detect External Secrets managed identity (ext-* prefix) for clusters with multiple identities.
610
+ * When AKS has multiple user-assigned identities, SecretStore needs to specify which one to use.
611
+ */
612
+ export async function detectExternalSecretsIdentity(execa, clusterName, sub) {
613
+ const { subArgs } = await import("./azure.js");
614
+
615
+ // List all managed identities that match the external-secrets pattern
616
+ const { stdout: identitiesJson } = await execa("az", [
617
+ "identity", "list",
618
+ "--query", `[?contains(name, '${clusterName}')].{name:name,clientId:clientId}`,
619
+ "-o", "json",
620
+ ...subArgs(sub),
621
+ ], { reject: false, timeout: 30000 });
622
+
623
+ let identities = [];
624
+ try { identities = JSON.parse(identitiesJson || "[]"); } catch {}
625
+
626
+ // Look for ext-* identity (External Secrets workload identity)
627
+ const extIdentity = identities.find(i => i.name?.startsWith("ext-") && i.name?.includes(clusterName));
628
+ if (extIdentity) {
629
+ return { name: extIdentity.name, clientId: extIdentity.clientId };
630
+ }
631
+
632
+ // If multiple identities exist but no ext-* found, warn about potential issues
633
+ if (identities.length > 1) {
634
+ const { WARN, hint } = await import("./azure.js");
635
+ console.log(WARN(` ⚠ Multiple managed identities found for ${clusterName} but no ext-* identity detected`));
636
+ hint("External Secrets may fail with 'Multiple user assigned identities exist' error");
637
+ hint("Create a dedicated identity: az identity create -n ext-<cluster> -g <rg>");
638
+ }
639
+
640
+ return null;
641
+ }
642
+
643
+ /**
644
+ * Validate External Secrets health - checks SecretStore config and ExternalSecret status.
645
+ * Reports issues like missing identityId when multiple identities exist.
646
+ */
647
+ export async function validateExternalSecretsHealth(ctx) {
648
+ const { execa, clusterName, sub } = ctx;
649
+ const { OK, WARN, DIM, hint, subArgs } = await import("./azure.js");
650
+
651
+ const kubectl = (args, opts = {}) =>
652
+ execa("kubectl", ["--context", clusterName, ...args], { timeout: 30000, reject: false, ...opts });
653
+
654
+ const issues = [];
655
+
656
+ // Check SecretStore status
657
+ const { stdout: ssJson } = await kubectl([
658
+ "get", "secretstore", SECRET_STORE_NAME, "-n", "foundation", "-o", "json",
659
+ ]);
660
+ if (!ssJson) {
661
+ issues.push({ level: "error", msg: "SecretStore not found in foundation namespace" });
662
+ return issues;
663
+ }
664
+
665
+ const ss = JSON.parse(ssJson);
666
+ const ssReady = ss.status?.conditions?.find(c => c.type === "Ready")?.status === "True";
667
+ const authType = ss.spec?.provider?.azurekv?.authType;
668
+ const identityId = ss.spec?.provider?.azurekv?.identityId;
669
+
670
+ // Check if using ManagedIdentity auth without identityId when multiple identities exist
671
+ if (authType === "ManagedIdentity" && !identityId) {
672
+ const extIdentity = await detectExternalSecretsIdentity(execa, clusterName, sub);
673
+ if (extIdentity) {
674
+ issues.push({
675
+ level: "warn",
676
+ msg: `SecretStore uses ManagedIdentity but identityId is empty`,
677
+ fix: `Set identityId to "${extIdentity.clientId}" in clusters/${clusterName}/config/secret-store.yaml`,
678
+ });
679
+ }
680
+ }
681
+
682
+ // Check ExternalSecret status
683
+ const { stdout: esJson } = await kubectl([
684
+ "get", "externalsecret", "-n", "foundation", "-o", "json",
685
+ ]);
686
+ const externalSecrets = esJson ? JSON.parse(esJson).items : [];
687
+
688
+ for (const es of externalSecrets) {
689
+ const ready = es.status?.conditions?.find(c => c.type === "Ready");
690
+ if (ready?.status !== "True") {
691
+ const msg = ready?.message || "Unknown error";
692
+ if (msg.includes("Multiple user assigned identities exist")) {
693
+ const extIdentity = await detectExternalSecretsIdentity(execa, clusterName, sub);
694
+ issues.push({
695
+ level: "error",
696
+ msg: `ExternalSecret "${es.metadata.name}" failing: Multiple identities detected`,
697
+ fix: extIdentity
698
+ ? `Add identityId: "${extIdentity.clientId}" to SecretStore spec`
699
+ : "Create ext-* managed identity and grant Key Vault access",
700
+ });
701
+ } else if (msg.includes("Forbidden") || msg.includes("not authorized")) {
702
+ issues.push({
703
+ level: "error",
704
+ msg: `ExternalSecret "${es.metadata.name}" failing: Key Vault access denied`,
705
+ fix: "Run: fops azure aks doctor --fix to grant Key Vault permissions",
706
+ });
707
+ } else {
708
+ issues.push({
709
+ level: "error",
710
+ msg: `ExternalSecret "${es.metadata.name}" failing: ${msg.substring(0, 100)}`,
711
+ });
712
+ }
713
+ }
714
+ }
715
+
716
+ // Report findings
717
+ if (issues.length === 0) {
718
+ console.log(OK(" ✓ External Secrets healthy"));
719
+ } else {
720
+ for (const issue of issues) {
721
+ if (issue.level === "error") {
722
+ console.log(WARN(` ✗ ${issue.msg}`));
723
+ } else {
724
+ console.log(WARN(` ⚠ ${issue.msg}`));
725
+ }
726
+ if (issue.fix) hint(` Fix: ${issue.fix}`);
727
+ }
728
+ }
729
+
730
+ return issues;
731
+ }
732
+
582
733
  // ── Vault auto-unseal bootstrap ──────────────────────────────────────────────
583
734
 
584
735
  export const VAULT_UNSEAL_KEY_NAME = "vault-unseal";
@@ -71,20 +71,28 @@ async function forEachVm({
71
71
  return { results: [], vms };
72
72
  }
73
73
 
74
- const targets = opts.vmName ? [opts.vmName] : names;
75
- for (const t of targets) {
74
+ const allTargets = opts.vmName ? [opts.vmName] : names;
75
+ for (const t of allTargets) {
76
76
  if (!vms[t]) {
77
77
  console.error(ERR(`\n VM "${t}" not tracked. Run: fops azure list\n`));
78
78
  process.exit(1);
79
79
  }
80
80
  }
81
81
 
82
+ // Filter out VMs without public IPs (e.g., local stack) unless explicitly targeted
83
+ const skippedVms = opts.vmName ? [] : allTargets.filter(t => !vms[t].publicIp);
84
+ const targets = opts.vmName ? allTargets : allTargets.filter(t => vms[t].publicIp);
85
+
82
86
  banner(title);
83
- hint(`${targets.length} VM(s)${concurrency ? ` (concurrency: ${concurrency})` : ""}…\n`);
87
+ if (targets.length === 0) {
88
+ hint("No VMs with public IPs to target.\n");
89
+ return { results: [], vms, activeVm: listVms().activeVm };
90
+ }
91
+ hint(`${targets.length} VM(s)${skippedVms.length ? ` (${skippedVms.length} skipped: no public IP)` : ""}${concurrency ? ` (concurrency: ${concurrency})` : ""}…\n`);
84
92
 
85
93
  async function runOne(name) {
86
94
  const vm = vms[name];
87
- if (!vm.publicIp) return { name, ok: false, reason: "no public IP" };
95
+ if (!vm.publicIp) return { name, ok: false, reason: "no public IP (local stack?)" };
88
96
 
89
97
  try {
90
98
  await knockForVm(vm);
@@ -14,6 +14,8 @@ export function registerTestCommands(azure) {
14
14
  .command("run [name]", { isDefault: true })
15
15
  .description("Run QA automation tests locally against a remote VM")
16
16
  .option("--vm-name <name>", "Target VM (default: active)")
17
+ .option("--landscape <file>", "Apply landscape file (FCL/HCL/YAML) before running tests")
18
+ .option("--landscape-template <name>", "Use built-in landscape template (demo, pipeline_demo)")
17
19
  .action(async (name, opts) => {
18
20
  const { resolveCliSrc, lazyExeca, ensureAzCli, ensureAzAuth, resolvePublicIp } = await import("../azure-helpers.js");
19
21
  const { requireVmState, knockForVm, sshCmd, MUX_OPTS } = await import("../azure.js");
@@ -46,6 +48,32 @@ export function registerTestCommands(azure) {
46
48
  process.exit(1);
47
49
  }
48
50
 
51
+ // Apply landscape if specified
52
+ let landscapeFile = opts.landscape;
53
+ if (!landscapeFile && opts.landscapeTemplate) {
54
+ const templateDir = path.join(root, "operator-cli/src/plugins/bundled/fops-plugin-foundation/templates/landscapes");
55
+ const templateName = opts.landscapeTemplate.endsWith(".fcl") ? opts.landscapeTemplate : `${opts.landscapeTemplate}.fcl`;
56
+ landscapeFile = path.join(templateDir, templateName);
57
+ try {
58
+ await fsp.access(landscapeFile);
59
+ } catch {
60
+ console.error(chalk.red(`\n Landscape template not found: ${templateName}`));
61
+ console.error(chalk.dim(` Available: demo.fcl, pipeline_demo.fcl\n`));
62
+ process.exit(1);
63
+ }
64
+ }
65
+ if (landscapeFile) {
66
+ console.log(chalk.cyan(`\n Applying landscape: ${path.basename(landscapeFile)}…\n`));
67
+ const { azureApply } = await import("../azure.js");
68
+ try {
69
+ await azureApply(landscapeFile, { vmName: state.vmName });
70
+ console.log(chalk.green(" ✓ Landscape applied\n"));
71
+ } catch (err) {
72
+ console.error(chalk.red(`\n Failed to apply landscape: ${err.message}\n`));
73
+ process.exit(1);
74
+ }
75
+ }
76
+
49
77
  const vmUrl = state.publicUrl || `https://${ip}`;
50
78
  const apiUrl = `${vmUrl}/api`;
51
79
 
@@ -1618,6 +1618,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1618
1618
  checkForUpdate()
1619
1619
  let updateTimer = Timer(timeInterval: 900, repeats: true) { _ in self.checkForUpdate() }
1620
1620
  RunLoop.main.add(updateTimer, forMode: .common)
1621
+ // Preload Azure resources
1622
+ preloadAzureResources()
1621
1623
  }
1622
1624
 
1623
1625
  func checkForUpdate() {
@@ -1819,7 +1821,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1819
1821
 
1820
1822
  var azureCache: [String: Any]? = nil
1821
1823
  var azureCacheTime: Date? = nil
1822
- let azureCacheTTL: TimeInterval = 60 // 60 seconds
1824
+ let azureCacheTTL: TimeInterval = 120 // 2 minutes
1825
+
1826
+ func preloadAzureResources() {
1827
+ fops(["azure", "list", "--json"], capture: true) { out, success in
1828
+ guard success,
1829
+ let data = out.data(using: .utf8),
1830
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
1831
+ DispatchQueue.main.async {
1832
+ self.azureCache = json
1833
+ self.azureCacheTime = Date()
1834
+ }
1835
+ }
1836
+ }
1823
1837
 
1824
1838
  func populateAzureMenu(_ m: NSMenu) {
1825
1839
  // Use cache if fresh
@@ -1837,21 +1851,23 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1837
1851
 
1838
1852
  // Fetch VMs and AKS clusters via fops azure list --json
1839
1853
  fops(["azure", "list", "--json"], capture: true) { out, success in
1840
- guard success,
1841
- let data = out.data(using: .utf8),
1842
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1843
- m.removeAllItems()
1844
- let err = NSMenuItem(title: "Not authenticated or plugin disabled", action: nil, keyEquivalent: "")
1845
- err.isEnabled = false
1846
- m.addItem(err)
1847
- return
1848
- }
1854
+ DispatchQueue.main.async {
1855
+ guard success,
1856
+ let data = out.data(using: .utf8),
1857
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1858
+ m.removeAllItems()
1859
+ let err = NSMenuItem(title: "Not authenticated or plugin disabled", action: nil, keyEquivalent: "")
1860
+ err.isEnabled = false
1861
+ m.addItem(err)
1862
+ return
1863
+ }
1849
1864
 
1850
- // Update cache
1851
- self.azureCache = json
1852
- self.azureCacheTime = Date()
1865
+ // Update cache
1866
+ self.azureCache = json
1867
+ self.azureCacheTime = Date()
1853
1868
 
1854
- self.renderAzureMenu(m, data: json)
1869
+ self.renderAzureMenu(m, data: json)
1870
+ }
1855
1871
  }
1856
1872
  }
1857
1873
 
@@ -1870,9 +1886,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1870
1886
  let name = vm["name"] as? String ?? "unknown"
1871
1887
  let ip = vm["publicIp"] as? String ?? ""
1872
1888
  let ipSuffix = ip.isEmpty ? "" : " (\\(ip))"
1873
- let item = NSMenuItem(title: " " + name + ipSuffix, action: #selector(AppDelegate.openAzureVM(_:)), keyEquivalent: "")
1874
- item.representedObject = name
1875
- item.target = self
1889
+
1890
+ let item = NSMenuItem(title: " " + name + ipSuffix, action: nil, keyEquivalent: "")
1891
+ let submenu = NSMenu()
1892
+
1893
+ let sshItem = NSMenuItem(title: "SSH", action: #selector(AppDelegate.azureVMSSH(_:)), keyEquivalent: "")
1894
+ sshItem.representedObject = name
1895
+ sshItem.target = self
1896
+ submenu.addItem(sshItem)
1897
+
1898
+ let testItem = NSMenuItem(title: "Run Tests", action: #selector(AppDelegate.azureVMTest(_:)), keyEquivalent: "")
1899
+ testItem.representedObject = name
1900
+ testItem.target = self
1901
+ submenu.addItem(testItem)
1902
+
1903
+ item.submenu = submenu
1876
1904
  m.addItem(item)
1877
1905
  }
1878
1906
  }
@@ -1887,9 +1915,28 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1887
1915
  let name = cluster["name"] as? String ?? "unknown"
1888
1916
  let state = cluster["provisioningState"] as? String ?? ""
1889
1917
  let dot = state.lowercased() == "succeeded" ? "● " : "○ "
1890
- let item = NSMenuItem(title: " " + dot + name, action: #selector(AppDelegate.openAzureAKS(_:)), keyEquivalent: "")
1891
- item.representedObject = name
1892
- item.target = self
1918
+
1919
+ let item = NSMenuItem(title: " " + dot + name, action: nil, keyEquivalent: "")
1920
+ let submenu = NSMenu()
1921
+
1922
+ let kubeconfigItem = NSMenuItem(title: "Kubeconfig", action: #selector(AppDelegate.azureAKSKubeconfig(_:)), keyEquivalent: "")
1923
+ kubeconfigItem.representedObject = name
1924
+ kubeconfigItem.target = self
1925
+ submenu.addItem(kubeconfigItem)
1926
+
1927
+ let testItem = NSMenuItem(title: "Run Tests", action: #selector(AppDelegate.azureAKSTest(_:)), keyEquivalent: "")
1928
+ testItem.representedObject = name
1929
+ testItem.target = self
1930
+ submenu.addItem(testItem)
1931
+
1932
+ submenu.addItem(NSMenuItem.separator())
1933
+
1934
+ let statusItem = NSMenuItem(title: "Status", action: #selector(AppDelegate.azureAKSStatus(_:)), keyEquivalent: "")
1935
+ statusItem.representedObject = name
1936
+ statusItem.target = self
1937
+ submenu.addItem(statusItem)
1938
+
1939
+ item.submenu = submenu
1893
1940
  m.addItem(item)
1894
1941
  }
1895
1942
  }
@@ -1901,12 +1948,27 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1901
1948
  }
1902
1949
  }
1903
1950
 
1904
- @objc func openAzureVM(_ sender: NSMenuItem) {
1951
+ @objc func azureVMSSH(_ sender: NSMenuItem) {
1905
1952
  guard let name = sender.representedObject as? String else { return }
1906
1953
  openTerminal(command: "fops azure ssh \\(name)")
1907
1954
  }
1908
1955
 
1909
- @objc func openAzureAKS(_ sender: NSMenuItem) {
1956
+ @objc func azureVMTest(_ sender: NSMenuItem) {
1957
+ guard let name = sender.representedObject as? String else { return }
1958
+ openTerminal(command: "fops azure test \\(name)")
1959
+ }
1960
+
1961
+ @objc func azureAKSKubeconfig(_ sender: NSMenuItem) {
1962
+ guard let name = sender.representedObject as? String else { return }
1963
+ openTerminal(command: "fops azure aks kubeconfig \\(name)")
1964
+ }
1965
+
1966
+ @objc func azureAKSTest(_ sender: NSMenuItem) {
1967
+ guard let name = sender.representedObject as? String else { return }
1968
+ openTerminal(command: "fops azure aks test \\(name)")
1969
+ }
1970
+
1971
+ @objc func azureAKSStatus(_ sender: NSMenuItem) {
1910
1972
  guard let name = sender.representedObject as? String else { return }
1911
1973
  openTerminal(command: "fops azure aks status \\(name)")
1912
1974
  }