@meshxdata/fops 0.1.49 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/package.json +1 -1
  3. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +347 -6
  4. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-data-bootstrap.js +421 -0
  5. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +5 -179
  6. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +14 -4
  7. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +171 -4
  8. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +303 -8
  9. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +2 -0
  10. package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +1 -1
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet-swarm.js +936 -0
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +10 -918
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +5 -0
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault-keys.js +413 -0
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +14 -399
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-config.js +754 -0
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-knock.js +527 -0
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-ssh.js +427 -0
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +99 -1686
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-health.js +279 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +186 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +66 -444
  23. package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +11 -0
  24. package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-lifecycle.js +5 -540
  25. package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-terraform.js +544 -0
  26. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +75 -3
  27. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +227 -11
  28. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +2 -1
  29. package/src/plugins/bundled/fops-plugin-azure/lib/pytest-parse.js +21 -0
  30. package/src/plugins/bundled/fops-plugin-foundation/index.js +309 -44
@@ -17,6 +17,25 @@ import {
17
17
  setVmKnockTag,
18
18
  } from "./azure-helpers.js";
19
19
 
20
+ // Re-export config functions from dedicated module
21
+ export {
22
+ azureConfig,
23
+ azureConfigVersions,
24
+ azureDeployVersion,
25
+ FOUNDATION_COMPONENTS,
26
+ parseComponentVersions,
27
+ applyVersionChanges,
28
+ } from "./azure-ops-config.js";
29
+
30
+ // Re-export SSH/remote functions from dedicated module
31
+ export {
32
+ azureSsh,
33
+ azurePortForward,
34
+ azureSshAdminAdd,
35
+ azureAgent,
36
+ azureOpenAiDebugVm,
37
+ } from "./azure-ops-ssh.js";
38
+
20
39
  // ── status ──────────────────────────────────────────────────────────────────
21
40
 
