@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 +10 -10
- package/package.json +1 -1
- package/src/doctor.js +11 -8
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +9 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-secrets.js +151 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +12 -4
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +28 -0
- package/src/plugins/bundled/fops-plugin-foundation/index.js +84 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
## [0.1.
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
for (const t of
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1865
|
+
// Update cache
|
|
1866
|
+
self.azureCache = json
|
|
1867
|
+
self.azureCacheTime = Date()
|
|
1853
1868
|
|
|
1854
|
-
|
|
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
|
-
|
|
1874
|
-
item
|
|
1875
|
-
|
|
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
|
-
|
|
1891
|
-
item
|
|
1892
|
-
|
|
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
|
|
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
|
|
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
|
}
|