22
41
  export async function azureStatus(opts = {}) {
@@ -405,416 +424,6 @@ export async function azureVmCheck(opts = {}) {
405
424
  if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
406
425
  }
407
426
 
408
- // ── ssh ─────────────────────────────────────────────────────────────────────
409
-
410
- export async function azureSsh(opts = {}) {
411
- const { knockForVm, ensureKnockSequence } = await import("./azure-helpers.js");
412
- // Sync IP + knock sequence from Azure before using them
413
- const freshState = await ensureKnockSequence(requireVmState(opts.vmName));
414
- const ip = freshState?.publicIp;
415
- if (!ip) { console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n")); process.exit(1); }
416
-
417
- const sshUser = opts.user || DEFAULTS.adminUser;
418
- await knockForVm(freshState);
419
-
420
- const { execa } = await import("execa");
421
- const result = await execa("ssh", [
422
- "-o", "StrictHostKeyChecking=no",
423
- "-o", "UserKnownHostsFile=/dev/null",
424
- "-o", "ConnectTimeout=15",
425
- `${sshUser}@${ip}`,
426
- ], { stdio: "inherit", reject: false });
427
- if (result.exitCode !== 0 && result.exitCode !== null) {
428
- console.error(chalk.red(`\n SSH failed (exit ${result.exitCode}). If port-knock is enabled, try: fops azure knock open ${freshState?.vmName}\n`));
429
- process.exit(1);
430
- }
431
- }
432
-
433
- // ── port forward ─────────────────────────────────────────────────────────────
434
-
435
- export async function azurePortForward(opts = {}) {
436
- const { remotePort, localPort } = opts;
437
- const state = requireVmState(opts.vmName);
438
- const ip = state.publicIp;
439
- if (!ip) { console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n")); process.exit(1); }
440
-
441
- await knockForVm(state);
442
-
443
- const lp = localPort || remotePort;
444
- console.log(chalk.cyan(`\n Forwarding localhost:${lp} → ${ip}:${remotePort}`));
445
- console.log(DIM(" Press Ctrl+C to stop\n"));
446
-
447
- const { execa } = await import("execa");
448
- await execa("ssh", [
449
- ...MUX_OPTS(ip, DEFAULTS.adminUser),
450
- "-N",
451
- "-L", `${lp}:localhost:${remotePort}`,
452
- `${DEFAULTS.adminUser}@${ip}`,
453
- ], { stdio: "inherit", reject: false });
454
- }
455
-
456
- // ── ssh admin ────────────────────────────────────────────────────────────────
457
-
458
- export async function azureSshAdminAdd(opts = {}) {
459
- const { pubKey } = opts;
460
- if (!pubKey?.trim()) {
461
- console.error(chalk.red("\n ✗ No public key provided\n"));
462
- process.exit(1);
463
- }
464
-
465
- const { vms } = listVms();
466
- const vmNames = Object.keys(vms);
467
- if (vmNames.length === 0) {
468
- console.error(chalk.red("\n ✗ No VMs tracked. Use: fops azure up <name>\n"));
469
- process.exit(1);
470
- }
471
-
472
- const execa = await lazyExeca();
473
- const adminUser = DEFAULTS.adminUser;
474
- const keyLine = pubKey.trim();
475
-
476
- banner("Adding SSH key to all VMs");
477
- console.log(DIM(` Key: ${keyLine.slice(0, 60)}...`));
478
- console.log(DIM(` VMs: ${vmNames.join(", ")}\n`));
479
-
480
- let success = 0, failed = 0;
481
-
482
- for (const vmName of vmNames) {
483
- const vm = vms[vmName];
484
- const ip = vm.publicIp;
485
- if (!ip) {
486
- console.log(WARN(` ⚠ ${vmName}: no IP (VM stopped?) — skipped`));
487
- failed++;
488
- continue;
489
- }
490
-
491
- await knockForVm(vm);
492
- const sshOk = await waitForSsh(execa, ip, adminUser, 15000);
493
- if (!sshOk) {
494
- console.log(ERR(` ✗ ${vmName}: SSH unreachable — skipped`));
495
- failed++;
496
- continue;
497
- }
498
-
499
- const addKeyCmd = `grep -qxF '${keyLine}' ~/.ssh/authorized_keys 2>/dev/null || echo '${keyLine}' >> ~/.ssh/authorized_keys`;
500
- const { exitCode } = await sshCmd(execa, ip, adminUser, addKeyCmd, 30000);
501
- if (exitCode === 0) {
502
- console.log(OK(` ✓ ${vmName}: key added`));
503
- success++;
504
- } else {
505
- console.log(ERR(` ✗ ${vmName}: failed to add key`));
506
- failed++;
507
- }
508
- }
509
-
510
- console.log("");
511
- if (failed === 0) {
512
- console.log(OK(` ✓ SSH key added to all ${success} VM(s)\n`));
513
- } else {
514
- console.log(WARN(` Done: ${success} succeeded, ${failed} failed\n`));
515
- }
516
- }
517
-
518
- // ── agent (remote TUI via SSH) ───────────────────────────────────────────────
519
-
520
- /** Shell-escape a value for single-quoted export (e.g. foo -> 'foo', a'b -> 'a'\''b'). */
521
- function shellExportValue(v) {
522
- if (v == null || v === "") return "''";
523
- const s = String(v);
524
- return "'" + s.replace(/'/g, "'\\''") + "'";
525
- }
526
-
527
- /**
528
- * Resolve Foundation project root for reading .env. Uses findProjectRoot() first, then
529
- * readState().projectRoot so that keys always come from project .env even when cwd is not the repo.
530
- * When we find root via cwd/FOUNDATION_ROOT, we persist it to state so running from ~ later still works.
531
- */
532
- async function resolveProjectRootForEnv() {
533
- const { findProjectRoot } = await import("./azure-openai.js");
534
- let root = findProjectRoot();
535
- if (root) {
536
- root = path.resolve(root);
537
- try {
538
- const state = readState();
539
- const existing = state?.projectRoot?.trim();
540
- if (existing !== root) saveState({ ...state, projectRoot: root });
541
- } catch {}
542
- return root;
543
- }
544
- try {
545
- const state = readState();
546
- const candidate = state?.projectRoot?.trim();
547
- if (candidate && fs.existsSync(candidate)) {
548
- const compose = path.join(candidate, "docker-compose.yaml");
549
- const composeAlt = path.join(candidate, "docker-compose.yml");
550
- if (fs.existsSync(compose) || fs.existsSync(composeAlt)) return path.resolve(candidate);
551
- }
552
- } catch {}
553
- return null;
554
- }
555
-
556
- /**
557
- * Build Azure OpenAI env vars from local project .env for the remote session.
558
- * Returns { exportPrefix, inlineEnv, envFileLines } where:
559
- * - exportPrefix: "export VAR='val' ... ; " (for sourcing before other commands)
560
- * - inlineEnv: "VAR='val' VAR2='val2' " for inline env (may be truncated by SSH argv limits)
561
- * - envFileLines: array of "VAR=val" lines for writing to a file on the VM and sourcing (most reliable).
562
- */
563
- async function buildAzureOpenAIEnvForRemote() {
564
- const { readAzureOpenAIConfigFromEnv } = await import("./azure-openai.js");
565
- const root = await resolveProjectRootForEnv();
566
- if (!root) return { exportPrefix: "", inlineEnv: "", envFileLines: [] };
567
- const envPath = path.join(root, ".env");
568
- const cfg = readAzureOpenAIConfigFromEnv(envPath);
569
- if (!cfg.endpoint || !cfg.key) return { exportPrefix: "", inlineEnv: "", envFileLines: [] };
570
- const deployment = cfg.deployment || "gpt-4o";
571
- const apiVersion = cfg.apiVersion || "2024-12-01-preview";
572
- const lines = [
573
- `AZURE_OPENAI_ENDPOINT=${cfg.endpoint}`,
574
- `AZURE_OPENAI_API_KEY=${cfg.key}`,
575
- `MX_OPENAI_API_KEY=${cfg.key}`,
576
- `AZURE_OPENAI_DEPLOYMENT=${deployment}`,
577
- `AZURE_OPENAI_API_VERSION=${apiVersion}`,
578
- ];
579
- if (cfg.fastDeployment) lines.push(`AZURE_OPENAI_FAST_DEPLOYMENT=${cfg.fastDeployment}`);
580
- const parts = lines.map((l) => {
581
- const eq = l.indexOf("=");
582
- const k = l.slice(0, eq);
583
- const v = l.slice(eq + 1);
584
- return `${k}=${shellExportValue(v)}`;
585
- });
586
- const assign = parts.join(" ");
587
- return {
588
- exportPrefix: "export " + assign + "; ",
589
- inlineEnv: assign + " ",
590
- envFileLines: lines,
591
- };
592
- }
593
-
594
- /** @deprecated Use buildAzureOpenAIEnvForRemote(). */
595
- async function buildAzureOpenAIExportPrefix() {
596
- const { exportPrefix } = await buildAzureOpenAIEnvForRemote();
597
- return exportPrefix;
598
- }
599
-
600
- /**
601
- * Sync Azure OpenAI config from the local project .env to the VM's /opt/foundation-compose/.env
602
- * when they differ. Source of truth is the project .env; we only push if the VM's Azure OpenAI
603
- * vars don't match local .env. No-op if no project root, endpoint/key missing in .env, or VM
604
- * already matches.
605
- */
606
- async function syncOpenAIKeysToVm(execa, ip, adminUser, state) {
607
- const {
608
- readAzureOpenAIConfigFromEnv,
609
- parseAzureOpenAIConfigFromContent,
610
- azureOpenAIConfigEqual,
611
- } = await import("./azure-openai.js");
612
- const root = await resolveProjectRootForEnv();
613
- if (!root) return;
614
- const envPath = path.join(root, ".env");
615
- const fromFile = readAzureOpenAIConfigFromEnv(envPath);
616
- const endpoint = fromFile.endpoint;
617
- const key = fromFile.key;
618
- if (!endpoint || !key) return;
619
-
620
- const deployment = fromFile.deployment || "gpt-4o";
621
- const apiVersion = fromFile.apiVersion || "2024-12-01-preview";
622
- const fastDeployment = fromFile.fastDeployment || "";
623
-
624
- try {
625
- const { stdout: remoteEnv } = await sshCmd(execa, ip, adminUser, "cat /opt/foundation-compose/.env 2>/dev/null || true", 10000);
626
- const onVm = parseAzureOpenAIConfigFromContent(remoteEnv || "");
627
- if (azureOpenAIConfigEqual(fromFile, onVm)) return;
628
- } catch {
629
- // If we can't read VM .env, sync anyway so VM gets local config
630
- }
631
- console.log(chalk.dim(" VM .env differs from local project .env; syncing Azure OpenAI config…"));
632
-
633
- const tmpFile = path.join(os.tmpdir(), `fops-openai-env-${Date.now()}`);
634
- const lines = [
635
- `AZURE_OPENAI_ENDPOINT=${endpoint}`,
636
- `AZURE_OPENAI_API_KEY=${key}`,
637
- `MX_OPENAI_API_KEY=${key}`,
638
- `AZURE_OPENAI_DEPLOYMENT=${deployment}`,
639
- `AZURE_OPENAI_API_VERSION=${apiVersion}`,
640
- ];
641
- if (fastDeployment) lines.push(`AZURE_OPENAI_FAST_DEPLOYMENT=${fastDeployment}`);
642
- fs.writeFileSync(tmpFile, lines.join("\n") + "\n", "utf8");
643
- const remotePath = "/tmp/fops-openai-env-update";
644
- try {
645
- const { exitCode: scpCode } = await execa("scp", [
646
- ...MUX_OPTS(ip, adminUser),
647
- tmpFile,
648
- `${adminUser}@${ip}:${remotePath}`,
649
- ], { timeout: 15000, reject: false });
650
- if (scpCode !== 0) {
651
- console.log(chalk.yellow(" ⚠ SCP failed — could not upload env to VM"));
652
- return;
653
- }
654
- // Loop body must use semicolons so "do" is not followed by "&&" (invalid on remote bash)
655
- const mergeScript = [
656
- "cd /opt/foundation-compose",
657
- "touch .env",
658
- "while IFS= read -r line; do [[ -z \"$line\" || \"$line\" =~ ^# ]] && continue; key=\"${line%%=*}\"; key=\"${key%% *}\"; [[ -z \"$key\" ]] && continue; (grep -v \"^$key=\" .env 2>/dev/null || true) > .env.tmp; echo \"$line\" >> .env.tmp; mv .env.tmp .env; done < " + remotePath,
659
- "rm -f " + remotePath,
660
- ].join(" && ");
661
- const { exitCode: mergeCode } = await sshCmd(execa, ip, adminUser, mergeScript, 15000);
662
- if (mergeCode === 0) {
663
- console.log(chalk.dim(" ✓ Azure OpenAI keys synced to VM .env"));
664
- } else {
665
- console.log(chalk.yellow(" ⚠ Merge script failed on VM (exit " + mergeCode + ") — .env may not be updated"));
666
- }
667
- } finally {
668
- try { fs.unlinkSync(tmpFile); } catch {}
669
- }
670
- }
671
-
672
- export async function azureAgent(opts = {}) {
673
- const execa = await lazyExeca();
674
- const state = requireVmState(opts.vmName);
675
- const ip = state.publicIp;
676
- const adminUser = DEFAULTS.adminUser;
677
- if (!ip) { console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n")); process.exit(1); }
678
-
679
- await knockForVm(state);
680
-
681
- // Whitelist VM's public IP on Azure OpenAI (if resource has firewall) so agent on VM can call the API
682
- await ensureOpenAiNetworkAccess(execa, ip, opts.profile);
683
-
684
- await syncOpenAIKeysToVm(execa, ip, adminUser, state);
685
-
686
- const webPort = 3099;
687
- const agentName = opts.agent || "foundation";
688
- const vmName = state.vmName;
689
- console.log(chalk.dim(` VM: ${vmName} (${ip}) — agent: ${agentName}\n`));
690
-
691
- // Sync local project root .env Azure OpenAI into the remote session (inline VAR=val before fops agent so the process gets them)
692
- const { exportPrefix, inlineEnv } = await buildAzureOpenAIEnvForRemote();
693
- if (!inlineEnv) {
694
- const root = await resolveProjectRootForEnv();
695
- const envPath = root ? path.join(root, ".env") : null;
696
- console.log(chalk.yellow(" ⚠ Azure OpenAI keys are read from the project .env; none found."));
697
- console.log(chalk.dim(" Agent on VM will show \"Connection error\" or \"No API key found\" unless keys are present."));
698
- if (envPath) {
699
- console.log(chalk.dim(` Add AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY to ${envPath} then run this again.`));
700
- } else {
701
- console.log(chalk.dim(` Project root not found (cwd: ${process.cwd()}). Set FOUNDATION_ROOT or projectRoot in ~/.fops.json to your foundation-compose directory, or run this from that directory. Then add AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY to that project's .env.`));
702
- }
703
- console.log(chalk.dim(""));
704
- }
705
- const envPrefix = "set -a && . /opt/foundation-compose/.env 2>/dev/null; set +a && cd /opt/foundation-compose && ";
706
- const remotePrefix = exportPrefix + envPrefix;
707
- const agentEnv = inlineEnv; // inline VAR=val before "fops agent" so the process inherits them (reliable with ssh sh -c)
708
- const args = [
709
- "-t",
710
- "-L", `${webPort}:localhost:${webPort}`,
711
- "-o", "StrictHostKeyChecking=no",
712
- "-o", "ExitOnForwardFailure=no",
713
- `${DEFAULTS.adminUser}@${ip}`,
714
- remotePrefix + agentEnv + `fops agent ${agentName}`.trim(),
715
- ];
716
- if (opts.message) {
717
- args.pop();
718
- args.push(remotePrefix + agentEnv + `fops agent ${agentName} -m ${JSON.stringify(opts.message)}`.trim());
719
- }
720
- if (opts.classic) {
721
- args[args.length - 1] += " --classic";
722
- }
723
- if (opts.model) {
724
- args[args.length - 1] += ` --model ${opts.model}`;
725
- }
726
-
727
- console.log(chalk.cyan(` Agent web UI: http://localhost:${webPort}`));
728
- console.log(chalk.dim(" Wait for the TUI to appear on the VM before opening the URL (avoids \"channel 3: open failed: Connection refused\")."));
729
- console.log(chalk.dim(" If the agent shows a connection error to Azure OpenAI, run: fops azure openai debug-vm " + (vmName && vmName !== "alessio" ? vmName : "") + "\n"));
730
-
731
- const result = await execa("ssh", args, { stdio: "inherit", reject: false });
732
- if (result?.exitCode !== 0 && result?.exitCode != null) {
733
- console.log(chalk.yellow("\n SSH exited with code " + result.exitCode + ". For Azure OpenAI connection errors run: fops azure openai debug-vm" + (vmName && vmName !== "alessio" ? " " + vmName : "") + "\n"));
734
- }
735
- }
736
-
737
- /**
738
- * Run a single agent request on the VM with DEBUG=1 and stream back stdout+stderr
739
- * so we can see the real connection error (e.g. cause.message, cause.code).
740
- */
741
- export async function azureOpenAiDebugVm(opts = {}) {
742
- const execa = await lazyExeca();
743
- const state = requireVmState(opts.vmName);
744
- const ip = state.publicIp;
745
- const adminUser = DEFAULTS.adminUser;
746
- const remoteEnvPath = "/tmp/fops-azure-env." + process.pid;
747
- if (!ip) {
748
- console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n"));
749
- process.exit(1);
750
- }
751
-
752
- await knockForVm(state);
753
- await ensureOpenAiNetworkAccess(execa, ip, opts.profile);
754
- await syncOpenAIKeysToVm(execa, ip, adminUser, state);
755
-
756
- const agentName = opts.agent || "foundation";
757
- const root = await resolveProjectRootForEnv();
758
- const envPath = root ? path.join(root, ".env") : null;
759
- const { envFileLines } = await buildAzureOpenAIEnvForRemote();
760
-
761
- if (envFileLines.length) {
762
- console.log(chalk.dim(" Pushing Azure OpenAI vars to VM .env, then running agent (sourcing .env so process gets keys)."));
763
- const tmpFile = path.join(os.tmpdir(), `fops-azure-env-${process.pid}`);
764
- fs.writeFileSync(tmpFile, envFileLines.join("\n") + "\n", "utf8");
765
- try {
766
- const { exitCode: scpCode } = await execa("scp", [
767
- ...MUX_OPTS(ip, adminUser),
768
- tmpFile,
769
- `${adminUser}@${ip}:${remoteEnvPath}`,
770
- ], { timeout: 15000, reject: false });
771
- if (scpCode === 0) {
772
- const mergeScript = [
773
- "cd /opt/foundation-compose",
774
- "touch .env",
775
- "while IFS= read -r line; do [[ -z \"$line\" || \"$line\" =~ ^# ]] && continue; key=\"${line%%=*}\"; key=\"${key%% *}\"; [[ -z \"$key\" ]] && continue; (grep -v \"^$key=\" .env 2>/dev/null || true) > .env.tmp; echo \"$line\" >> .env.tmp; mv .env.tmp .env; done < " + remoteEnvPath,
776
- "rm -f " + remoteEnvPath,
777
- ].join(" && ");
778
- const { exitCode: mergeCode } = await sshCmd(execa, ip, adminUser, mergeScript, 15000);
779
- if (mergeCode === 0) {
780
- const { stdout: check } = await sshCmd(execa, ip, adminUser, "grep -E '^AZURE_OPENAI_ENDPOINT=|^AZURE_OPENAI_API_KEY=' /opt/foundation-compose/.env 2>/dev/null | sed 's/=.*/=***/' || true", 5000);
781
- if (check?.trim()) console.log(chalk.dim(" VM .env: " + check.trim().replace(/\n/g, " ")));
782
- }
783
- }
784
- } finally {
785
- try { fs.unlinkSync(tmpFile); } catch {}
786
- }
787
- } else {
788
- console.log(chalk.yellow(" ⚠ Not injecting Azure OpenAI — remote session will have no keys from project .env."));
789
- if (envPath) {
790
- const { readAzureOpenAIConfigFromEnv } = await import("./azure-openai.js");
791
- const cfg = fs.existsSync(envPath) ? readAzureOpenAIConfigFromEnv(envPath) : { endpoint: "", key: "" };
792
- const missing = [];
793
- if (!cfg.endpoint?.trim()) missing.push("AZURE_OPENAI_ENDPOINT");
794
- if (!cfg.key?.trim()) missing.push("AZURE_OPENAI_API_KEY or MX_OPENAI_API_KEY");
795
- if (missing.length) {
796
- console.log(chalk.dim(` Tried ${envPath} — missing: ${missing.join(", ")}.`));
797
- } else {
798
- console.log(chalk.dim(` Project root not found. Tried cwd/FOUNDATION_ROOT/~/.fops.json projectRoot.`));
799
- }
800
- console.log(chalk.dim(` Add the missing var(s) to ${envPath}, then run again.\n`));
801
- } else {
802
- console.log(chalk.dim(` Project root not found (cwd: ${process.cwd()}). Set FOUNDATION_ROOT or projectRoot in ~/.fops.json, or run from foundation-compose. Then add AZURE_OPENAI_* to that project's .env.\n`));
803
- }
804
- }
805
-
806
- // Source VM .env so AZURE_OPENAI_* (and MX_OPENAI_API_KEY) are in the environment, then run fops
807
- const cmd =
808
- "bash -c 'cd /opt/foundation-compose && set -a && . ./.env 2>/dev/null; set +a; exec env DEBUG=1 fops agent " + agentName + " -m \"hi\" 2>&1'";
809
- console.log(chalk.cyan("\n Running: DEBUG=1 fops agent " + agentName + " -m 'hi' (VM .env sourced so AZURE_OPENAI_* are exported)\n"));
810
- const { stdout, stderr, exitCode } = await sshCmd(execa, ip, adminUser, cmd, 60000);
811
- const out = [stdout, stderr].filter(Boolean).join("\n");
812
- if (out) console.log(out);
813
- if (exitCode !== 0) {
814
- console.log(chalk.yellow("\n Exit code: " + exitCode + " — see output above. If 'No API key': ensure VM .env has AZURE_OPENAI_* or run 'fops azure deploy' to update VM fops."));
815
- }
816
- }
817
-
818
427
  // ── deploy (git pull + restart) ─────────────────────────────────────────────
819
428
 
820
429
  export async function azureDeploy(opts = {}) {
@@ -1030,779 +639,43 @@ export async function azureRunUp(opts = {}) {
1030
639
  console.error(DIM(` Try manually: fops azure ssh ${state.vmName}, then: sudo npm install -g @meshxdata/fops@latest`));
1031
640
  process.exit(1);
1032
641
  }
1033
- // Ensure symlinks
1034
- await ssh(
1035
- 'sudo -E rm -f /usr/local/bin/fops /usr/bin/fops 2>/dev/null; MJS="$(npm root -g 2>/dev/null)/@meshxdata/fops/fops.mjs"; [ -f "$MJS" ] || MJS="$(sudo -E npm root -g 2>/dev/null)/@meshxdata/fops/fops.mjs"; [ -f "$MJS" ] && sudo -E ln -sf "$MJS" /usr/local/bin/fops && sudo -E ln -sf /usr/local/bin/fops /usr/bin/fops',
1036
- 15000,
1037
- );
1038
- console.log(OK(" ✓ fops updated"));
1039
-
1040
- // Pull images
1041
- const { exitCode: dlCode, stdout: dlOut, stderr: dlErr } = await ssh("cd /opt/foundation-compose && make download 2>&1", 600000);
1042
- if (dlCode !== 0) {
1043
- console.error(ERR(` image pull failed (exit ${dlCode})`));
1044
- console.error(DIM(` ${(dlErr || dlOut || "").split("\n").slice(-5).join("\n ")}`));
1045
- process.exit(1);
1046
- }
1047
- console.log(OK(" ✓ images pulled\n"));
1048
- }
1049
-
1050
- const publicUrl = opts.url || state.publicUrl || `https://${resolveUniqueDomain(state.vmName, "vm")}`;
1051
- const k3s = opts.k3s ?? false;
1052
- const traefik = opts.traefik ?? true;
1053
- const dai = opts.dai ?? false;
1054
- const upArgs = buildRemoteFopsUpArgs(publicUrl, {
1055
- k3s,
1056
- traefik,
1057
- dai,
1058
- component: opts.component,
1059
- branch: opts.branch,
1060
- });
1061
-
1062
- const cmd = `cd /opt/foundation-compose && export PATH=/usr/local/bin:/usr/bin:$PATH && sudo -E ${upArgs}`;
1063
- banner(`Run fops up on ${state.vmName}${opts.component && opts.branch ? ` (${opts.component} ${opts.branch})` : ""}`);
1064
- kvLine("IP", DIM(ip));
1065
- kvLine("URL", LABEL(publicUrl));
1066
- console.log("");
1067
-
1068
- const result = await sshCmd(execa, ip, adminUser, cmd, 600000, { stdio: "inherit" });
1069
- if (result.exitCode !== 0) process.exit(result.exitCode);
1070
- }
1071
-
1072
- // ── config (remote feature flags) ────────────────────────────────────────────
1073
-
1074
- export async function azureConfig(opts = {}) {
1075
- const execa = await lazyExeca();
1076
- const state = requireVmState(opts.vmName);
1077
- const ip = state.publicIp;
1078
- const adminUser = DEFAULTS.adminUser;
1079
-
1080
- if (!ip) { console.error(chalk.red("\n No IP. Is the VM running? Try: fops azure start\n")); process.exit(1); }
1081
-
1082
- await knockForVm(state);
1083
-
1084
- const ssh = (cmd) => sshCmd(execa, ip, adminUser, cmd, 30000);
1085
-
1086
- hint("Reading feature flags from VM…");
1087
- const { stdout: composeContent, exitCode } = await ssh("cat /opt/foundation-compose/docker-compose.yaml");
1088
- if (exitCode !== 0 || !composeContent?.trim()) {
1089
- console.error(chalk.red("\n Could not read docker-compose.yaml from VM.\n"));
1090
- process.exit(1);
1091
- }
1092
-
1093
- const { KNOWN_FLAGS, parseComposeFlagsFromContent, applyComposeFlagChanges } =
1094
- await import(resolveCliSrc("feature-flags.js"));
1095
-
1096
- const composeFlags = parseComposeFlagsFromContent(composeContent);
1097
-
1098
- const allFlags = {};
1099
- for (const [name, info] of Object.entries(composeFlags)) {
1100
- allFlags[name] = { ...info, inCompose: true };
1101
- }
1102
- for (const name of Object.keys(KNOWN_FLAGS)) {
1103
- if (!allFlags[name]) {
1104
- allFlags[name] = { value: false, services: new Set(), lines: [], inCompose: false };
1105
- }
1106
- }
1107
-
1108
- const flagNames = Object.keys(allFlags).sort();
1109
-
1110
- console.log(chalk.bold.cyan("\n Feature Flags") + chalk.dim(` — ${state.vmName} (${ip})\n`));
1111
-
1112
- for (const name of flagNames) {
1113
- const flag = allFlags[name];
1114
- const label = KNOWN_FLAGS[name] || name;
1115
- const services = flag.services.size > 0 ? chalk.dim(` (${[...flag.services].join(", ")})`) : "";
1116
- if (flag.value) {
1117
- console.log(chalk.green(` ✓ ${label}`) + services);
1118
- } else {
1119
- console.log(chalk.dim(` · ${label}`) + services);
1120
- }
1121
- }
1122
- console.log("");
1123
-
1124
- const { getInquirer } = await import(resolveCliSrc("lazy.js"));
1125
- const choices = flagNames.map((name) => ({
1126
- name: KNOWN_FLAGS[name] || name,
1127
- value: name,
1128
- checked: allFlags[name].value,
1129
- }));
1130
-
1131
- const { enabled } = await (await getInquirer()).prompt([{
1132
- type: "checkbox",
1133
- name: "enabled",
1134
- message: "Toggle feature flags:",
1135
- choices,
1136
- }]);
1137
-
1138
- const changes = [];
1139
- const affectedServices = new Set();
1140
-
1141
- for (const name of flagNames) {
1142
- const flag = allFlags[name];
1143
- const newValue = enabled.includes(name);
1144
-
1145
- if (newValue !== flag.value) {
1146
- if (flag.inCompose) {
1147
- for (const line of flag.lines) {
1148
- changes.push({ lineNum: line.lineNum, newValue: String(newValue) });
1149
- }
1150
- for (const svc of flag.services) affectedServices.add(svc);
1151
- } else if (newValue) {
1152
- console.log(chalk.yellow(` ⚠ ${KNOWN_FLAGS[name] || name} not in docker-compose.yaml — add it to service environments to take effect`));
1153
- }
1154
- }
1155
- }
1156
-
1157
- if (changes.length === 0) {
1158
- console.log(chalk.dim("\n No changes.\n"));
1159
- if (state.knockSequence?.length) {
1160
- await closeKnock(ssh, { quiet: true });
1161
- }
1162
- return;
1163
- }
1164
-
1165
- const updatedContent = applyComposeFlagChanges(composeContent, changes);
1166
-
1167
- hint("Applying changes to VM…");
1168
- const b64 = Buffer.from(updatedContent).toString("base64");
1169
- const { exitCode: writeCode } = await sshCmd(execa, ip, adminUser,
1170
- `echo '${b64}' | base64 -d > /opt/foundation-compose/docker-compose.yaml`,
1171
- 30000,
1172
- );
1173
- if (writeCode !== 0) {
1174
- console.error(chalk.red("\n Failed to write docker-compose.yaml on VM.\n"));
1175
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1176
- process.exit(1);
1177
- }
1178
- console.log(chalk.green(`\n ✓ Updated ${changes.length} flag value(s) on VM`));
1179
-
1180
- if (affectedServices.size === 0) {
1181
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1182
- console.log("");
1183
- return;
1184
- }
1185
-
1186
- const serviceList = [...affectedServices];
1187
- console.log(chalk.dim(` Affected: ${serviceList.join(", ")}`));
1188
-
1189
- const { restart } = await (await getInquirer()).prompt([{
1190
- type: "confirm",
1191
- name: "restart",
1192
- message: `Restart ${serviceList.length} service(s) on VM?`,
1193
- default: true,
1194
- }]);
1195
-
1196
- if (restart) {
1197
- const svcArgs = serviceList.join(" ");
1198
- console.log(chalk.cyan(`\n ▶ docker compose up -d --remove-orphans ${svcArgs}\n`));
1199
- await sshCmd(execa, ip, adminUser,
1200
- `cd /opt/foundation-compose && docker compose up -d --remove-orphans ${svcArgs}`,
1201
- 120000,
1202
- );
1203
- console.log(chalk.green("\n ✓ Services restarted on VM.\n"));
1204
- } else {
1205
- console.log(chalk.dim("\n Changes saved. Restart manually: fops azure deploy\n"));
1206
- }
1207
-
1208
- if (state.knockSequence?.length) {
1209
- await closeKnock(ssh, { quiet: true });
1210
- }
1211
- }
1212
-
1213
- // ── GHCR: resolve latest tag via gh CLI ──────────────────────────────────────
1214
-
1215
- const GHCR_ORG = "meshxdata";
1216
-
1217
- /**
1218
- * Pick a short error-like line from command output (git, etc.) for inclusion in reason.
1219
- */
1220
- function pickErrorLine(output) {
1221
- const lines = (output || "").trim().split("\n").map((l) => l.trim()).filter(Boolean);
1222
- if (lines.length === 0) return "";
1223
- const errorLike = /error|failed|denied|fatal|refused|cannot|unable|invalid|conflict|Permission/i;
1224
- for (let i = lines.length - 1; i >= 0; i--) {
1225
- if (errorLike.test(lines[i]) && lines[i].length < 120) return lines[i];
1226
- }
1227
- return lines[lines.length - 1].length < 120 ? lines[lines.length - 1] : "";
1228
- }
1229
-
1230
- /**
1231
- * Pick the most useful line from make download / docker pull output for a failure message.
1232
- * Prefers a line that mentions both an image and an error; then any error line; then last non-noise.
1233
- */
1234
- function pickPullErrorLine(output) {
1235
- const lines = (output || "").trim().split("\n").map((l) => l.trim()).filter(Boolean);
1236
- if (lines.length === 0) return "";
1237
- const errorLike = /error|failed|denied|unauthorized|not found|manifest|invalid|refused|required/i;
1238
- const imageRef = /ghcr\.io\/[\w.-]+\/[\w.-]+/;
1239
- const noise = /^(?:[\da-f]{12}\s+)?(?:Download complete|Pull complete|Pulled|Extracting|Waiting)$/i;
1240
- for (let i = lines.length - 1; i >= 0; i--) {
1241
- const line = lines[i];
1242
- if (imageRef.test(line) && errorLike.test(line)) return line;
1243
- }
1244
- for (let i = lines.length - 1; i >= 0; i--) {
1245
- const line = lines[i];
1246
- if (errorLike.test(line)) return line;
1247
- }
1248
- for (let i = lines.length - 1; i >= 0; i--) {
1249
- if (imageRef.test(lines[i])) return lines[i];
1250
- }
1251
- for (let i = lines.length - 1; i >= 0; i--) {
1252
- if (!noise.test(lines[i])) return lines[i];
1253
- }
1254
- return lines[lines.length - 1];
1255
- }
1256
-
1257
- /**
1258
- * Parse make download / docker output for image:tag that failed (not found).
1259
- * Returns [{ image: "ghcr.io/org/name", tag: "0.3.65" }, ...].
1260
- */
1261
- function parseNotFoundImages(output) {
1262
- if (!output?.trim()) return [];
1263
- const seen = new Set();
1264
- const out = [];
1265
- // "Image ghcr.io/meshxdata/foundation-backend:0.3.65 failed to resolve reference"
1266
- // "failed to resolve reference \"...\": ... not found" (image:tag appears before "not found")
1267
- const re = /(ghcr\.io\/[^/]+\/[^:]+):([^\s"']+).*?(?:failed|not found)/gi;
1268
- let m;
1269
- while ((m = re.exec(output)) !== null) {
1270
- const key = `${m[1]}:${m[2]}`;
1271
- if (!seen.has(key)) {
1272
- seen.add(key);
1273
- out.push({ image: m[1], tag: m[2] });
1274
- }
1275
- }
1276
- return out;
1277
- }
1278
-
1279
- /**
1280
- * Get latest available tag for a GHCR image via gh api (org packages container versions).
1281
- * Returns tag string or null if not ghcr.io/org/... or gh api fails.
1282
- * Prefers semver-like tag, then "compose", then first tag.
1283
- */
1284
- async function getLatestTagForGhcrImage(execa, imageFull) {
1285
- const match = imageFull.match(/^ghcr\.io\/([^/]+)\/(.+)$/);
1286
- if (!match) return null;
1287
- const [, org, pkg] = match;
1288
- try {
1289
- const { stdout, exitCode } = await execa("gh", [
1290
- "api",
1291
- `orgs/${org}/packages/container/${pkg}/versions`,
1292
- "--jq", "if length == 0 then null else (.[0].metadata.container.tags // []) end",
1293
- ], { timeout: 15000, reject: false });
1294
- if (exitCode !== 0 || !stdout?.trim() || stdout === "null") return null;
1295
- let tags = [];
1296
- try {
1297
- tags = JSON.parse(stdout);
1298
- } catch {
1299
- return null;
1300
- }
1301
- if (!Array.isArray(tags) || tags.length === 0) return null;
1302
- const semver = tags.find(t => /^\d+\.\d+\.\d+(-.+)?$/.test(t));
1303
- if (semver) return semver;
1304
- if (tags.includes("compose")) return "compose";
1305
- if (tags.includes("latest")) return "latest";
1306
- return tags[0];
1307
- } catch {
1308
- return null;
1309
- }
1310
- }
1311
-
1312
- // ── config versions (remote component image tags) ────────────────────────────
1313
-
1314
- const FOUNDATION_COMPONENTS = {
1315
- backend: { label: "Backend", image: "ghcr.io/meshxdata/foundation-backend", envKey: "BACKEND" },
1316
- frontend: { label: "Frontend", image: "ghcr.io/meshxdata/foundation-frontend", envKey: "FRONTEND" },
1317
- processor: { label: "Processor", image: "ghcr.io/meshxdata/foundation-processor", envKey: "PROCESSOR" },
1318
- watcher: { label: "Watcher", image: "ghcr.io/meshxdata/foundation-watcher", envKey: "WATCHER" },
1319
- scheduler: { label: "Scheduler", image: "ghcr.io/meshxdata/foundation-scheduler", envKey: "SCHEDULER" },
1320
- storage: { label: "Storage", image: "ghcr.io/meshxdata/foundation-storage-engine", envKey: "STORAGE" },
1321
- hive: { label: "Hive Metastore", image: "ghcr.io/meshxdata/foundation-hive-metastore", envKey: "HIVE" },
1322
- data: { label: "Data", image: "ghcr.io/meshxdata/foundation-data", envKey: "DATA" },
1323
- dataBase: { label: "Data Base", image: "ghcr.io/meshxdata/foundation-data-base", envKey: "DATA_BASE" },
1324
- };
1325
-
1326
- function trimEnvValue(val) {
1327
- if (val == null) return null;
1328
- return val.trim().replace(/\s*#.*$/, "").trim().replace(/^["']|["']$/g, "") || null;
1329
- }
1330
-
1331
- function parseImageTagFromEnv(envContent) {
1332
- if (!envContent?.trim()) return null;
1333
- const line = envContent.match(/^\s*IMAGE_TAG\s*=\s*(.+)/m)?.[1];
1334
- if (line == null) return null;
1335
- return trimEnvValue(line);
1336
- }
1337
-
1338
- function parsePerComponentTagsFromEnv(envContent) {
1339
- const out = {};
1340
- if (!envContent?.trim()) return out;
1341
- for (const key of Object.keys(FOUNDATION_COMPONENTS)) {
1342
- const envKey = FOUNDATION_COMPONENTS[key].envKey;
1343
- const varName = `IMAGE_TAG_${envKey}`;
1344
- const re = new RegExp(`^\\s*${varName}\\s*=[ \\t]*([^\\n\\r]*)`, "m");
1345
- const m = envContent.match(re);
1346
- if (m) {
1347
- const v = trimEnvValue(m[1]);
1348
- if (v) out[varName] = v;
1349
- }
1350
- }
1351
- return out;
1352
- }
1353
-
1354
- function parseComponentVersions(composeContent, envContent) {
1355
- const envTag = parseImageTagFromEnv(envContent);
1356
- const perComponent = parsePerComponentTagsFromEnv(envContent);
1357
-
1358
- const versions = {};
1359
- for (const [key, comp] of Object.entries(FOUNDATION_COMPONENTS)) {
1360
- const escapedImage = comp.image.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1361
- const re = new RegExp(`image:\\s*${escapedImage}:\\s*(.+)`, "m");
1362
- const m = composeContent.match(re);
1363
- if (m) {
1364
- const rawTag = m[1].trim();
1365
- const isEnvRef = rawTag.includes("${IMAGE_TAG");
1366
- const componentVar = `IMAGE_TAG_${comp.envKey}`;
1367
- const effectiveTag = isEnvRef
1368
- ? (perComponent[componentVar] ?? envTag ?? "compose")
1369
- : rawTag;
1370
- versions[key] = { ...comp, rawTag, effectiveTag, isEnvRef };
1371
- }
1372
- }
1373
- return { versions, globalTag: envTag || null };
1374
- }
1375
-
1376
- function applyVersionChanges(composeContent, changes) {
1377
- let result = composeContent;
1378
- for (const { image, oldPattern, newTag } of changes) {
1379
- const escapedImage = image.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1380
- const escapedOld = oldPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1381
- const re = new RegExp(`(image:\\s*${escapedImage}:)\\s*${escapedOld}`, "g");
1382
- result = result.replace(re, `$1${newTag}`);
1383
- }
1384
- return result;
1385
- }
1386
-
1387
- export async function azureConfigVersions(opts = {}) {
1388
- const execa = await lazyExeca();
1389
- const state = requireVmState(opts.vmName);
1390
- const ip = state.publicIp;
1391
- const adminUser = DEFAULTS.adminUser;
1392
-
1393
- if (!ip) { console.error(chalk.red("\n No IP. Is the VM running? Try: fops azure start\n")); process.exit(1); }
1394
-
1395
- await knockForVm(state);
1396
-
1397
- const ssh = (cmd) => sshCmd(execa, ip, adminUser, cmd, 30000);
1398
-
1399
- hint("Reading component versions from VM…");
1400
- const { stdout: composeContent, exitCode } = await ssh("cat /opt/foundation-compose/docker-compose.yaml");
1401
- if (exitCode !== 0 || !composeContent?.trim()) {
1402
- console.error(chalk.red("\n Could not read docker-compose.yaml from VM.\n"));
1403
- process.exit(1);
1404
- }
1405
-
1406
- const { stdout: envContent } = await ssh("cat /opt/foundation-compose/.env 2>/dev/null || echo ''");
1407
-
1408
- const { versions, globalTag } = parseComponentVersions(composeContent, envContent);
1409
-
1410
- const n = nameArg(state.vmName);
1411
- console.log(chalk.bold.cyan("\n Component Versions") + chalk.dim(` — ${state.vmName} (${ip})\n`));
1412
-
1413
- if (globalTag) {
1414
- console.log(chalk.dim(` IMAGE_TAG=${globalTag} (from .env)\n`));
1415
- }
1416
-
1417
- const maxLabel = Math.max(...Object.values(versions).map(v => v.label.length));
1418
- for (const [key, v] of Object.entries(versions)) {
1419
- const tagDisplay = v.isEnvRef
1420
- ? `${chalk.white(v.effectiveTag)} ${chalk.dim("(via IMAGE_TAG)")}`
1421
- : `${chalk.white(v.effectiveTag)} ${chalk.yellow("(hardcoded in compose)")}`;
1422
- console.log(` ${chalk.cyan(v.label.padEnd(maxLabel + 2))} ${tagDisplay}`);
1423
- }
1424
- console.log("");
1425
-
1426
- const { getInquirer } = await import(resolveCliSrc("lazy.js"));
1427
- const inquirer = await getInquirer();
1428
-
1429
- // Ask: set all at once, or pick individual components
1430
- const { mode } = await inquirer.prompt([{
1431
- type: "list",
1432
- name: "mode",
1433
- message: "How do you want to set versions?",
1434
- choices: [
1435
- { name: "Set all components to one tag (IMAGE_TAG)", value: "global" },
1436
- { name: "Set individual component tags", value: "individual" },
1437
- { name: "Cancel", value: "cancel" },
1438
- ],
1439
- }]);
1440
-
1441
- if (mode === "cancel") {
1442
- console.log(chalk.dim("\n Cancelled.\n"));
1443
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1444
- return;
1445
- }
1446
-
1447
- const envPerComponent = {};
1448
- const composeChanges = [];
1449
- let envChanges = null;
1450
- const affectedComponents = [];
1451
-
1452
- if (mode === "global") {
1453
- const { tag } = await inquirer.prompt([{
1454
- type: "input",
1455
- name: "tag",
1456
- message: "Tag for all components:",
1457
- default: globalTag || "compose",
1458
- }]);
1459
-
1460
- const trimmed = tag.trim();
1461
- if (!trimmed) {
1462
- console.log(chalk.dim("\n No tag entered.\n"));
1463
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1464
- return;
1465
- }
1466
-
1467
- envChanges = trimmed;
1468
- for (const [key, v] of Object.entries(versions)) {
1469
- if (v.effectiveTag !== trimmed) affectedComponents.push(key);
1470
- }
1471
- } else {
1472
- // Individual mode — pick components; write IMAGE_TAG_* to .env only (no compose edit)
1473
- const componentKeys = Object.keys(versions);
1474
- const selected = [];
1475
-
1476
- while (true) {
1477
- const remaining = componentKeys.filter(k => !selected.includes(k));
1478
- const choices = [
1479
- ...remaining.map(key => ({
1480
- name: `${versions[key].label} (${versions[key].effectiveTag})`,
1481
- value: key,
1482
- })),
1483
- { name: chalk.dim(selected.length ? "── Done" : "── Cancel"), value: "_done" },
1484
- ];
1485
-
1486
- const { pick } = await inquirer.prompt([{
1487
- type: "list",
1488
- name: "pick",
1489
- message: selected.length
1490
- ? `Selected: ${selected.map(k => versions[k].label).join(", ")}. Add more?`
1491
- : "Pick a component:",
1492
- choices,
1493
- }]);
1494
-
1495
- if (pick === "_done") break;
1496
-
1497
- const v = versions[pick];
1498
- const { tag } = await inquirer.prompt([{
1499
- type: "input",
1500
- name: "tag",
1501
- message: `Tag for ${v.label}:`,
1502
- default: v.effectiveTag,
1503
- }]);
1504
-
1505
- const trimmed = tag.trim();
1506
- if (!trimmed || trimmed === v.effectiveTag) continue;
1507
-
1508
- selected.push(pick);
1509
- envPerComponent[`IMAGE_TAG_${v.envKey}`] = trimmed;
1510
- affectedComponents.push(pick);
1511
-
1512
- if (remaining.length <= 1) break;
1513
- }
1514
-
1515
- if (!selected.length) {
1516
- console.log(chalk.dim("\n No components selected.\n"));
1517
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1518
- return;
1519
- }
1520
- }
1521
-
1522
- if (Object.keys(envPerComponent).length === 0 && composeChanges.length === 0 && !envChanges) {
1523
- console.log(chalk.dim("\n No changes.\n"));
1524
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1525
- return;
1526
- }
1527
-
1528
- let envAfterGlobal = envContent || "";
1529
- // Apply IMAGE_TAG to .env (global mode)
1530
- if (envChanges) {
1531
- hint(`Setting IMAGE_TAG=${envChanges} in .env…`);
1532
- if (/^\s*IMAGE_TAG\s*=/m.test(envAfterGlobal)) {
1533
- envAfterGlobal = envAfterGlobal.replace(/^\s*IMAGE_TAG\s*=.*/m, `IMAGE_TAG=${envChanges}`);
1534
- } else {
1535
- envAfterGlobal = envAfterGlobal.trimEnd() + (envAfterGlobal.trim() ? "\n" : "") + `IMAGE_TAG=${envChanges}\n`;
1536
- }
1537
- const envB64 = Buffer.from(envAfterGlobal).toString("base64");
1538
- const { exitCode: envWriteCode } = await sshCmd(execa, ip, adminUser,
1539
- `echo '${envB64}' | base64 -d > /opt/foundation-compose/.env`,
1540
- 30000,
1541
- );
1542
- if (envWriteCode !== 0) {
1543
- console.error(chalk.red("\n Failed to write .env on VM.\n"));
1544
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1545
- process.exit(1);
1546
- }
1547
- console.log(chalk.green(` ✓ IMAGE_TAG=${envChanges}`));
1548
- }
1549
-
1550
- // Apply per-component tags to .env only (no compose edit)
1551
- if (Object.keys(envPerComponent).length > 0) {
1552
- hint("Setting per-component tags in .env…");
1553
- let currentEnv = envAfterGlobal;
1554
- for (const [varName, tag] of Object.entries(envPerComponent)) {
1555
- const re = new RegExp(`^\\s*${varName}\\s*=.*`, "m");
1556
- if (re.test(currentEnv)) {
1557
- currentEnv = currentEnv.replace(re, `${varName}=${tag}`);
1558
- } else {
1559
- currentEnv = currentEnv.trimEnd() + (currentEnv.trim() ? "\n" : "") + `${varName}=${tag}\n`;
1560
- }
1561
- const comp = Object.values(FOUNDATION_COMPONENTS).find(c => `IMAGE_TAG_${c.envKey}` === varName);
1562
- console.log(chalk.green(` ✓ ${comp?.label || varName}: ${tag}`));
1563
- }
1564
- const envB64 = Buffer.from(currentEnv).toString("base64");
1565
- const { exitCode: envWriteCode } = await sshCmd(execa, ip, adminUser,
1566
- `echo '${envB64}' | base64 -d > /opt/foundation-compose/.env`,
1567
- 30000,
1568
- );
1569
- if (envWriteCode !== 0) {
1570
- console.error(chalk.red("\n Failed to write .env on VM.\n"));
1571
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1572
- process.exit(1);
1573
- }
1574
- }
1575
-
1576
- // Compose edits only for legacy hardcoded-tag fixes (e.g. make download fallback)
1577
- if (composeChanges.length > 0) {
1578
- hint("Updating docker-compose.yaml…");
1579
- const updatedContent = applyVersionChanges(composeContent, composeChanges);
1580
- const composeB64 = Buffer.from(updatedContent).toString("base64");
1581
- const { exitCode: composeWriteCode } = await sshCmd(execa, ip, adminUser,
1582
- `echo '${composeB64}' | base64 -d > /opt/foundation-compose/docker-compose.yaml`,
1583
- 30000,
642
+ // Ensure symlinks
643
+ await ssh(
644
+ 'sudo -E rm -f /usr/local/bin/fops /usr/bin/fops 2>/dev/null; MJS="$(npm root -g 2>/dev/null)/@meshxdata/fops/fops.mjs"; [ -f "$MJS" ] || MJS="$(sudo -E npm root -g 2>/dev/null)/@meshxdata/fops/fops.mjs"; [ -f "$MJS" ] && sudo -E ln -sf "$MJS" /usr/local/bin/fops && sudo -E ln -sf /usr/local/bin/fops /usr/bin/fops',
645
+ 15000,
1584
646
  );
1585
- if (composeWriteCode !== 0) {
1586
- console.error(chalk.red("\n Failed to write docker-compose.yaml on VM.\n"));
1587
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1588
- process.exit(1);
1589
- }
1590
- for (const c of composeChanges) {
1591
- const comp = Object.values(FOUNDATION_COMPONENTS).find(v => v.image === c.image);
1592
- console.log(chalk.green(` ✓ ${comp?.label || c.image}: ${c.newTag}`));
1593
- }
1594
- }
1595
-
1596
- console.log(chalk.green(`\n ✓ ${affectedComponents.length} component(s) updated`));
1597
-
1598
- // Offer to pull + restart
1599
- if (affectedComponents.length > 0) {
1600
- const { action } = await inquirer.prompt([{
1601
- type: "list",
1602
- name: "action",
1603
- message: "Pull images and restart?",
1604
- choices: [
1605
- { name: "Pull + restart affected services", value: "pull-restart" },
1606
- { name: "Pull only (no restart)", value: "pull" },
1607
- { name: "Skip (apply on next deploy)", value: "skip" },
1608
- ],
1609
- }]);
1610
-
1611
- if (action === "pull-restart" || action === "pull") {
1612
- hint("Pulling images…");
1613
- await sshCmd(execa, ip, adminUser,
1614
- "cd /opt/foundation-compose && sudo docker compose pull --ignore-pull-failures 2>/dev/null; true",
1615
- 300000,
1616
- );
1617
- console.log(chalk.green(" ✓ Images pulled"));
1618
-
1619
- if (action === "pull-restart") {
1620
- hint("Restarting services…");
1621
- await sshCmd(execa, ip, adminUser,
1622
- "cd /opt/foundation-compose && sudo docker compose up -d --remove-orphans",
1623
- 120000,
1624
- );
1625
- console.log(chalk.green(" ✓ Services restarted"));
1626
- }
1627
- } else {
1628
- hint(`Apply on next deploy: fops azure deploy${n}`);
1629
- }
1630
- }
1631
-
1632
- if (state.knockSequence?.length) {
1633
- await closeKnock(ssh, { quiet: true });
1634
- }
1635
- console.log("");
1636
- }
1637
-
1638
- // ── deploy version (non-interactive, CI-friendly) ────────────────────────────
1639
-
1640
- export async function azureDeployVersion(opts = {}) {
1641
- const execa = await lazyExeca();
1642
- const component = opts.component;
1643
- const tag = opts.tag;
1644
-
1645
- if (!component || !tag) {
1646
- console.error(chalk.red("\n Usage: fops azure deploy version <component> <tag>"));
1647
- console.error(chalk.dim(" Components: " + Object.keys(FOUNDATION_COMPONENTS).join(", ")));
1648
- console.error(chalk.dim(" Example: fops azure deploy version backend v0.3.80\n"));
1649
- process.exit(1);
1650
- }
1651
-
1652
- const compDef = FOUNDATION_COMPONENTS[component];
1653
- if (!compDef) {
1654
- console.error(chalk.red(`\n Unknown component: "${component}"`));
1655
- console.error(chalk.dim(" Available: " + Object.keys(FOUNDATION_COMPONENTS).join(", ") + "\n"));
1656
- process.exit(1);
1657
- }
1658
-
1659
- const { activeVm, vms } = listVms();
1660
- const vmNames = Object.keys(vms);
647
+ console.log(OK(" ✓ fops updated"));
1661
648
 
1662
- // Determine targets: specific VM, all VMs, or AKS
1663
- const targets = [];
1664
- if (opts.vmName) {
1665
- if (!vms[opts.vmName]) {
1666
- console.error(chalk.red(`\n VM "${opts.vmName}" not tracked. Run: fops azure list\n`));
649
+ // Pull images
650
+ const { exitCode: dlCode, stdout: dlOut, stderr: dlErr } = await ssh("cd /opt/foundation-compose && make download 2>&1", 600000);
651
+ if (dlCode !== 0) {
652
+ console.error(ERR(` image pull failed (exit ${dlCode})`));
653
+ console.error(DIM(` ${(dlErr || dlOut || "").split("\n").slice(-5).join("\n ")}`));
1667
654
  process.exit(1);
1668
655
  }
1669
- targets.push(opts.vmName);
1670
- } else if (opts.all) {
1671
- targets.push(...vmNames);
1672
- } else if (vmNames.length === 1) {
1673
- targets.push(vmNames[0]);
1674
- } else if (activeVm && vms[activeVm]) {
1675
- targets.push(activeVm);
1676
- } else if (vmNames.length > 0) {
1677
- targets.push(...vmNames);
1678
- }
1679
-
1680
- banner(`Deploy ${compDef.label} → ${tag}`);
1681
-
1682
- // Deploy to VMs
1683
- if (targets.length > 0) {
1684
- kvLine("VMs", DIM(targets.join(", ")));
1685
- kvLine("Image", DIM(`${compDef.image}:${tag}`));
1686
- console.log("");
1687
-
1688
- const results = await Promise.allSettled(targets.map(async (name) => {
1689
- const vm = vms[name];
1690
- if (!vm?.publicIp) return { name, ok: false, reason: "no public IP" };
1691
-
1692
- try {
1693
- await knockForVm(vm);
1694
- const ip = vm.publicIp;
1695
- const adminUser = DEFAULTS.adminUser;
1696
- const ssh = (cmd) => sshCmd(execa, ip, adminUser, cmd, 30000);
1697
-
1698
- // Read current compose + env
1699
- const { stdout: composeContent } = await ssh("cat /opt/foundation-compose/docker-compose.yaml");
1700
- const { stdout: envContent } = await ssh("cat /opt/foundation-compose/.env 2>/dev/null || echo ''");
1701
- if (!composeContent?.trim()) throw new Error("Cannot read docker-compose.yaml");
1702
-
1703
- const { versions } = parseComponentVersions(composeContent, envContent);
1704
- const v = versions[component];
1705
- if (!v) {
1706
- console.log(chalk.dim(` ${name}: ${compDef.label} not found in compose — skipping`));
1707
- return { name, ok: true, skipped: true };
1708
- }
1709
-
1710
- if (v.effectiveTag === tag) {
1711
- console.log(OK(` ${name}: ${compDef.label} already at ${tag}`));
1712
- return { name, ok: true, unchanged: true };
1713
- }
1714
-
1715
- // Apply version change: per-component env var in .env only (no compose edit for env refs)
1716
- if (v.isEnvRef) {
1717
- const varName = `IMAGE_TAG_${v.envKey}`;
1718
- const currentEnv = envContent || "";
1719
- let newEnv;
1720
- const re = new RegExp(`^\\s*${varName}\\s*=.*`, "m");
1721
- if (re.test(currentEnv)) {
1722
- newEnv = currentEnv.replace(re, `${varName}=${tag}`);
1723
- } else {
1724
- newEnv = currentEnv.trimEnd() + (currentEnv.trim() ? "\n" : "") + `${varName}=${tag}\n`;
1725
- }
1726
- const envB64 = Buffer.from(newEnv).toString("base64");
1727
- await sshCmd(execa, ip, adminUser,
1728
- `echo '${envB64}' | base64 -d > /opt/foundation-compose/.env`, 30000);
1729
- } else {
1730
- const updated = applyVersionChanges(composeContent, [
1731
- { image: v.image, oldPattern: v.rawTag, newTag: tag },
1732
- ]);
1733
- const composeB64 = Buffer.from(updated).toString("base64");
1734
- await sshCmd(execa, ip, adminUser,
1735
- `echo '${composeB64}' | base64 -d > /opt/foundation-compose/docker-compose.yaml`, 30000);
1736
- }
1737
-
1738
- // Pull the specific image and restart the service
1739
- const serviceName = `foundation-${component}`;
1740
- hint(` ${name}: pulling ${compDef.image}:${tag}…`);
1741
- await sshCmd(execa, ip, adminUser,
1742
- `cd /opt/foundation-compose && sudo docker compose pull ${serviceName} 2>/dev/null; true`,
1743
- 300000);
1744
-
1745
- if (!opts.noPull) {
1746
- hint(` ${name}: restarting ${serviceName}…`);
1747
- await sshCmd(execa, ip, adminUser,
1748
- `cd /opt/foundation-compose && sudo docker compose up -d --remove-orphans ${serviceName}`,
1749
- 120000);
1750
- }
1751
-
1752
- console.log(OK(` ${name}: ${compDef.label} → ${tag} ✓`));
1753
- if (vm.knockSequence?.length) {
1754
- const sshFn = (cmd) => sshCmd(execa, ip, adminUser, cmd, 30000);
1755
- await closeKnock(sshFn, { quiet: true });
1756
- }
1757
- return { name, ok: true };
1758
- } catch (err) {
1759
- console.log(chalk.red(` ${name}: failed — ${err.message}`));
1760
- return { name, ok: false, reason: err.message };
1761
- }
1762
- }));
1763
-
1764
- const succeeded = results.filter(r => r.status === "fulfilled" && r.value?.ok).length;
1765
- const failed = targets.length - succeeded;
1766
- console.log(`\n ${chalk.green(succeeded + " ok")}${failed ? chalk.red(", " + failed + " failed") : ""}`);
1767
- } else {
1768
- hint("No VMs tracked.");
656
+ console.log(OK(" ✓ images pulled\n"));
1769
657
  }
1770
658
 
1771
- // Deploy to AKS clusters if --aks or --all
1772
- if (opts.aks || opts.aksCluster) {
1773
- const aksTarget = opts.aksCluster || "";
1774
- hint(`\nUpdating AKS image tag…`);
1775
-
1776
- try {
1777
- const { readAksClusters } = await import(resolveCliSrc("plugins/bundled/fops-plugin-azure/lib/azure-aks.js"));
1778
- const { clusters } = readAksClusters?.() || {};
1779
- const clusterNames = aksTarget ? [aksTarget] : Object.keys(clusters || {});
1780
-
1781
- for (const cn of clusterNames) {
1782
- const fullImage = `${compDef.image}:${tag}`;
1783
- const namespace = "foundation";
1784
- const deployName = `foundation-${component}`;
1785
-
1786
- const { exitCode, stderr } = await execa("kubectl", [
1787
- "--context", cn,
1788
- "set", "image",
1789
- `deployment/${deployName}`,
1790
- `${component}=${fullImage}`,
1791
- "-n", namespace,
1792
- ], { reject: false, timeout: 30000 });
1793
-
1794
- if (exitCode === 0) {
1795
- console.log(OK(` ${cn}: ${deployName} → ${tag}`));
1796
- } else {
1797
- console.log(WARN(` ${cn}: ${(stderr || "").split("\n")[0]}`));
1798
- }
1799
- }
1800
- } catch (err) {
1801
- console.log(WARN(` AKS deploy failed: ${err.message}`));
1802
- }
1803
- }
659
+ const publicUrl = opts.url || state.publicUrl || `https://${resolveUniqueDomain(state.vmName, "vm")}`;
660
+ const k3s = opts.k3s ?? false;
661
+ const traefik = opts.traefik ?? true;
662
+ const dai = opts.dai ?? false;
663
+ const upArgs = buildRemoteFopsUpArgs(publicUrl, {
664
+ k3s,
665
+ traefik,
666
+ dai,
667
+ component: opts.component,
668
+ branch: opts.branch,
669
+ });
1804
670
 
671
+ const cmd = `cd /opt/foundation-compose && export PATH=/usr/local/bin:/usr/bin:$PATH && sudo -E ${upArgs}`;
672
+ banner(`Run fops up on ${state.vmName}${opts.component && opts.branch ? ` (${opts.component} ${opts.branch})` : ""}`);
673
+ kvLine("IP", DIM(ip));
674
+ kvLine("URL", LABEL(publicUrl));
1805
675
  console.log("");
676
+
677
+ const result = await sshCmd(execa, ip, adminUser, cmd, 600000, { stdio: "inherit" });
678
+ if (result.exitCode !== 0) process.exit(result.exitCode);
1806
679
  }
1807
680
 
1808
681
  // ── update (parallel fops update on remote hosts) ────────────────────────────
@@ -2422,6 +1295,28 @@ export async function azureList(opts = {}) {
2422
1295
  }
2423
1296
  } catch { /* az not available or not authenticated */ }
2424
1297
 
1298
+ // JSON output mode - early return with structured data
1299
+ if (opts.json) {
1300
+ const output = {
1301
+ vms: vmNames.map(name => ({
1302
+ name,
1303
+ publicIp: vms[name]?.publicIp || null,
1304
+ resourceGroup: vms[name]?.resourceGroup || null,
1305
+ location: vms[name]?.location || null,
1306
+ state: vms[name]?.powerState || "unknown",
1307
+ })),
1308
+ clusters: Object.entries(aksClusters).map(([name, cl]) => ({
1309
+ name,
1310
+ resourceGroup: cl.resourceGroup || null,
1311
+ location: cl.location || null,
1312
+ provisioningState: cl.provisioningState || "unknown",
1313
+ kubernetesVersion: cl.kubernetesVersion || null,
1314
+ })),
1315
+ };
1316
+ console.log(JSON.stringify(output, null, 2));
1317
+ return;
1318
+ }
1319
+
2425
1320
  if (vmNames.length === 0 && !hasAks) {
2426
1321
  banner("Azure VMs");
2427
1322
  hint("No VMs or clusters found in Azure.");
@@ -2643,6 +1538,16 @@ export async function azureList(opts = {}) {
2643
1538
  ...(cachedClusters[name] || { status: "unknown" }),
2644
1539
  }));
2645
1540
 
1541
+ // Sort: primaries first, then their standbys grouped together
1542
+ clusterResults.sort((a, b) => {
1543
+ const aIsStandby = a.name.endsWith("-standby");
1544
+ const bIsStandby = b.name.endsWith("-standby");
1545
+ const aBase = aIsStandby ? a.name.replace(/-standby$/, "") : a.name;
1546
+ const bBase = bIsStandby ? b.name.replace(/-standby$/, "") : b.name;
1547
+ if (aBase !== bBase) return aBase.localeCompare(bBase);
1548
+ return aIsStandby ? 1 : -1; // primary before standby
1549
+ });
1550
+
2646
1551
  const maxCName = Math.max(...clusterResults.map(c => c.name.length), 4);
2647
1552
  const hasAksQa = clusterNames.some(n => aksClusters[n]?.qa);
2648
1553
  const aksQaW = 20;
@@ -2658,8 +1563,15 @@ export async function azureList(opts = {}) {
2658
1563
  for (const cr of clusterResults) {
2659
1564
  const cl = aksClusters[cr.name];
2660
1565
  const active = cr.name === activeCluster;
1566
+ const isStandby = cr.name.endsWith("-standby");
1567
+ const primaryName = isStandby ? cr.name.replace(/-standby$/, "") : null;
1568
+ const hasPrimary = primaryName && clusterNames.includes(primaryName);
1569
+ const prefix = isStandby && hasPrimary ? " └─" : "";
2661
1570
  const dot = active ? OK("●") : DIM("○");
2662
- const cNameTxt = active ? OK(cr.name.padEnd(maxCName)) : LABEL(cr.name.padEnd(maxCName));
1571
+ const displayName = isStandby && hasPrimary
1572
+ ? `${cr.name} ${DIM("(HA standby)")}`
1573
+ : cr.name;
1574
+ const cNameTxt = active ? OK(displayName.padEnd(maxCName + 13)) : LABEL(displayName.padEnd(maxCName + 13));
2663
1575
  const loc = (cl?.location || cr.location || "–").padEnd(10);
2664
1576
  const nodes = cr.nodes != null ? `${cr.nodes} x ${cr.sizes || "?"}` : "–";
2665
1577
  const k8s = (cr.kubernetesVersion || "–").padEnd(6);
@@ -2702,7 +1614,8 @@ export async function azureList(opts = {}) {
2702
1614
  costCell = ` ${formatCostCell(amount, costData.currency).padEnd(14)}`;
2703
1615
  }
2704
1616
 
2705
- console.log(` ${dot} ${cNameTxt} ${DIM(loc)} ${DIM(nodes.padEnd(12))} ${DIM(k8s)} ${flux}${aksQaCell}${costCell} ${statusTxt}`);
1617
+ const rowPrefix = prefix ? DIM(prefix) : ` ${dot}`;
1618
+ console.log(`${rowPrefix} ${cNameTxt} ${DIM(loc)} ${DIM(nodes.padEnd(12))} ${DIM(k8s)} ${flux}${aksQaCell}${costCell} ${statusTxt}`);
2706
1619
  }
2707
1620
 
2708
1621
  const costSuffix = showCost && aksTotal > 0
@@ -2813,513 +1726,13 @@ export async function azureApply(file, opts = {}) {
2813
1726
  ], { stdio: "inherit", reject: false, timeout: 300000 });
2814
1727
  }
2815
1728
 
2816
- // ── knock ────────────────────────────────────────────────────────────────────
2817
-
2818
- export async function azureKnock(opts = {}) {
2819
- const { ensureKnockSequence } = await import("./azure-helpers.js");
2820
- // Sync IP + knock sequence from Azure before using them
2821
- const state = await ensureKnockSequence(requireVmState(opts.vmName));
2822
- if (!state?.knockSequence?.length) {
2823
- console.error(ERR("\n Port knocking is not configured on this VM."));
2824
- hint(`Provision with: fops azure up${nameArg(state?.vmName)}\n`);
2825
- process.exit(1);
2826
- }
2827
- const ip = state.publicIp;
2828
- if (!ip) { console.error(ERR("\n No IP address. Is the VM running?\n")); process.exit(1); }
2829
-
2830
- await performKnock(ip, state.knockSequence);
2831
-
2832
- hint("SSH (22) open for ~5 min from your IP.");
2833
- hint(`Connect: ssh ${DEFAULTS.adminUser}@${ip}`);
2834
- if (state.publicUrl) hint(`Browse: ${state.publicUrl}`);
2835
- console.log();
2836
- }
2837
-
2838
- export async function azureKnockClose(opts = {}) {
2839
- const execa = await lazyExeca();
2840
- const state = requireVmState(opts.vmName);
2841
- if (!state.knockSequence?.length) {
2842
- console.error(ERR("\n Port knocking is not configured on this VM."));
2843
- hint(`Provision with: fops azure up${nameArg(state?.vmName)}\n`);
2844
- process.exit(1);
2845
- }
2846
- const ip = state.publicIp;
2847
- if (!ip) { console.error(ERR("\n No IP. Is the VM running?\n")); process.exit(1); }
2848
-
2849
- await performKnock(ip, state.knockSequence, { quiet: true });
2850
- const ssh = (cmd) => sshCmd(execa, ip, DEFAULTS.adminUser, cmd);
2851
- await closeKnock(ssh);
2852
- }
2853
-
2854
- /**
2855
- * Run the same teardown as removeKnockd on the VM via Azure Run Command (no SSH needed).
2856
- * Use when VM is unreachable (e.g. knock broken, wrong sequence).
2857
- */
2858
- async function removeKnockdViaRunCommand(execa, rg, vmName, sub) {
2859
- const script = [
2860
- "for dport in $(sudo iptables -L INPUT -n 2>/dev/null | grep 'tcp dpt:' | grep -oP 'dpt:\\K[0-9]+' | sort -u); do sudo iptables -D INPUT -p tcp --dport $dport -j DROP 2>/dev/null || true; sudo iptables -D INPUT -p tcp --dport $dport -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true; done",
2861
- "sudo netfilter-persistent save 2>/dev/null || true",
2862
- "sudo systemctl stop knockd 2>/dev/null || true",
2863
- "sudo systemctl disable knockd 2>/dev/null || true",
2864
- ].join(" && ");
2865
- const { exitCode } = await execa("az", [
2866
- "vm", "run-command", "invoke", "--resource-group", rg, "--name", vmName,
2867
- "--command-id", "RunShellScript", "--scripts", script,
2868
- "--output", "none", ...subArgs(sub),
2869
- ], { timeout: 60000, reject: false });
2870
- return exitCode === 0;
2871
- }
2872
-
2873
- export async function azureKnockDisable(opts = {}) {
2874
- const execa = await lazyExeca();
2875
- const state = requireVmState(opts.vmName);
2876
- const ip = state.publicIp;
2877
- if (!ip) { console.error(chalk.red("\n No IP. Is the VM running?\n")); process.exit(1); }
2878
-
2879
- if (!state.knockSequence?.length) {
2880
- console.log(chalk.dim("\n Port knocking is not configured.\n"));
2881
- return;
2882
- }
2883
-
2884
- const rg = state.resourceGroup;
2885
- const vmName = state.vmName || opts.vmName;
2886
- const sub = opts.profile;
2887
-
2888
- const trySsh = async () => {
2889
- await performKnock(ip, state.knockSequence, { quiet: true });
2890
- const ssh = (cmd) => sshCmd(execa, ip, DEFAULTS.adminUser, cmd, 15000);
2891
- await removeKnockd(ssh);
2892
- };
2893
-
2894
- let sshOk = false;
2895
- if (rg && vmName) {
2896
- let sshReachable = false;
2897
- try {
2898
- await performKnock(ip, state.knockSequence, { quiet: true });
2899
- const { exitCode } = await execa("ssh", [
2900
- ...MUX_OPTS(ip, DEFAULTS.adminUser),
2901
- "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
2902
- `${DEFAULTS.adminUser}@${ip}`, "echo ok",
2903
- ], { timeout: 12000, reject: false });
2904
- sshReachable = exitCode === 0;
2905
- } catch { sshReachable = false; }
2906
- if (sshReachable) {
2907
- try {
2908
- await trySsh();
2909
- sshOk = true;
2910
- } catch (e) {
2911
- console.log(chalk.yellow(" SSH failed: " + (e?.message || e) + " — trying Azure Run Command…"));
2912
- }
2913
- } else {
2914
- console.log(chalk.dim(" VM not reachable via SSH — using Azure Run Command (no knock required)."));
2915
- }
2916
- if (!sshOk && rg && vmName) {
2917
- const ok = await removeKnockdViaRunCommand(execa, rg, vmName, sub);
2918
- if (ok) {
2919
- console.log(chalk.green(" ✓ Port knocking disabled via Azure Run Command — all ports open"));
2920
- sshOk = true;
2921
- } else {
2922
- console.error(chalk.red("\n Run Command failed. Ensure VM agent is running and you have access to the resource group.\n"));
2923
- process.exit(1);
2924
- }
2925
- }
2926
- }
2927
- if (!sshOk) {
2928
- try {
2929
- await trySsh();
2930
- } catch (e) {
2931
- if (rg && vmName) {
2932
- console.error(chalk.red("\n Could not reach VM via SSH and no resource group/vm name for Run Command.\n"));
2933
- } else {
2934
- console.error(chalk.red("\n Could not reach VM via SSH. If knock is broken, ensure state has resourceGroup/vmName and retry (uses az vm run-command).\n"));
2935
- }
2936
- process.exit(1);
2937
- }
2938
- }
2939
-
2940
- writeVmState(state.vmName, { knockSequence: undefined });
2941
- }
2942
-
2943
- export async function azureKnockVerify(opts = {}) {
2944
- const execa = await lazyExeca();
2945
- const { vms } = listVms();
2946
- const targets = opts.vmName ? [opts.vmName] : Object.keys(vms);
2947
-
2948
- if (targets.length === 0) {
2949
- hint("No VMs tracked.\n");
2950
- return;
2951
- }
2952
-
2953
- banner("Knock Verification");
2954
- let issues = 0;
2955
-
2956
- const KNOCK_PORT_RANGE = { min: 49152, max: 49200 };
2957
- const knockRangeStr = `${KNOCK_PORT_RANGE.min}-${KNOCK_PORT_RANGE.max}`;
2958
-
2959
- for (const name of targets) {
2960
- const state = vms[name];
2961
- if (!state) { console.log(ERR(` ✗ ${name}: not tracked`)); issues++; continue; }
2962
- const ip = state.publicIp;
2963
- if (!ip) { console.log(ERR(` ✗ ${name}: no public IP`)); issues++; continue; }
2964
-
2965
- console.log(`\n ${LABEL(name)} ${DIM(`(${ip})`)}`);
2966
-
2967
- // ── Azure NSG (firewall) — no SSH needed ─────────────────────────────────
2968
- const rg = state.resourceGroup;
2969
- const vmName = state.vmName || name;
2970
- if (rg) {
2971
- try {
2972
- const { stdout: nicId } = await execa("az", [
2973
- "vm", "show", "-g", rg, "-n", vmName,
2974
- "--query", "networkProfile.networkInterfaces[0].id", "-o", "tsv",
2975
- ...subArgs(opts.profile),
2976
- ], { timeout: 15000, reject: false });
2977
- if (nicId?.trim()) {
2978
- const { stdout: nsgId } = await execa("az", [
2979
- "network", "nic", "show", "--ids", nicId.trim(),
2980
- "--query", "networkSecurityGroup.id", "-o", "tsv",
2981
- ...subArgs(opts.profile),
2982
- ], { timeout: 10000, reject: false });
2983
- if (nsgId?.trim()) {
2984
- const nsgName = nsgId.trim().split("/").pop();
2985
- const { stdout: rulesJson } = await execa("az", [
2986
- "network", "nsg", "rule", "list", "-g", rg, "--nsg-name", nsgName,
2987
- "--output", "json", ...subArgs(opts.profile),
2988
- ], { timeout: 10000, reject: false });
2989
- const rules = rulesJson ? JSON.parse(rulesJson) : [];
2990
- const allow = rules.filter((r) => r.direction === "Inbound" && r.access === "Allow");
2991
- const has22 = allow.some((r) =>
2992
- r.destinationPortRange === "22" || (r.destinationPortRanges || []).includes("22")
2993
- );
2994
- const hasKnockRange = allow.some((r) =>
2995
- r.destinationPortRange === knockRangeStr ||
2996
- (r.destinationPortRanges || []).some((p) => {
2997
- const n = parseInt(p, 10);
2998
- return n >= KNOCK_PORT_RANGE.min && n <= KNOCK_PORT_RANGE.max;
2999
- })
3000
- );
3001
- if (has22 && hasKnockRange) {
3002
- console.log(OK(` ✓ NSG "${nsgName}": SSH (22) + knock range (${knockRangeStr}) allowed`));
3003
- } else {
3004
- if (!has22) { console.log(ERR(` ✗ NSG: no rule allowing port 22`)); issues++; }
3005
- if (!hasKnockRange) { console.log(ERR(` ✗ NSG: no rule allowing knock ports ${knockRangeStr}`)); issues++; }
3006
- hint(` Fix: fops azure up ${name} (reconciles NSG rules)`);
3007
- }
3008
- } else {
3009
- console.log(WARN(" ⚠ No NSG on NIC — SSH/knock not restricted at Azure level"));
3010
- }
3011
- }
3012
- } catch (e) {
3013
- console.log(WARN(` ⚠ Could not check NSG: ${e.message || e}`));
3014
- }
3015
- }
3016
-
3017
- // Check local config
3018
- if (!state.knockSequence?.length) {
3019
- console.log(WARN(" ⚠ No knock sequence stored locally"));
3020
- console.log(DIM(" Fix: fops azure up " + name));
3021
- issues++;
3022
- continue;
3023
- }
3024
- console.log(OK(` ✓ Local sequence: ${state.knockSequence.join(" → ")}`));
3025
-
3026
- // Knock to get SSH access for diagnostics
3027
- await performKnock(ip, state.knockSequence, { quiet: true });
3028
-
3029
- const ssh = (cmd) => sshCmd(execa, ip, DEFAULTS.adminUser, cmd, 10000);
3030
-
3031
- // In-guest checks (may fail if knock/SSH still blocked)
3032
- let sshOk = false;
3033
- try {
3034
- const { exitCode } = await execa("ssh", [
3035
- ...MUX_OPTS(ip, DEFAULTS.adminUser),
3036
- "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
3037
- `${DEFAULTS.adminUser}@${ip}`, "echo ok",
3038
- ], { timeout: 12000, reject: false });
3039
- sshOk = exitCode === 0;
3040
- } catch { sshOk = false; }
3041
- if (!sshOk) {
3042
- console.log(ERR(" ✗ SSH unreachable after knock — check NSG above and sequence match"));
3043
- hint(` Try: fops azure knock open ${name} then fops azure ssh ${name}`);
3044
- hint(` Or: fops azure knock fix ${name} to re-setup knock`);
3045
- issues++;
3046
- continue;
3047
- }
3048
-
3049
- // Check knockd service
3050
- const { stdout: knockdStatus } = await ssh(
3051
- "systemctl is-active knockd 2>/dev/null || echo inactive"
3052
- );
3053
- if (knockdStatus?.trim() === "active") {
3054
- console.log(OK(" ✓ knockd service: active"));
3055
- } else {
3056
- console.log(ERR(" ✗ knockd service: " + (knockdStatus?.trim() || "unknown")));
3057
- console.log(DIM(" Fix: fops azure up " + name));
3058
- issues++;
3059
- }
3060
-
3061
- // Check iptables DROP rules for port 22 and 443
3062
- const { stdout: iptables } = await ssh(
3063
- "sudo iptables -L INPUT -n --line-numbers 2>/dev/null"
3064
- );
3065
- const lines = (iptables || "").split("\n");
3066
-
3067
- for (const port of [22, 443]) {
3068
- const dropRule = lines.find(l => l.includes("DROP") && l.includes(`dpt:${port}`));
3069
- const estRule = lines.find(l => l.includes("ACCEPT") && l.includes(`dpt:${port}`) && l.includes("ESTABLISHED"));
3070
-
3071
- if (dropRule && estRule) {
3072
- const dropNum = parseInt(dropRule.trim());
3073
- const estNum = parseInt(estRule.trim());
3074
- if (estNum < dropNum) {
3075
- console.log(OK(` ✓ Port ${port}: DROP rule #${dropNum}, ESTABLISHED rule #${estNum} (correct order)`));
3076
- } else {
3077
- console.log(ERR(` ✗ Port ${port}: ESTABLISHED rule #${estNum} is AFTER DROP #${dropNum} (wrong order)`));
3078
- issues++;
3079
- }
3080
- } else if (!dropRule) {
3081
- console.log(ERR(` ✗ Port ${port}: no DROP rule — port is wide open`));
3082
- issues++;
3083
- } else if (!estRule) {
3084
- console.log(WARN(` ⚠ Port ${port}: DROP exists but no ESTABLISHED rule (may break active sessions)`));
3085
- issues++;
3086
- }
3087
- }
3088
-
3089
- // Check for broad ACCEPT rules that would override DROP
3090
- const broadAccept = lines.filter(l =>
3091
- l.includes("ACCEPT") &&
3092
- l.includes("0.0.0.0/0") &&
3093
- !l.includes("dpt:") &&
3094
- !l.includes("ESTABLISHED") &&
3095
- !l.includes("RELATED")
3096
- );
3097
- if (broadAccept.length > 0) {
3098
- for (const rule of broadAccept) {
3099
- const ruleNum = parseInt(rule.trim());
3100
- const drop22 = lines.find(l => l.includes("DROP") && l.includes("dpt:22"));
3101
- const dropNum = drop22 ? parseInt(drop22.trim()) : 999;
3102
- if (ruleNum < dropNum) {
3103
- console.log(ERR(` ✗ Broad ACCEPT-all rule #${ruleNum} is before DROP #${dropNum} — bypasses knock!`));
3104
- issues++;
3105
- }
3106
- }
3107
- }
3108
-
3109
- // Check knockd config matches local sequence
3110
- const { stdout: confRaw } = await ssh("sudo cat /etc/knockd.conf 2>/dev/null || echo ''");
3111
- const seqMatch = confRaw?.match(/sequence\s*=\s*([\d,]+)/);
3112
- if (seqMatch) {
3113
- const remoteSeq = seqMatch[1].split(",").map(Number);
3114
- const localSeq = state.knockSequence;
3115
- if (JSON.stringify(remoteSeq) === JSON.stringify(localSeq)) {
3116
- console.log(OK(" ✓ knockd.conf sequence matches local state"));
3117
- } else {
3118
- console.log(ERR(` ✗ Sequence mismatch — remote: [${remoteSeq.join(",")}] vs local: [${localSeq.join(",")}]`));
3119
- console.log(DIM(" Fix: fops azure knock disable " + name + " && fops azure up " + name));
3120
- issues++;
3121
- }
3122
- } else {
3123
- console.log(ERR(" ✗ Could not read /etc/knockd.conf"));
3124
- issues++;
3125
- }
3126
-
3127
- // Close the door after verification
3128
- await closeKnock(ssh, { quiet: true });
3129
- }
3130
-
3131
- console.log("");
3132
- if (issues > 0) {
3133
- console.log(ERR(` ${issues} issue(s) found.`));
3134
- hint("To re-setup knock on all VMs: fops azure knock fix\n");
3135
- } else {
3136
- console.log(OK(" ✓ All VMs properly configured — ports are blocked until knocked.\n"));
3137
- }
3138
- }
3139
-
3140
- export async function azureKnockFix(opts = {}) {
3141
- const execa = await lazyExeca();
3142
- const { generateKnockSequence } = await import("./port-knock.js");
3143
- const { vms } = listVms();
3144
- const targets = opts.vmName ? [opts.vmName] : Object.keys(vms);
3145
-
3146
- if (targets.length === 0) {
3147
- hint("No VMs tracked.\n");
3148
- return;
3149
- }
3150
-
3151
- await ensureAzCli(execa);
3152
- await ensureAzAuth(execa, { subscription: opts.profile });
3153
-
3154
- banner("Knock Fix");
3155
-
3156
- for (const name of targets) {
3157
- const state = vms[name];
3158
- if (!state?.resourceGroup) {
3159
- console.log(chalk.dim(` ${name}: no resource group — skipped`));
3160
- continue;
3161
- }
3162
- const { publicIp: ip, resourceGroup: rg } = state;
3163
-
3164
- console.log(`\n ${LABEL(name)} ${DIM(`(${ip || "no IP"})`)}`);
3165
-
3166
- const knockSeq = state.knockSequence?.length ? state.knockSequence : generateKnockSequence();
3167
- const appPort = DEFAULTS.publicPort || 443;
3168
-
3169
- // Build the setup script to run via az vm run-command (works even when SSH is locked)
3170
- const script = buildKnockdScript(knockSeq, appPort);
3171
-
3172
- console.log(chalk.dim(" Running via az vm run-command (bypasses firewall)..."));
3173
- const { stdout, exitCode } = await execa("az", [
3174
- "vm", "run-command", "invoke",
3175
- "--resource-group", rg,
3176
- "--name", name,
3177
- "--command-id", "RunShellScript",
3178
- "--scripts", script,
3179
- "--output", "json",
3180
- ...subArgs(opts.profile),
3181
- ], { reject: false, timeout: 180000 });
3182
-
3183
- if (exitCode !== 0) {
3184
- console.error(ERR(` ✗ az run-command failed for ${name}`));
3185
- continue;
3186
- }
3187
-
3188
- // Check script output for errors
3189
- try {
3190
- const result = JSON.parse(stdout);
3191
- const msg = result?.value?.[0]?.message || "";
3192
- if (msg.toLowerCase().includes("failed") || msg.toLowerCase().includes("error")) {
3193
- console.log(chalk.dim(` Script output: ${msg.slice(0, 200)}`));
3194
- }
3195
- } catch { /* ignore parse errors */ }
3196
-
3197
- writeVmState(name, { knockSequence: knockSeq });
3198
- await setVmKnockTag(execa, rg, name, knockSeq, opts.profile);
3199
-
3200
- console.log(OK(` ✓ Knock re-configured (sequence: ${knockSeq.join(" → ")})`));
3201
- }
3202
-
3203
- console.log(OK(`\n ✓ Done — run fops azure knock verify to confirm.\n`));
3204
- }
3205
-
3206
- /**
3207
- * Build a self-contained bash script that installs knockd and configures
3208
- * iptables. Designed to run via az vm run-command (no SSH needed).
3209
- */
3210
- function buildKnockdScript(sequence, appPort) {
3211
- const ports = [22];
3212
- if (appPort && appPort !== 22) ports.push(Number(appPort));
3213
-
3214
- const openCmds = ports.map(p => `/usr/sbin/iptables -I INPUT -s %IP% -p tcp --dport ${p} -j ACCEPT`).join(" && ");
3215
- const closeCmds = ports.map(p => `/usr/sbin/iptables -D INPUT -s %IP% -p tcp --dport ${p} -j ACCEPT`).join(" && ");
3216
- const KNOCK_CMD_TIMEOUT = 300;
3217
-
3218
- // iptables rules: clean up stale entries, then insert DROP + ESTABLISHED
3219
- const iptRules = [];
3220
- for (const p of ports) {
3221
- iptRules.push(
3222
- `while iptables -D INPUT -p tcp --dport ${p} -j DROP 2>/dev/null; do :; done`,
3223
- `while iptables -D INPUT -p tcp --dport ${p} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; do :; done`,
3224
- `while iptables -D INPUT -p tcp --dport ${p} -j ACCEPT 2>/dev/null; do :; done`,
3225
- );
3226
- }
3227
- for (const p of ports) {
3228
- iptRules.push(
3229
- `iptables -I INPUT -p tcp --dport ${p} -j DROP`,
3230
- `iptables -I INPUT -p tcp --dport ${p} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`,
3231
- );
3232
- }
3233
-
3234
- return [
3235
- "#!/bin/bash",
3236
- "set -e",
3237
- // Detect interface dynamically inside the script
3238
- "IFACE=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1)",
3239
- "IFACE=${IFACE:-eth0}",
3240
- // Install knockd
3241
- "export DEBIAN_FRONTEND=noninteractive",
3242
- "apt-get update -qq",
3243
- "apt-get install -y -qq knockd iptables-persistent 2>/dev/null || true",
3244
- // Write knockd.conf using printf to avoid heredoc quoting issues
3245
- `printf '[options]\\n UseSyslog\\n Interface = %s\\n\\n[openPorts]\\n sequence = ${sequence.join(",")}\\n seq_timeout = 15\\n command = ${openCmds}\\n tcpflags = syn\\n cmd_timeout = ${KNOCK_CMD_TIMEOUT}\\n stop_command = ${closeCmds}\\n' "$IFACE" > /etc/knockd.conf`,
3246
- // Enable knockd service
3247
- "sed -i 's/^START_KNOCKD=0/START_KNOCKD=1/' /etc/default/knockd 2>/dev/null || true",
3248
- `sed -i "s|^KNOCKD_OPTS=.*|KNOCKD_OPTS=\\"-i $IFACE\\"|" /etc/default/knockd 2>/dev/null || true`,
3249
- // Apply iptables rules
3250
- ...iptRules,
3251
- "netfilter-persistent save 2>/dev/null || true",
3252
- "systemctl enable knockd",
3253
- "systemctl restart knockd",
3254
- "echo 'knockd configured OK'",
3255
- ].join("\n");
3256
- }
3257
-
3258
- // ── ssh whitelist-me ─────────────────────────────────────────────────────────
3259
-
3260
- export async function azureSshWhitelistMe(opts = {}) {
3261
- const execa = await lazyExeca();
3262
- const sub = opts.profile;
3263
- await ensureAzCli(execa);
3264
- await ensureAzAuth(execa, { subscription: sub });
3265
- const state = requireVmState(opts.vmName);
3266
- const { vmName, resourceGroup: rg } = state;
3267
-
3268
- const myIp = await fetchMyIp();
3269
- if (!myIp) {
3270
- console.error(ERR("\n Could not detect your public IP address.\n"));
3271
- process.exit(1);
3272
- }
3273
- const myCidr = `${myIp}/32`;
3274
-
3275
- // Resolve NSG name from NIC
3276
- const nicName = `${vmName}VMNic`;
3277
- const { stdout: nicJson, exitCode: nicCode } = await execa("az", [
3278
- "network", "nic", "show", "-g", rg, "-n", nicName, "--output", "json",
3279
- ...subArgs(sub),
3280
- ], { reject: false, timeout: 15000 });
3281
- let nsgName = `${vmName}NSG`;
3282
- if (nicCode === 0) {
3283
- const nsgId = JSON.parse(nicJson).networkSecurityGroup?.id || "";
3284
- if (nsgId) nsgName = nsgId.split("/").pop();
3285
- }
3286
-
3287
- // Fetch current SSH rule
3288
- const { stdout: rulesJson, exitCode: rulesCode } = await execa("az", [
3289
- "network", "nsg", "rule", "list", "-g", rg, "--nsg-name", nsgName, "--output", "json",
3290
- ...subArgs(sub),
3291
- ], { reject: false, timeout: 15000 });
3292
- const rules = rulesCode === 0 ? JSON.parse(rulesJson || "[]") : [];
3293
- const inboundAllow = rules.filter(r => r.access === "Allow" && r.direction === "Inbound");
3294
- const sshRule = inboundAllow.find(r =>
3295
- r.destinationPortRange === "22" ||
3296
- (Array.isArray(r.destinationPortRanges) && r.destinationPortRanges.includes("22"))
3297
- );
3298
-
3299
- const currentSources = [];
3300
- if (sshRule?.sourceAddressPrefix) currentSources.push(sshRule.sourceAddressPrefix);
3301
- if (Array.isArray(sshRule?.sourceAddressPrefixes)) currentSources.push(...sshRule.sourceAddressPrefixes);
1729
+ // ── knock & ssh whitelist (split to azure-ops-knock.js) ─────────────────────
3302
1730
 
3303
- if (currentSources.includes(myCidr)) {
3304
- console.log(OK(`\n ✓ ${myCidr} is already whitelisted for SSH on ${vmName}\n`));
3305
- return;
3306
- }
3307
-
3308
- const merged = [...new Set([...currentSources.filter(s => s && s !== "*" && s !== "Internet"), myCidr])];
3309
- console.log(chalk.yellow(` ↻ Adding ${myCidr} to SSH rule on ${nsgName} (${currentSources.length} existing)...`));
3310
- const { exitCode: updateCode } = await execa("az", [
3311
- "network", "nsg", "rule", "create", "-g", rg, "--nsg-name", nsgName,
3312
- "-n", sshRule?.name || "allow-ssh", "--priority", String(sshRule?.priority || 1000),
3313
- "--destination-port-ranges", "22", "--access", "Allow",
3314
- "--source-address-prefixes", ...merged,
3315
- "--protocol", "Tcp", "--direction", "Inbound", "--output", "none",
3316
- ...subArgs(sub),
3317
- ], { reject: false, timeout: 30000 });
3318
-
3319
- if (updateCode !== 0) {
3320
- console.error(ERR(`\n Failed to update NSG rule on ${nsgName}\n`));
3321
- process.exit(1);
3322
- }
3323
- console.log(OK(`\n ✓ SSH (22) whitelisted for ${myCidr} on ${vmName} (${nsgName})\n`));
3324
- console.log(` Sources: ${merged.join(", ")}\n`);
3325
- }
1731
+ export {
1732
+ azureKnock,
1733
+ azureKnockClose,
1734
+ azureKnockDisable,
1735
+ azureKnockVerify,
1736
+ azureKnockFix,
1737
+ azureSshWhitelistMe,
1738
+ } from "./azure-ops-knock.js";