hackerrun 0.1.9 → 0.1.11
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/.claude/settings.local.json +2 -1
- package/dist/index.js +1007 -1017
- package/package.json +1 -1
- package/src/commands/scale.ts +158 -153
- package/src/lib/cluster.ts +15 -20
package/dist/index.js
CHANGED
|
@@ -537,17 +537,592 @@ var TunnelManager = class {
|
|
|
537
537
|
}
|
|
538
538
|
};
|
|
539
539
|
|
|
540
|
+
// src/lib/uncloud-runner.ts
|
|
541
|
+
import { execSync as execSync3, spawnSync as spawnSync3 } from "child_process";
|
|
542
|
+
|
|
543
|
+
// src/lib/vpn.ts
|
|
544
|
+
import { execSync as execSync2, spawnSync as spawnSync2 } from "child_process";
|
|
545
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, unlinkSync } from "fs";
|
|
546
|
+
import { homedir as homedir2, platform } from "os";
|
|
547
|
+
import { join as join3 } from "path";
|
|
548
|
+
import chalk2 from "chalk";
|
|
549
|
+
function detectOS() {
|
|
550
|
+
const os = platform();
|
|
551
|
+
if (os === "darwin") {
|
|
552
|
+
return { type: "macos" };
|
|
553
|
+
}
|
|
554
|
+
if (os === "win32") {
|
|
555
|
+
return { type: "windows" };
|
|
556
|
+
}
|
|
557
|
+
if (os === "linux") {
|
|
558
|
+
try {
|
|
559
|
+
if (existsSync3("/etc/os-release")) {
|
|
560
|
+
const osRelease = readFileSync3("/etc/os-release", "utf-8");
|
|
561
|
+
const idMatch = osRelease.match(/^ID=(.*)$/m);
|
|
562
|
+
if (idMatch) {
|
|
563
|
+
const distro = idMatch[1].replace(/"/g, "").toLowerCase();
|
|
564
|
+
return { type: "linux", distro };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} catch {
|
|
568
|
+
}
|
|
569
|
+
return { type: "linux" };
|
|
570
|
+
}
|
|
571
|
+
return { type: "unknown" };
|
|
572
|
+
}
|
|
573
|
+
function getWireGuardInstallInstructions() {
|
|
574
|
+
const os = detectOS();
|
|
575
|
+
switch (os.type) {
|
|
576
|
+
case "macos":
|
|
577
|
+
return ` ${chalk2.cyan("macOS:")} brew install wireguard-tools`;
|
|
578
|
+
case "windows":
|
|
579
|
+
return ` ${chalk2.cyan("Windows:")} Download from https://www.wireguard.com/install/
|
|
580
|
+
Or using winget: winget install WireGuard.WireGuard`;
|
|
581
|
+
case "linux":
|
|
582
|
+
switch (os.distro) {
|
|
583
|
+
case "arch":
|
|
584
|
+
case "manjaro":
|
|
585
|
+
case "endeavouros":
|
|
586
|
+
case "artix":
|
|
587
|
+
return ` ${chalk2.cyan("Arch Linux:")} sudo pacman -S wireguard-tools`;
|
|
588
|
+
case "ubuntu":
|
|
589
|
+
case "debian":
|
|
590
|
+
case "linuxmint":
|
|
591
|
+
case "pop":
|
|
592
|
+
case "elementary":
|
|
593
|
+
case "zorin":
|
|
594
|
+
return ` ${chalk2.cyan("Ubuntu/Debian:")} sudo apt install wireguard`;
|
|
595
|
+
case "fedora":
|
|
596
|
+
return ` ${chalk2.cyan("Fedora:")} sudo dnf install wireguard-tools`;
|
|
597
|
+
case "rhel":
|
|
598
|
+
case "centos":
|
|
599
|
+
case "rocky":
|
|
600
|
+
case "almalinux":
|
|
601
|
+
return ` ${chalk2.cyan("RHEL/CentOS:")} sudo dnf install wireguard-tools
|
|
602
|
+
(may need EPEL: sudo dnf install epel-release)`;
|
|
603
|
+
case "opensuse":
|
|
604
|
+
case "opensuse-leap":
|
|
605
|
+
case "opensuse-tumbleweed":
|
|
606
|
+
return ` ${chalk2.cyan("openSUSE:")} sudo zypper install wireguard-tools`;
|
|
607
|
+
case "gentoo":
|
|
608
|
+
return ` ${chalk2.cyan("Gentoo:")} sudo emerge net-vpn/wireguard-tools`;
|
|
609
|
+
case "void":
|
|
610
|
+
return ` ${chalk2.cyan("Void Linux:")} sudo xbps-install wireguard-tools`;
|
|
611
|
+
case "alpine":
|
|
612
|
+
return ` ${chalk2.cyan("Alpine:")} sudo apk add wireguard-tools`;
|
|
613
|
+
case "nixos":
|
|
614
|
+
return ` ${chalk2.cyan("NixOS:")} Add wireguard-tools to environment.systemPackages`;
|
|
615
|
+
default:
|
|
616
|
+
return ` ${chalk2.cyan("Linux:")} Install wireguard-tools using your package manager:
|
|
617
|
+
Arch: sudo pacman -S wireguard-tools
|
|
618
|
+
Ubuntu/Debian: sudo apt install wireguard
|
|
619
|
+
Fedora: sudo dnf install wireguard-tools
|
|
620
|
+
openSUSE: sudo zypper install wireguard-tools`;
|
|
621
|
+
}
|
|
622
|
+
default:
|
|
623
|
+
return ` Visit https://www.wireguard.com/install/ for installation instructions`;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
var CONFIG_DIR = join3(homedir2(), ".config", "hackerrun");
|
|
627
|
+
var PRIVATE_KEY_FILE = join3(CONFIG_DIR, "wg-private-key");
|
|
628
|
+
var WG_INTERFACE = "hackerrun";
|
|
629
|
+
var WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
|
|
630
|
+
function isWireGuardInstalled() {
|
|
631
|
+
try {
|
|
632
|
+
execSync2("which wg", { stdio: "ignore" });
|
|
633
|
+
execSync2("which wg-quick", { stdio: "ignore" });
|
|
634
|
+
return true;
|
|
635
|
+
} catch {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function ensureConfigDir() {
|
|
640
|
+
if (!existsSync3(CONFIG_DIR)) {
|
|
641
|
+
mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function generateKeyPair() {
|
|
645
|
+
const privateKey = execSync2("wg genkey", { encoding: "utf-8" }).trim();
|
|
646
|
+
const publicKey = execSync2("wg pubkey", {
|
|
647
|
+
input: privateKey,
|
|
648
|
+
encoding: "utf-8"
|
|
649
|
+
}).trim();
|
|
650
|
+
return { privateKey, publicKey };
|
|
651
|
+
}
|
|
652
|
+
function getOrCreateKeyPair() {
|
|
653
|
+
ensureConfigDir();
|
|
654
|
+
if (existsSync3(PRIVATE_KEY_FILE)) {
|
|
655
|
+
const privateKey2 = readFileSync3(PRIVATE_KEY_FILE, "utf-8").trim();
|
|
656
|
+
const publicKey2 = execSync2("wg pubkey", {
|
|
657
|
+
input: privateKey2,
|
|
658
|
+
encoding: "utf-8"
|
|
659
|
+
}).trim();
|
|
660
|
+
return { privateKey: privateKey2, publicKey: publicKey2, isNew: false };
|
|
661
|
+
}
|
|
662
|
+
const { privateKey, publicKey } = generateKeyPair();
|
|
663
|
+
writeFileSync3(PRIVATE_KEY_FILE, privateKey + "\n", { mode: 384 });
|
|
664
|
+
return { privateKey, publicKey, isNew: true };
|
|
665
|
+
}
|
|
666
|
+
function getPublicKey() {
|
|
667
|
+
if (!existsSync3(PRIVATE_KEY_FILE)) {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
const privateKey = readFileSync3(PRIVATE_KEY_FILE, "utf-8").trim();
|
|
671
|
+
return execSync2("wg pubkey", {
|
|
672
|
+
input: privateKey,
|
|
673
|
+
encoding: "utf-8"
|
|
674
|
+
}).trim();
|
|
675
|
+
}
|
|
676
|
+
function isVPNUp() {
|
|
677
|
+
try {
|
|
678
|
+
const result = execSync2(`ip link show ${WG_INTERFACE}`, {
|
|
679
|
+
encoding: "utf-8",
|
|
680
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
681
|
+
});
|
|
682
|
+
return result.includes(WG_INTERFACE);
|
|
683
|
+
} catch {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function isRoutedViaVPN(ipv6Address) {
|
|
688
|
+
if (!isVPNUp()) {
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
const result = execSync2(`ip -6 route get ${ipv6Address}`, {
|
|
693
|
+
encoding: "utf-8",
|
|
694
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
695
|
+
});
|
|
696
|
+
return result.includes(`dev ${WG_INTERFACE}`);
|
|
697
|
+
} catch {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function getVPNStatus() {
|
|
702
|
+
if (!isVPNUp()) {
|
|
703
|
+
return { connected: false };
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
const output = execSync2(`sudo wg show ${WG_INTERFACE}`, {
|
|
707
|
+
encoding: "utf-8",
|
|
708
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
709
|
+
});
|
|
710
|
+
const lines = output.split("\n");
|
|
711
|
+
let endpoint;
|
|
712
|
+
let latestHandshake;
|
|
713
|
+
let transferRx;
|
|
714
|
+
let transferTx;
|
|
715
|
+
for (const line of lines) {
|
|
716
|
+
if (line.includes("endpoint:")) {
|
|
717
|
+
endpoint = line.split("endpoint:")[1]?.trim();
|
|
718
|
+
}
|
|
719
|
+
if (line.includes("latest handshake:")) {
|
|
720
|
+
latestHandshake = line.split("latest handshake:")[1]?.trim();
|
|
721
|
+
}
|
|
722
|
+
if (line.includes("transfer:")) {
|
|
723
|
+
const transfer = line.split("transfer:")[1]?.trim();
|
|
724
|
+
const parts = transfer?.split(",");
|
|
725
|
+
transferRx = parts?.[0]?.trim();
|
|
726
|
+
transferTx = parts?.[1]?.trim();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
connected: true,
|
|
731
|
+
interface: WG_INTERFACE,
|
|
732
|
+
endpoint,
|
|
733
|
+
latestHandshake,
|
|
734
|
+
transferRx,
|
|
735
|
+
transferTx
|
|
736
|
+
};
|
|
737
|
+
} catch {
|
|
738
|
+
return { connected: isVPNUp(), interface: WG_INTERFACE };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function generateWireGuardConfig(config) {
|
|
742
|
+
return `# HackerRun VPN - Auto-generated
|
|
743
|
+
# Do not edit manually
|
|
744
|
+
|
|
745
|
+
[Interface]
|
|
746
|
+
PrivateKey = ${config.privateKey}
|
|
747
|
+
Address = ${config.address}
|
|
748
|
+
|
|
749
|
+
[Peer]
|
|
750
|
+
PublicKey = ${config.gatewayPublicKey}
|
|
751
|
+
Endpoint = ${config.gatewayEndpoint}
|
|
752
|
+
AllowedIPs = ${config.allowedIPs}
|
|
753
|
+
PersistentKeepalive = 25
|
|
754
|
+
`;
|
|
755
|
+
}
|
|
756
|
+
function writeWireGuardConfig(config) {
|
|
757
|
+
const configContent = generateWireGuardConfig(config);
|
|
758
|
+
const tempFile = join3(CONFIG_DIR, "wg-temp.conf");
|
|
759
|
+
writeFileSync3(tempFile, configContent, { mode: 384 });
|
|
760
|
+
try {
|
|
761
|
+
execSync2("sudo mkdir -p /etc/wireguard", { stdio: "inherit" });
|
|
762
|
+
execSync2(`sudo mv ${tempFile} ${WG_CONFIG_PATH}`, { stdio: "inherit" });
|
|
763
|
+
execSync2(`sudo chmod 600 ${WG_CONFIG_PATH}`, { stdio: "inherit" });
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (existsSync3(tempFile)) {
|
|
766
|
+
unlinkSync(tempFile);
|
|
767
|
+
}
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
function vpnUp() {
|
|
772
|
+
if (isVPNUp()) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const result = spawnSync2("sudo", ["wg-quick", "up", WG_INTERFACE], {
|
|
776
|
+
stdio: "inherit"
|
|
777
|
+
});
|
|
778
|
+
if (result.status !== 0) {
|
|
779
|
+
throw new Error(`Failed to bring up VPN interface (exit code ${result.status})`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
function vpnDown() {
|
|
783
|
+
if (!isVPNUp()) {
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const result = spawnSync2("sudo", ["wg-quick", "down", WG_INTERFACE], {
|
|
787
|
+
stdio: "inherit"
|
|
788
|
+
});
|
|
789
|
+
if (result.status !== 0) {
|
|
790
|
+
throw new Error(`Failed to bring down VPN interface (exit code ${result.status})`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function testIPv6Connectivity2(ipv6Address, timeoutSeconds = 3) {
|
|
794
|
+
try {
|
|
795
|
+
execSync2(`ping -6 -c 1 -W ${timeoutSeconds} ${ipv6Address}`, {
|
|
796
|
+
stdio: "ignore",
|
|
797
|
+
timeout: (timeoutSeconds + 2) * 1e3
|
|
798
|
+
});
|
|
799
|
+
return true;
|
|
800
|
+
} catch {
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
var VPNManager = class {
|
|
805
|
+
wasConnectedByUs = false;
|
|
806
|
+
/**
|
|
807
|
+
* Ensure VPN is connected if IPv6 is not available
|
|
808
|
+
* Returns true if VPN was established (and should be torn down later)
|
|
809
|
+
*/
|
|
810
|
+
async ensureConnected(targetIPv6, getVPNConfig) {
|
|
811
|
+
if (testIPv6Connectivity2(targetIPv6)) {
|
|
812
|
+
console.log(chalk2.green("\u2713 Direct IPv6 connectivity available"));
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
console.log(chalk2.yellow("IPv6 not available, checking VPN..."));
|
|
816
|
+
if (isVPNUp()) {
|
|
817
|
+
if (testIPv6Connectivity2(targetIPv6)) {
|
|
818
|
+
console.log(chalk2.green("\u2713 VPN already connected"));
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
console.log(chalk2.yellow("VPN is up but cannot reach target, reconnecting..."));
|
|
822
|
+
vpnDown();
|
|
823
|
+
}
|
|
824
|
+
if (!isWireGuardInstalled()) {
|
|
825
|
+
const instructions = getWireGuardInstallInstructions();
|
|
826
|
+
throw new Error(
|
|
827
|
+
"WireGuard is not installed.\n\nWireGuard is needed for IPv6 connectivity to your app VMs.\nPlease install it and try again:\n\n" + instructions
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
console.log(chalk2.cyan("Establishing VPN tunnel (requires sudo)..."));
|
|
831
|
+
const { publicKey, isNew } = getOrCreateKeyPair();
|
|
832
|
+
if (isNew) {
|
|
833
|
+
console.log(chalk2.dim("Generated new WireGuard keypair"));
|
|
834
|
+
}
|
|
835
|
+
const vpnConfig = await getVPNConfig();
|
|
836
|
+
writeWireGuardConfig(vpnConfig);
|
|
837
|
+
vpnUp();
|
|
838
|
+
if (!testIPv6Connectivity2(targetIPv6)) {
|
|
839
|
+
vpnDown();
|
|
840
|
+
throw new Error("VPN established but cannot reach target. Please try again.");
|
|
841
|
+
}
|
|
842
|
+
console.log(chalk2.green("\u2713 VPN connected"));
|
|
843
|
+
this.wasConnectedByUs = true;
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Disconnect VPN if we established it
|
|
848
|
+
*/
|
|
849
|
+
disconnect() {
|
|
850
|
+
if (this.wasConnectedByUs && isVPNUp()) {
|
|
851
|
+
console.log(chalk2.dim("Disconnecting VPN..."));
|
|
852
|
+
try {
|
|
853
|
+
vpnDown();
|
|
854
|
+
console.log(chalk2.green("\u2713 VPN disconnected"));
|
|
855
|
+
} catch (error) {
|
|
856
|
+
console.log(chalk2.yellow(`Warning: Failed to disconnect VPN: ${error.message}`));
|
|
857
|
+
}
|
|
858
|
+
this.wasConnectedByUs = false;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
// src/lib/uncloud-runner.ts
|
|
864
|
+
import chalk3 from "chalk";
|
|
865
|
+
var UncloudRunner = class {
|
|
866
|
+
constructor(platformClient) {
|
|
867
|
+
this.platformClient = platformClient;
|
|
868
|
+
this.certManager = new SSHCertManager(platformClient);
|
|
869
|
+
}
|
|
870
|
+
certManager;
|
|
871
|
+
gatewayCache = /* @__PURE__ */ new Map();
|
|
872
|
+
tunnelManager = new TunnelManager();
|
|
873
|
+
vpnManager = new VPNManager();
|
|
874
|
+
tempConfigPath = null;
|
|
875
|
+
vpnEstablishedByUs = false;
|
|
876
|
+
/**
|
|
877
|
+
* Get the connection URL for an app's primary VM
|
|
878
|
+
* Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
|
|
879
|
+
*/
|
|
880
|
+
async getConnectionInfo(appName) {
|
|
881
|
+
const app = await this.platformClient.getApp(appName);
|
|
882
|
+
if (!app) {
|
|
883
|
+
throw new Error(`App '${appName}' not found`);
|
|
884
|
+
}
|
|
885
|
+
const primaryNode = app.nodes.find((n) => n.isPrimary);
|
|
886
|
+
if (!primaryNode?.ipv6) {
|
|
887
|
+
throw new Error(`App '${appName}' has no primary node with IPv6 address`);
|
|
888
|
+
}
|
|
889
|
+
const vmIp = primaryNode.ipv6;
|
|
890
|
+
await this.certManager.getSession(appName, vmIp);
|
|
891
|
+
const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
|
|
892
|
+
if (canConnectDirect) {
|
|
893
|
+
const viaVPN = isRoutedViaVPN(vmIp);
|
|
894
|
+
return {
|
|
895
|
+
url: `ssh://root@${vmIp}`,
|
|
896
|
+
vmIp,
|
|
897
|
+
viaGateway: false,
|
|
898
|
+
viaVPN
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
const vpnAvailable = isWireGuardInstalled();
|
|
902
|
+
if (vpnAvailable) {
|
|
903
|
+
console.log(chalk3.yellow("Direct IPv6 not available, trying VPN..."));
|
|
904
|
+
try {
|
|
905
|
+
if (isVPNUp() && testIPv6Connectivity2(vmIp, 3)) {
|
|
906
|
+
console.log(chalk3.green("\u2713 VPN already connected"));
|
|
907
|
+
return {
|
|
908
|
+
url: `ssh://root@${vmIp}`,
|
|
909
|
+
vmIp,
|
|
910
|
+
viaGateway: false,
|
|
911
|
+
viaVPN: true
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
this.vpnEstablishedByUs = await this.vpnManager.ensureConnected(
|
|
915
|
+
vmIp,
|
|
916
|
+
async () => this.getVPNConfig(app.location)
|
|
917
|
+
);
|
|
918
|
+
if (testIPv6Connectivity2(vmIp, 5)) {
|
|
919
|
+
console.log(chalk3.green("\u2713 Connected via VPN"));
|
|
920
|
+
return {
|
|
921
|
+
url: `ssh://root@${vmIp}`,
|
|
922
|
+
vmIp,
|
|
923
|
+
viaGateway: false,
|
|
924
|
+
viaVPN: true
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
} catch (error) {
|
|
928
|
+
console.log(chalk3.yellow(`VPN setup failed: ${error.message}`));
|
|
929
|
+
console.log(chalk3.dim("Falling back to SSH tunnel..."));
|
|
930
|
+
}
|
|
931
|
+
} else {
|
|
932
|
+
console.log(chalk3.yellow("Direct IPv6 not available on your network."));
|
|
933
|
+
console.log(chalk3.dim("For faster deploys, install WireGuard:"));
|
|
934
|
+
console.log(chalk3.dim(getWireGuardInstallInstructions()));
|
|
935
|
+
console.log();
|
|
936
|
+
}
|
|
937
|
+
console.log(chalk3.yellow("Using SSH tunnel (may be slower for large transfers)..."));
|
|
938
|
+
const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
|
|
939
|
+
return {
|
|
940
|
+
url: `ssh://root@localhost:${tunnelInfo.localPort}`,
|
|
941
|
+
vmIp,
|
|
942
|
+
viaGateway: true,
|
|
943
|
+
viaVPN: false,
|
|
944
|
+
localPort: tunnelInfo.localPort
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Get VPN configuration from platform
|
|
949
|
+
*/
|
|
950
|
+
async getVPNConfig(location) {
|
|
951
|
+
const { privateKey, publicKey } = getOrCreateKeyPair();
|
|
952
|
+
const vpnConfigResponse = await this.platformClient.registerVPNPeer(publicKey, location);
|
|
953
|
+
return {
|
|
954
|
+
privateKey,
|
|
955
|
+
publicKey,
|
|
956
|
+
address: vpnConfigResponse.address,
|
|
957
|
+
gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
|
|
958
|
+
gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
|
|
959
|
+
allowedIPs: vpnConfigResponse.allowedIPs
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Pre-accept a host's SSH key by running ssh-keyscan
|
|
964
|
+
* This prevents "Host key verification failed" errors from uncloud
|
|
965
|
+
*/
|
|
966
|
+
preAcceptHostKey(host, port) {
|
|
967
|
+
try {
|
|
968
|
+
const portArg = port ? `-p ${port}` : "";
|
|
969
|
+
execSync3(
|
|
970
|
+
`ssh-keyscan ${portArg} -H ${host} >> ~/.ssh/known_hosts 2>/dev/null`,
|
|
971
|
+
{ stdio: "pipe", timeout: 1e4 }
|
|
972
|
+
);
|
|
973
|
+
} catch {
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Ensure an SSH tunnel exists for gateway fallback
|
|
978
|
+
*/
|
|
979
|
+
async ensureTunnel(appName, vmIp, location) {
|
|
980
|
+
const gateway = await this.getGateway(location);
|
|
981
|
+
if (!gateway) {
|
|
982
|
+
throw new Error(`No gateway found for location ${location}`);
|
|
983
|
+
}
|
|
984
|
+
return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Run an uncloud command on the app's VM
|
|
988
|
+
*/
|
|
989
|
+
async run(appName, command, args = [], options = {}) {
|
|
990
|
+
const connInfo = await this.getConnectionInfo(appName);
|
|
991
|
+
if (connInfo.viaGateway) {
|
|
992
|
+
console.log(chalk3.dim(`Connecting via gateway...`));
|
|
993
|
+
}
|
|
994
|
+
const fullArgs = ["--connect", connInfo.url, command, ...args];
|
|
995
|
+
if (options.stdio === "inherit") {
|
|
996
|
+
const result = spawnSync3("uc", fullArgs, {
|
|
997
|
+
cwd: options.cwd,
|
|
998
|
+
stdio: "inherit",
|
|
999
|
+
timeout: options.timeout
|
|
1000
|
+
});
|
|
1001
|
+
if (result.status !== 0) {
|
|
1002
|
+
throw new Error(`Uncloud command failed with exit code ${result.status}`);
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
const result = execSync3(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
|
|
1006
|
+
cwd: options.cwd,
|
|
1007
|
+
encoding: "utf-8",
|
|
1008
|
+
timeout: options.timeout
|
|
1009
|
+
});
|
|
1010
|
+
return result;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Run 'uc deploy' for an app
|
|
1015
|
+
*/
|
|
1016
|
+
async deploy(appName, cwd) {
|
|
1017
|
+
await this.run(appName, "deploy", ["--yes"], { cwd, stdio: "inherit" });
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Run 'uc service logs' for an app
|
|
1021
|
+
*/
|
|
1022
|
+
async logs(appName, serviceName, options = {}) {
|
|
1023
|
+
const args = [serviceName];
|
|
1024
|
+
if (options.follow) args.push("-f");
|
|
1025
|
+
if (options.tail) args.push("--tail", String(options.tail));
|
|
1026
|
+
await this.run(appName, "service", ["logs", ...args], { stdio: "inherit" });
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Run 'uc service ls' for an app
|
|
1030
|
+
*/
|
|
1031
|
+
async serviceList(appName) {
|
|
1032
|
+
const result = await this.run(appName, "service", ["ls"], { stdio: "pipe" });
|
|
1033
|
+
return result;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Run 'uc machine token' on remote VM via SSH
|
|
1037
|
+
* This is different - it runs on the VM directly, not via uncloud connector
|
|
1038
|
+
*/
|
|
1039
|
+
async getMachineToken(appName) {
|
|
1040
|
+
const connInfo = await this.getConnectionInfo(appName);
|
|
1041
|
+
let sshCmd2;
|
|
1042
|
+
if (connInfo.viaGateway && connInfo.localPort) {
|
|
1043
|
+
sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
|
|
1044
|
+
} else {
|
|
1045
|
+
sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
|
|
1046
|
+
}
|
|
1047
|
+
const token = execSync3(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
|
|
1048
|
+
return token;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Execute an SSH command on the VM
|
|
1052
|
+
*/
|
|
1053
|
+
async sshExec(appName, command) {
|
|
1054
|
+
const connInfo = await this.getConnectionInfo(appName);
|
|
1055
|
+
let sshCmd2;
|
|
1056
|
+
if (connInfo.viaGateway && connInfo.localPort) {
|
|
1057
|
+
sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
|
|
1058
|
+
} else {
|
|
1059
|
+
sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
|
|
1060
|
+
}
|
|
1061
|
+
return execSync3(`${sshCmd2} "${command}"`, { encoding: "utf-8" }).trim();
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Remove a node from the uncloud cluster
|
|
1065
|
+
* Uses 'uc machine rm' to drain containers and remove from cluster
|
|
1066
|
+
*/
|
|
1067
|
+
async removeNode(appName, nodeName) {
|
|
1068
|
+
const connInfo = await this.getConnectionInfo(appName);
|
|
1069
|
+
const result = spawnSync3("uc", ["--connect", connInfo.url, "machine", "rm", nodeName, "--yes"], {
|
|
1070
|
+
stdio: "inherit",
|
|
1071
|
+
timeout: 3e5
|
|
1072
|
+
// 5 minute timeout for container drainage
|
|
1073
|
+
});
|
|
1074
|
+
if (result.status !== 0) {
|
|
1075
|
+
throw new Error(`Failed to remove node '${nodeName}' from cluster (exit code ${result.status})`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* List machines in the uncloud cluster
|
|
1080
|
+
*/
|
|
1081
|
+
async listMachines(appName) {
|
|
1082
|
+
const result = await this.run(appName, "machine", ["ls"], { stdio: "pipe" });
|
|
1083
|
+
return result;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Get gateway info (cached)
|
|
1087
|
+
*/
|
|
1088
|
+
async getGateway(location) {
|
|
1089
|
+
if (this.gatewayCache.has(location)) {
|
|
1090
|
+
return this.gatewayCache.get(location) || null;
|
|
1091
|
+
}
|
|
1092
|
+
const gateway = await this.platformClient.getGateway(location);
|
|
1093
|
+
if (gateway) {
|
|
1094
|
+
this.gatewayCache.set(location, { ipv4: gateway.ipv4, ipv6: gateway.ipv6 });
|
|
1095
|
+
return { ipv4: gateway.ipv4, ipv6: gateway.ipv6 };
|
|
1096
|
+
}
|
|
1097
|
+
this.gatewayCache.set(location, null);
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Clean up all SSH sessions, tunnels, and VPN
|
|
1102
|
+
*/
|
|
1103
|
+
cleanup() {
|
|
1104
|
+
this.tunnelManager.closeAll();
|
|
1105
|
+
this.certManager.cleanupAll();
|
|
1106
|
+
if (this.vpnEstablishedByUs) {
|
|
1107
|
+
this.vpnManager.disconnect();
|
|
1108
|
+
this.vpnEstablishedByUs = false;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
|
|
540
1113
|
// src/lib/cluster.ts
|
|
541
|
-
import { execSync as
|
|
1114
|
+
import { execSync as execSync4 } from "child_process";
|
|
542
1115
|
import ora2 from "ora";
|
|
543
|
-
import
|
|
1116
|
+
import chalk4 from "chalk";
|
|
544
1117
|
var platformKeysCache = null;
|
|
545
1118
|
var ClusterManager = class {
|
|
546
|
-
constructor(platformClient) {
|
|
1119
|
+
constructor(platformClient, uncloudRunner) {
|
|
547
1120
|
this.platformClient = platformClient;
|
|
548
1121
|
this.sshCertManager = new SSHCertManager(platformClient);
|
|
1122
|
+
this.uncloudRunner = uncloudRunner || new UncloudRunner(platformClient);
|
|
549
1123
|
}
|
|
550
1124
|
sshCertManager;
|
|
1125
|
+
uncloudRunner;
|
|
551
1126
|
/**
|
|
552
1127
|
* Get platform SSH keys (cached for the session)
|
|
553
1128
|
* Returns CA public key and platform public key for VM creation
|
|
@@ -600,7 +1175,7 @@ var ClusterManager = class {
|
|
|
600
1175
|
});
|
|
601
1176
|
spinner.text = `Waiting for VM to be ready...`;
|
|
602
1177
|
spinner.stop();
|
|
603
|
-
console.log(
|
|
1178
|
+
console.log(chalk4.cyan("\nWaiting for VM to get an IPv6 address..."));
|
|
604
1179
|
const vmWithIp = await this.waitForVM(location, vmName, 600, false);
|
|
605
1180
|
spinner = ora2("Setting up VM...").start();
|
|
606
1181
|
spinner.text = "Waiting for SSH to be ready...";
|
|
@@ -623,17 +1198,17 @@ var ClusterManager = class {
|
|
|
623
1198
|
const savedCluster = await this.platformClient.saveApp(cluster);
|
|
624
1199
|
spinner.text = "Installing Docker and Uncloud...";
|
|
625
1200
|
spinner.stop();
|
|
626
|
-
console.log(
|
|
1201
|
+
console.log(chalk4.cyan("\nInitializing uncloud (this may take a few minutes)..."));
|
|
627
1202
|
await this.initializeUncloud(vmWithIp.ip6, appName, gateway?.ipv4);
|
|
628
1203
|
spinner = ora2("Configuring Docker for NAT64...").start();
|
|
629
1204
|
await this.configureDockerNAT64(vmWithIp.ip6, gateway?.ipv4);
|
|
630
1205
|
spinner.text = "Waiting for uncloud services to be ready...";
|
|
631
1206
|
await this.waitForUncloudReady(vmWithIp.ip6, gateway?.ipv4);
|
|
632
1207
|
this.sshCertManager.cleanupAll();
|
|
633
|
-
spinner.succeed(
|
|
1208
|
+
spinner.succeed(chalk4.green(`Cluster initialized successfully`));
|
|
634
1209
|
return savedCluster;
|
|
635
1210
|
} catch (error) {
|
|
636
|
-
spinner.fail(
|
|
1211
|
+
spinner.fail(chalk4.red("Failed to initialize cluster"));
|
|
637
1212
|
throw error;
|
|
638
1213
|
}
|
|
639
1214
|
}
|
|
@@ -669,7 +1244,7 @@ var ClusterManager = class {
|
|
|
669
1244
|
});
|
|
670
1245
|
spinner.text = `Waiting for VM to be ready...`;
|
|
671
1246
|
spinner.stop();
|
|
672
|
-
console.log(
|
|
1247
|
+
console.log(chalk4.cyan("\nWaiting for VM to get an IPv6 address..."));
|
|
673
1248
|
const vmWithIp = await this.waitForVM(cluster.location, vmName, 600, false);
|
|
674
1249
|
spinner = ora2("Setting up VM...").start();
|
|
675
1250
|
await this.sleep(3e4);
|
|
@@ -677,11 +1252,11 @@ var ClusterManager = class {
|
|
|
677
1252
|
await this.platformClient.setupVM(vmWithIp.ip6, cluster.location, appName);
|
|
678
1253
|
spinner.text = "Joining uncloud cluster...";
|
|
679
1254
|
spinner.stop();
|
|
680
|
-
console.log(
|
|
681
|
-
await this.joinUncloudCluster(vmWithIp.ip6,
|
|
1255
|
+
console.log(chalk4.cyan("\nJoining uncloud cluster..."));
|
|
1256
|
+
await this.joinUncloudCluster(vmWithIp.ip6, appName);
|
|
682
1257
|
spinner = ora2("Configuring Docker for NAT64...").start();
|
|
683
1258
|
await this.configureDockerNAT64(vmWithIp.ip6, gateway?.ipv4);
|
|
684
|
-
spinner.succeed(
|
|
1259
|
+
spinner.succeed(chalk4.green(`Node '${vmName}' added successfully`));
|
|
685
1260
|
const newNode = {
|
|
686
1261
|
name: vmName,
|
|
687
1262
|
id: vm.id,
|
|
@@ -692,7 +1267,7 @@ var ClusterManager = class {
|
|
|
692
1267
|
await this.platformClient.saveApp(cluster);
|
|
693
1268
|
return newNode;
|
|
694
1269
|
} catch (error) {
|
|
695
|
-
spinner.fail(
|
|
1270
|
+
spinner.fail(chalk4.red("Failed to add node"));
|
|
696
1271
|
throw error;
|
|
697
1272
|
}
|
|
698
1273
|
}
|
|
@@ -716,32 +1291,32 @@ var ClusterManager = class {
|
|
|
716
1291
|
if (!gatewayIp) {
|
|
717
1292
|
throw new Error("No direct IPv6 connectivity and no gateway available");
|
|
718
1293
|
}
|
|
719
|
-
console.log(
|
|
1294
|
+
console.log(chalk4.dim("No direct IPv6 connectivity, tunneling through gateway..."));
|
|
720
1295
|
tunnel = await createTunnel(vmIp, gatewayIp);
|
|
721
1296
|
targetHost = `localhost:${tunnel.localPort}`;
|
|
722
1297
|
}
|
|
723
1298
|
let initSucceeded = false;
|
|
724
1299
|
try {
|
|
725
|
-
|
|
1300
|
+
execSync4(`uc machine init -c "${contextName}" --no-dns root@${targetHost}`, {
|
|
726
1301
|
stdio: "inherit",
|
|
727
1302
|
timeout: 6e5
|
|
728
1303
|
// 10 min timeout
|
|
729
1304
|
});
|
|
730
1305
|
initSucceeded = true;
|
|
731
1306
|
} catch (error) {
|
|
732
|
-
console.log(
|
|
1307
|
+
console.log(chalk4.yellow("\nInitial setup had errors, will attempt to recover..."));
|
|
733
1308
|
}
|
|
734
1309
|
if (!initSucceeded) {
|
|
735
1310
|
const maxCaddyAttempts = 5;
|
|
736
1311
|
for (let attempt = 1; attempt <= maxCaddyAttempts; attempt++) {
|
|
737
|
-
console.log(
|
|
1312
|
+
console.log(chalk4.dim(`Waiting for machine to be ready (attempt ${attempt}/${maxCaddyAttempts})...`));
|
|
738
1313
|
await new Promise((resolve) => setTimeout(resolve, 1e4));
|
|
739
1314
|
try {
|
|
740
|
-
|
|
1315
|
+
execSync4(`yes | uc -c "${contextName}" caddy deploy`, {
|
|
741
1316
|
stdio: "inherit",
|
|
742
1317
|
timeout: 12e4
|
|
743
1318
|
});
|
|
744
|
-
console.log(
|
|
1319
|
+
console.log(chalk4.green("Caddy deployed successfully"));
|
|
745
1320
|
break;
|
|
746
1321
|
} catch (caddyError) {
|
|
747
1322
|
if (attempt >= maxCaddyAttempts) {
|
|
@@ -750,7 +1325,7 @@ var ClusterManager = class {
|
|
|
750
1325
|
}
|
|
751
1326
|
}
|
|
752
1327
|
}
|
|
753
|
-
console.log(
|
|
1328
|
+
console.log(chalk4.dim("Uncloud initialization complete."));
|
|
754
1329
|
} catch (error) {
|
|
755
1330
|
throw new Error(`Failed to initialize uncloud: ${error.message}`);
|
|
756
1331
|
} finally {
|
|
@@ -761,20 +1336,15 @@ var ClusterManager = class {
|
|
|
761
1336
|
}
|
|
762
1337
|
/**
|
|
763
1338
|
* Join a new node to an existing uncloud cluster
|
|
764
|
-
* Uses
|
|
1339
|
+
* Uses `uc machine add` which handles:
|
|
1340
|
+
* - SSH to the new machine
|
|
1341
|
+
* - Installing uncloudd if needed
|
|
1342
|
+
* - Getting token from new machine via gRPC
|
|
1343
|
+
* - Registering machine in cluster
|
|
765
1344
|
*/
|
|
766
|
-
async joinUncloudCluster(newVmIp,
|
|
1345
|
+
async joinUncloudCluster(newVmIp, appName) {
|
|
767
1346
|
try {
|
|
768
|
-
await this.
|
|
769
|
-
await this.sshCertManager.getSession(contextName, newVmIp);
|
|
770
|
-
const token = execSync2(`uc --connect ssh://root@${primaryVmIp} machine token`, {
|
|
771
|
-
encoding: "utf-8",
|
|
772
|
-
timeout: 3e4
|
|
773
|
-
}).trim();
|
|
774
|
-
if (!token) {
|
|
775
|
-
throw new Error("Failed to get join token from primary node");
|
|
776
|
-
}
|
|
777
|
-
execSync2(`uc --connect ssh://root@${primaryVmIp} machine add root@${newVmIp} --token "${token}"`, {
|
|
1347
|
+
await this.uncloudRunner.run(appName, "machine", ["add", `root@${newVmIp}`, "--no-caddy"], {
|
|
778
1348
|
stdio: "inherit",
|
|
779
1349
|
timeout: 6e5
|
|
780
1350
|
// 10 min timeout
|
|
@@ -800,12 +1370,6 @@ var ClusterManager = class {
|
|
|
800
1370
|
const setupScript = `#!/bin/bash
|
|
801
1371
|
set -e
|
|
802
1372
|
|
|
803
|
-
# Install jq if not present (needed to merge Docker config)
|
|
804
|
-
if ! command -v jq &> /dev/null; then
|
|
805
|
-
apt-get update -qq
|
|
806
|
-
apt-get install -y -qq jq
|
|
807
|
-
fi
|
|
808
|
-
|
|
809
1373
|
# Step 1: Add IPv6 support to Docker daemon config
|
|
810
1374
|
DAEMON_JSON='/etc/docker/daemon.json'
|
|
811
1375
|
if [ -f "$DAEMON_JSON" ]; then
|
|
@@ -846,21 +1410,18 @@ systemctl restart docker
|
|
|
846
1410
|
sleep 3
|
|
847
1411
|
|
|
848
1412
|
# Step 4: Recreate uncloud network with IPv6 enabled
|
|
849
|
-
# Must stop containers first - can't remove network while containers are attached
|
|
850
1413
|
CONTAINERS=$(docker ps -aq --filter network=uncloud 2>/dev/null || true)
|
|
851
1414
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
fi
|
|
1415
|
+
for container in $CONTAINERS; do
|
|
1416
|
+
docker network disconnect uncloud "$container" 2>/dev/null || true
|
|
1417
|
+
done
|
|
856
1418
|
|
|
857
1419
|
docker network rm uncloud 2>/dev/null || true
|
|
858
1420
|
docker network create --driver bridge --subnet 10.210.0.0/24 --gateway 10.210.0.1 --ipv6 --subnet fd00:a10:210::/64 --gateway fd00:a10:210::1 uncloud
|
|
859
1421
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
fi
|
|
1422
|
+
for container in $CONTAINERS; do
|
|
1423
|
+
docker network connect uncloud "$container" 2>/dev/null || true
|
|
1424
|
+
done
|
|
864
1425
|
|
|
865
1426
|
# Step 5: Start the iptables service
|
|
866
1427
|
systemctl start docker-ipv6-nat64
|
|
@@ -877,7 +1438,7 @@ echo "Docker NAT64 configuration complete"
|
|
|
877
1438
|
sshHost = "localhost";
|
|
878
1439
|
sshPortArgs = `-p ${tunnel.localPort}`;
|
|
879
1440
|
}
|
|
880
|
-
|
|
1441
|
+
execSync4(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
|
|
881
1442
|
${setupScript}
|
|
882
1443
|
REMOTESCRIPT`, {
|
|
883
1444
|
stdio: "inherit",
|
|
@@ -903,13 +1464,13 @@ REMOTESCRIPT`, {
|
|
|
903
1464
|
while (Date.now() - startTime < timeoutMs) {
|
|
904
1465
|
const vm = await this.platformClient.getVM(location, vmName);
|
|
905
1466
|
if (vm.status && vm.status !== lastStatus) {
|
|
906
|
-
console.log(
|
|
1467
|
+
console.log(chalk4.dim(` Status: ${vm.status}`));
|
|
907
1468
|
lastStatus = vm.status;
|
|
908
1469
|
}
|
|
909
1470
|
const hasRequiredIp = requireIpv4 ? vm.ip4 : vm.ip6;
|
|
910
1471
|
if (hasRequiredIp) {
|
|
911
1472
|
const ipDisplay = requireIpv4 ? vm.ip4 : vm.ip6;
|
|
912
|
-
console.log(
|
|
1473
|
+
console.log(chalk4.green(` IP assigned: ${ipDisplay}`));
|
|
913
1474
|
return vm;
|
|
914
1475
|
}
|
|
915
1476
|
const elapsed = Math.floor((Date.now() - startTime) / 1e3);
|
|
@@ -938,27 +1499,27 @@ The VM may still be provisioning. You can:
|
|
|
938
1499
|
const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
|
|
939
1500
|
const proxyJump = !canConnectDirect && gatewayIp ? `-J root@${gatewayIp}` : "";
|
|
940
1501
|
const sshBase = `ssh ${proxyJump} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 root@${vmIp}`;
|
|
941
|
-
console.log(
|
|
1502
|
+
console.log(chalk4.dim(" Waiting for uncloud services..."));
|
|
942
1503
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
943
1504
|
try {
|
|
944
|
-
const result =
|
|
1505
|
+
const result = execSync4(
|
|
945
1506
|
`${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
|
|
946
1507
|
{ encoding: "utf-8", timeout: 2e4, stdio: ["pipe", "pipe", "pipe"] }
|
|
947
1508
|
).trim();
|
|
948
1509
|
if (result.includes("ready")) {
|
|
949
|
-
console.log(
|
|
1510
|
+
console.log(chalk4.dim(" Uncloud services ready"));
|
|
950
1511
|
return;
|
|
951
1512
|
}
|
|
952
1513
|
} catch (error) {
|
|
953
1514
|
}
|
|
954
1515
|
if (attempt % 5 === 0) {
|
|
955
|
-
console.log(
|
|
1516
|
+
console.log(chalk4.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
|
|
956
1517
|
}
|
|
957
1518
|
if (attempt < maxAttempts) {
|
|
958
1519
|
await this.sleep(checkInterval);
|
|
959
1520
|
}
|
|
960
1521
|
}
|
|
961
|
-
console.log(
|
|
1522
|
+
console.log(chalk4.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
|
|
962
1523
|
}
|
|
963
1524
|
/**
|
|
964
1525
|
* Generate a sequential node name: appName-1, appName-2, etc.
|
|
@@ -987,21 +1548,21 @@ The VM may still be provisioning. You can:
|
|
|
987
1548
|
};
|
|
988
1549
|
|
|
989
1550
|
// src/lib/platform.ts
|
|
990
|
-
import { platform } from "os";
|
|
991
|
-
import
|
|
1551
|
+
import { platform as platform2 } from "os";
|
|
1552
|
+
import chalk5 from "chalk";
|
|
992
1553
|
var PlatformDetector = class {
|
|
993
1554
|
/**
|
|
994
1555
|
* Check if running on Windows
|
|
995
1556
|
*/
|
|
996
1557
|
static isWindows() {
|
|
997
|
-
return
|
|
1558
|
+
return platform2() === "win32";
|
|
998
1559
|
}
|
|
999
1560
|
/**
|
|
1000
1561
|
* Check if running inside WSL (Windows Subsystem for Linux)
|
|
1001
1562
|
*/
|
|
1002
1563
|
static isWSL() {
|
|
1003
1564
|
if (!this.isWindows()) {
|
|
1004
|
-
if (
|
|
1565
|
+
if (platform2() === "linux") {
|
|
1005
1566
|
try {
|
|
1006
1567
|
const fs = __require("fs");
|
|
1007
1568
|
const procVersion = fs.readFileSync("/proc/version", "utf8").toLowerCase();
|
|
@@ -1018,40 +1579,40 @@ var PlatformDetector = class {
|
|
|
1018
1579
|
* Check if platform is supported for hackerrun
|
|
1019
1580
|
*/
|
|
1020
1581
|
static isSupported() {
|
|
1021
|
-
return
|
|
1582
|
+
return platform2() === "darwin" || platform2() === "linux" || this.isWSL();
|
|
1022
1583
|
}
|
|
1023
1584
|
/**
|
|
1024
1585
|
* Get platform name for display
|
|
1025
1586
|
*/
|
|
1026
1587
|
static getPlatformName() {
|
|
1027
1588
|
if (this.isWSL()) return "WSL (Windows Subsystem for Linux)";
|
|
1028
|
-
if (
|
|
1029
|
-
if (
|
|
1030
|
-
if (
|
|
1031
|
-
return
|
|
1589
|
+
if (platform2() === "win32") return "Windows";
|
|
1590
|
+
if (platform2() === "darwin") return "macOS";
|
|
1591
|
+
if (platform2() === "linux") return "Linux";
|
|
1592
|
+
return platform2();
|
|
1032
1593
|
}
|
|
1033
1594
|
/**
|
|
1034
1595
|
* Ensure platform is supported, exit with helpful message if not
|
|
1035
1596
|
*/
|
|
1036
1597
|
static ensureSupported() {
|
|
1037
1598
|
if (this.isWindows() && !this.isWSL()) {
|
|
1038
|
-
console.log(
|
|
1599
|
+
console.log(chalk5.yellow("\n\u26A0\uFE0F Windows detected\n"));
|
|
1039
1600
|
console.log("Hackerrun requires WSL (Windows Subsystem for Linux) to run.");
|
|
1040
1601
|
console.log("Uncloud and SSH tools work best in a Linux environment.\n");
|
|
1041
|
-
console.log(
|
|
1602
|
+
console.log(chalk5.cyan("How to set up WSL:\n"));
|
|
1042
1603
|
console.log("1. Open PowerShell as Administrator and run:");
|
|
1043
|
-
console.log(
|
|
1604
|
+
console.log(chalk5.bold(" wsl --install\n"));
|
|
1044
1605
|
console.log("2. Restart your computer\n");
|
|
1045
1606
|
console.log("3. Install Ubuntu from Microsoft Store (or use default Linux)\n");
|
|
1046
1607
|
console.log("4. Open WSL terminal and install hackerrun:");
|
|
1047
|
-
console.log(
|
|
1608
|
+
console.log(chalk5.bold(" npm install -g hackerrun\n"));
|
|
1048
1609
|
console.log("Learn more: https://docs.microsoft.com/en-us/windows/wsl/install\n");
|
|
1049
|
-
console.log(
|
|
1610
|
+
console.log(chalk5.red("Please install WSL and run hackerrun from WSL terminal.\n"));
|
|
1050
1611
|
process.exit(1);
|
|
1051
1612
|
}
|
|
1052
1613
|
if (!this.isSupported()) {
|
|
1053
|
-
console.log(
|
|
1054
|
-
\u274C Platform '${
|
|
1614
|
+
console.log(chalk5.red(`
|
|
1615
|
+
\u274C Platform '${platform2()}' is not supported
|
|
1055
1616
|
`));
|
|
1056
1617
|
console.log("Hackerrun supports:");
|
|
1057
1618
|
console.log(" - macOS");
|
|
@@ -1063,9 +1624,9 @@ var PlatformDetector = class {
|
|
|
1063
1624
|
};
|
|
1064
1625
|
|
|
1065
1626
|
// src/lib/uncloud.ts
|
|
1066
|
-
import { execSync as
|
|
1067
|
-
import { platform as
|
|
1068
|
-
import
|
|
1627
|
+
import { execSync as execSync5 } from "child_process";
|
|
1628
|
+
import { platform as platform3 } from "os";
|
|
1629
|
+
import chalk6 from "chalk";
|
|
1069
1630
|
import ora3 from "ora";
|
|
1070
1631
|
var UncloudManager = class {
|
|
1071
1632
|
/**
|
|
@@ -1073,7 +1634,7 @@ var UncloudManager = class {
|
|
|
1073
1634
|
*/
|
|
1074
1635
|
static isInstalled() {
|
|
1075
1636
|
try {
|
|
1076
|
-
|
|
1637
|
+
execSync5("uc --version", { stdio: "pipe" });
|
|
1077
1638
|
return true;
|
|
1078
1639
|
} catch {
|
|
1079
1640
|
return false;
|
|
@@ -1084,7 +1645,7 @@ var UncloudManager = class {
|
|
|
1084
1645
|
*/
|
|
1085
1646
|
static getVersion() {
|
|
1086
1647
|
try {
|
|
1087
|
-
const version =
|
|
1648
|
+
const version = execSync5("uc --version", { encoding: "utf-8" }).trim();
|
|
1088
1649
|
return version;
|
|
1089
1650
|
} catch {
|
|
1090
1651
|
return null;
|
|
@@ -1099,34 +1660,34 @@ var UncloudManager = class {
|
|
|
1099
1660
|
return;
|
|
1100
1661
|
}
|
|
1101
1662
|
if (options?.nonInteractive) {
|
|
1102
|
-
console.error(
|
|
1663
|
+
console.error(chalk6.red("\n\u274C Uncloud CLI (uc) not found\n"));
|
|
1103
1664
|
console.error("The uncloud CLI must be installed on the build VM.");
|
|
1104
1665
|
console.error("This is a build infrastructure issue - the uc binary should be pre-installed.\n");
|
|
1105
1666
|
process.exit(1);
|
|
1106
1667
|
}
|
|
1107
|
-
console.log(
|
|
1668
|
+
console.log(chalk6.yellow("\n\u26A0\uFE0F Uncloud CLI not found\n"));
|
|
1108
1669
|
console.log("Uncloud is required to deploy and manage your apps.");
|
|
1109
1670
|
console.log("Learn more: https://uncloud.run\n");
|
|
1110
|
-
const os =
|
|
1671
|
+
const os = platform3();
|
|
1111
1672
|
if (os === "darwin" || os === "linux") {
|
|
1112
1673
|
this.showInstallInstructions();
|
|
1113
|
-
console.log(
|
|
1674
|
+
console.log(chalk6.cyan("Would you like to install uncloud now? (Y/n): "));
|
|
1114
1675
|
const answer = await this.promptUser();
|
|
1115
1676
|
if (answer === "y" || answer === "yes" || answer === "") {
|
|
1116
1677
|
try {
|
|
1117
1678
|
await this.autoInstall();
|
|
1118
1679
|
return;
|
|
1119
1680
|
} catch (error) {
|
|
1120
|
-
console.log(
|
|
1681
|
+
console.log(chalk6.red("\nAuto-installation failed. Please install manually.\n"));
|
|
1121
1682
|
process.exit(1);
|
|
1122
1683
|
}
|
|
1123
1684
|
} else {
|
|
1124
|
-
console.log(
|
|
1685
|
+
console.log(chalk6.yellow("\nPlease install uncloud manually and run hackerrun again.\n"));
|
|
1125
1686
|
process.exit(1);
|
|
1126
1687
|
}
|
|
1127
1688
|
} else {
|
|
1128
1689
|
this.showInstallInstructions();
|
|
1129
|
-
console.log(
|
|
1690
|
+
console.log(chalk6.red("Please install uncloud and run hackerrun again.\n"));
|
|
1130
1691
|
process.exit(1);
|
|
1131
1692
|
}
|
|
1132
1693
|
}
|
|
@@ -1134,20 +1695,20 @@ var UncloudManager = class {
|
|
|
1134
1695
|
* Show installation instructions based on platform
|
|
1135
1696
|
*/
|
|
1136
1697
|
static showInstallInstructions() {
|
|
1137
|
-
const os =
|
|
1138
|
-
console.log(
|
|
1698
|
+
const os = platform3();
|
|
1699
|
+
console.log(chalk6.cyan("Installation instructions:\n"));
|
|
1139
1700
|
if (os === "darwin" || os === "linux") {
|
|
1140
|
-
console.log(
|
|
1141
|
-
console.log(
|
|
1142
|
-
console.log(
|
|
1143
|
-
console.log(
|
|
1144
|
-
console.log(
|
|
1701
|
+
console.log(chalk6.bold("Option 1: Automated install (Recommended)"));
|
|
1702
|
+
console.log(chalk6.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
|
|
1703
|
+
console.log(chalk6.bold("Option 2: Homebrew"));
|
|
1704
|
+
console.log(chalk6.green(" brew install psviderski/tap/uncloud\n"));
|
|
1705
|
+
console.log(chalk6.bold("Option 3: Manual download"));
|
|
1145
1706
|
console.log(" Download from: https://github.com/psviderski/uncloud/releases/latest");
|
|
1146
1707
|
console.log(" Extract and move to /usr/local/bin\n");
|
|
1147
1708
|
} else if (os === "win32") {
|
|
1148
|
-
console.log(
|
|
1709
|
+
console.log(chalk6.bold("For Windows (via WSL):"));
|
|
1149
1710
|
console.log(" Open your WSL terminal and run:");
|
|
1150
|
-
console.log(
|
|
1711
|
+
console.log(chalk6.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
|
|
1151
1712
|
}
|
|
1152
1713
|
console.log("Documentation: https://uncloud.run/docs/getting-started/install-cli\n");
|
|
1153
1714
|
}
|
|
@@ -1172,27 +1733,27 @@ var UncloudManager = class {
|
|
|
1172
1733
|
static async autoInstall() {
|
|
1173
1734
|
const spinner = ora3("Installing uncloud...").start();
|
|
1174
1735
|
try {
|
|
1175
|
-
|
|
1736
|
+
execSync5("curl -fsS https://get.uncloud.run/install.sh | sh", {
|
|
1176
1737
|
stdio: "inherit"
|
|
1177
1738
|
});
|
|
1178
|
-
spinner.succeed(
|
|
1739
|
+
spinner.succeed(chalk6.green("Uncloud installed successfully!"));
|
|
1179
1740
|
if (this.isInstalled()) {
|
|
1180
1741
|
const version = this.getVersion();
|
|
1181
|
-
console.log(
|
|
1742
|
+
console.log(chalk6.cyan(`\u2713 Uncloud ${version} is ready to use
|
|
1182
1743
|
`));
|
|
1183
1744
|
} else {
|
|
1184
|
-
spinner.warn(
|
|
1185
|
-
console.log(
|
|
1745
|
+
spinner.warn(chalk6.yellow("Installation completed but uc command not found in PATH"));
|
|
1746
|
+
console.log(chalk6.yellow("\nYou may need to:"));
|
|
1186
1747
|
console.log(" 1. Restart your terminal");
|
|
1187
1748
|
console.log(" 2. Or run: source ~/.bashrc (or ~/.zshrc)\n");
|
|
1188
1749
|
throw new Error("Please restart your terminal and try again");
|
|
1189
1750
|
}
|
|
1190
1751
|
} catch (error) {
|
|
1191
|
-
spinner.fail(
|
|
1192
|
-
console.log(
|
|
1752
|
+
spinner.fail(chalk6.red("Failed to install uncloud"));
|
|
1753
|
+
console.log(chalk6.red(`
|
|
1193
1754
|
Error: ${error.message}
|
|
1194
1755
|
`));
|
|
1195
|
-
console.log(
|
|
1756
|
+
console.log(chalk6.yellow("Please try manual installation:"));
|
|
1196
1757
|
console.log(" curl -fsS https://get.uncloud.run/install.sh | sh\n");
|
|
1197
1758
|
throw error;
|
|
1198
1759
|
}
|
|
@@ -1200,16 +1761,16 @@ Error: ${error.message}
|
|
|
1200
1761
|
};
|
|
1201
1762
|
|
|
1202
1763
|
// src/lib/platform-auth.ts
|
|
1203
|
-
import
|
|
1764
|
+
import chalk7 from "chalk";
|
|
1204
1765
|
function getPlatformToken() {
|
|
1205
1766
|
const configManager = new ConfigManager();
|
|
1206
1767
|
try {
|
|
1207
1768
|
const config = configManager.load();
|
|
1208
1769
|
return config.apiToken;
|
|
1209
1770
|
} catch (error) {
|
|
1210
|
-
console.error(
|
|
1211
|
-
console.log(
|
|
1212
|
-
console.log(` ${
|
|
1771
|
+
console.error(chalk7.red("\n Not logged in"));
|
|
1772
|
+
console.log(chalk7.cyan("\nPlease login first:\n"));
|
|
1773
|
+
console.log(` ${chalk7.bold("hackerrun login")}
|
|
1213
1774
|
`);
|
|
1214
1775
|
process.exit(1);
|
|
1215
1776
|
}
|
|
@@ -1421,243 +1982,12 @@ var PlatformClient = class {
|
|
|
1421
1982
|
await this.request("POST", "/api/uncloud/config", { config: configYaml });
|
|
1422
1983
|
}
|
|
1423
1984
|
/**
|
|
1424
|
-
* Download uncloud config from platform
|
|
1425
|
-
*/
|
|
1426
|
-
async downloadUncloudConfig() {
|
|
1427
|
-
try {
|
|
1428
|
-
const { config } = await this.request("GET", "/api/uncloud/config");
|
|
1429
|
-
return config;
|
|
1430
|
-
} catch (error) {
|
|
1431
|
-
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1432
|
-
return null;
|
|
1433
|
-
}
|
|
1434
|
-
throw error;
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
// ==================== Gateway Management ====================
|
|
1438
|
-
/**
|
|
1439
|
-
* Get gateway info for a location
|
|
1440
|
-
*/
|
|
1441
|
-
async getGateway(location) {
|
|
1442
|
-
try {
|
|
1443
|
-
const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`, void 0, { operation: `Get gateway for '${location}'` });
|
|
1444
|
-
return gateway;
|
|
1445
|
-
} catch (error) {
|
|
1446
|
-
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1447
|
-
return null;
|
|
1448
|
-
}
|
|
1449
|
-
throw error;
|
|
1450
|
-
}
|
|
1451
|
-
}
|
|
1452
|
-
// ==================== Route Management ====================
|
|
1453
|
-
/**
|
|
1454
|
-
* Register a route for an app (maps hostname to VM IPv6)
|
|
1455
|
-
*/
|
|
1456
|
-
async registerRoute(appName, backendIpv6, backendPort = 443) {
|
|
1457
|
-
const { route } = await this.request("POST", "/api/routes", {
|
|
1458
|
-
appName,
|
|
1459
|
-
backendIpv6,
|
|
1460
|
-
backendPort
|
|
1461
|
-
}, { operation: `Register route for '${appName}'` });
|
|
1462
|
-
return route;
|
|
1463
|
-
}
|
|
1464
|
-
/**
|
|
1465
|
-
* Delete routes for an app
|
|
1466
|
-
*/
|
|
1467
|
-
async deleteRoute(appName) {
|
|
1468
|
-
await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
|
|
1469
|
-
}
|
|
1470
|
-
// ==================== Gateway Route Sync ====================
|
|
1471
|
-
/**
|
|
1472
|
-
* Sync gateway Caddy configuration for a location
|
|
1473
|
-
* This updates the gateway's reverse proxy config with current routes
|
|
1474
|
-
*/
|
|
1475
|
-
async syncGatewayRoutes(location) {
|
|
1476
|
-
const result = await this.request("POST", "/api/gateway/sync", { location }, { operation: `Sync gateway routes for '${location}'` });
|
|
1477
|
-
return { routeCount: result.routeCount };
|
|
1478
|
-
}
|
|
1479
|
-
// ==================== SSH Certificate Management ====================
|
|
1480
|
-
/**
|
|
1481
|
-
* Get platform SSH keys (CA public key + platform public key for VM creation)
|
|
1482
|
-
*/
|
|
1483
|
-
async getPlatformSSHKeys() {
|
|
1484
|
-
return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
|
|
1485
|
-
}
|
|
1486
|
-
/**
|
|
1487
|
-
* Initialize platform SSH keys (creates them if they don't exist)
|
|
1488
|
-
*/
|
|
1489
|
-
async initPlatformSSHKeys() {
|
|
1490
|
-
return this.request("POST", "/api/platform/ssh-keys");
|
|
1491
|
-
}
|
|
1492
|
-
/**
|
|
1493
|
-
* Request a signed SSH certificate for accessing an app's VMs
|
|
1494
|
-
* Returns a short-lived certificate (5 min) signed by the platform CA
|
|
1495
|
-
*/
|
|
1496
|
-
async requestSSHCertificate(appName, publicKey) {
|
|
1497
|
-
return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
|
|
1498
|
-
}
|
|
1499
|
-
// ==================== VM Setup ====================
|
|
1500
|
-
/**
|
|
1501
|
-
* Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
|
|
1502
|
-
* This runs all configuration using the platform SSH key
|
|
1503
|
-
*/
|
|
1504
|
-
async setupVM(vmIp, location, appName) {
|
|
1505
|
-
await this.request("POST", "/api/vms/setup", {
|
|
1506
|
-
vmIp,
|
|
1507
|
-
location,
|
|
1508
|
-
appName
|
|
1509
|
-
}, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3e3 });
|
|
1510
|
-
}
|
|
1511
|
-
// ==================== Environment Variables ====================
|
|
1512
|
-
/**
|
|
1513
|
-
* Set a single environment variable
|
|
1514
|
-
*/
|
|
1515
|
-
async setEnvVar(appName, key, value) {
|
|
1516
|
-
await this.request("POST", `/api/apps/${appName}/env`, { key, value });
|
|
1517
|
-
}
|
|
1518
|
-
/**
|
|
1519
|
-
* Set multiple environment variables at once
|
|
1520
|
-
*/
|
|
1521
|
-
async setEnvVars(appName, vars) {
|
|
1522
|
-
await this.request("POST", `/api/apps/${appName}/env`, { vars });
|
|
1523
|
-
}
|
|
1524
|
-
/**
|
|
1525
|
-
* List environment variables (values are masked)
|
|
1526
|
-
*/
|
|
1527
|
-
async listEnvVars(appName) {
|
|
1528
|
-
const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
|
|
1529
|
-
return envVars;
|
|
1530
|
-
}
|
|
1531
|
-
/**
|
|
1532
|
-
* Remove an environment variable
|
|
1533
|
-
*/
|
|
1534
|
-
async unsetEnvVar(appName, key) {
|
|
1535
|
-
await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
|
|
1536
|
-
}
|
|
1537
|
-
// ==================== GitHub Connection ====================
|
|
1538
|
-
/**
|
|
1539
|
-
* Create app metadata without creating VM (for connect-before-deploy flow)
|
|
1540
|
-
*/
|
|
1541
|
-
async createAppMetadata(appName, location) {
|
|
1542
|
-
const { app } = await this.request("POST", "/api/apps", {
|
|
1543
|
-
appName,
|
|
1544
|
-
location: location || "eu-central-h1",
|
|
1545
|
-
metadataOnly: true
|
|
1546
|
-
// Signal that we don't want to create VM yet
|
|
1547
|
-
});
|
|
1548
|
-
return app;
|
|
1549
|
-
}
|
|
1550
|
-
/**
|
|
1551
|
-
* Initiate GitHub App installation flow
|
|
1552
|
-
*/
|
|
1553
|
-
async initiateGitHubConnect() {
|
|
1554
|
-
return this.request("POST", "/api/github/connect");
|
|
1555
|
-
}
|
|
1556
|
-
/**
|
|
1557
|
-
* Poll for GitHub App installation completion
|
|
1558
|
-
*/
|
|
1559
|
-
async pollGitHubConnect(stateToken) {
|
|
1560
|
-
return this.request("GET", `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
|
|
1561
|
-
}
|
|
1562
|
-
/**
|
|
1563
|
-
* Get current user's GitHub App installation
|
|
1564
|
-
*/
|
|
1565
|
-
async getGitHubInstallation() {
|
|
1566
|
-
try {
|
|
1567
|
-
const { installation } = await this.request("GET", "/api/github/installation");
|
|
1568
|
-
return installation;
|
|
1569
|
-
} catch (error) {
|
|
1570
|
-
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1571
|
-
return null;
|
|
1572
|
-
}
|
|
1573
|
-
throw error;
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
/**
|
|
1577
|
-
* Delete current user's GitHub App installation record
|
|
1578
|
-
* Used when the installation becomes stale/invalid
|
|
1579
|
-
*/
|
|
1580
|
-
async deleteGitHubInstallation() {
|
|
1581
|
-
await this.request("DELETE", "/api/github/installation");
|
|
1582
|
-
}
|
|
1583
|
-
/**
|
|
1584
|
-
* List repositories accessible via GitHub App installation
|
|
1585
|
-
*/
|
|
1586
|
-
async listAccessibleRepos() {
|
|
1587
|
-
const { repos } = await this.request("GET", "/api/github/repos");
|
|
1588
|
-
return repos;
|
|
1589
|
-
}
|
|
1590
|
-
/**
|
|
1591
|
-
* Connect a repository to an app
|
|
1592
|
-
*/
|
|
1593
|
-
async connectRepo(appName, repoFullName, branch) {
|
|
1594
|
-
await this.request("POST", `/api/apps/${appName}/repo`, {
|
|
1595
|
-
repoFullName,
|
|
1596
|
-
branch: branch || "main"
|
|
1597
|
-
});
|
|
1598
|
-
}
|
|
1599
|
-
/**
|
|
1600
|
-
* Get connected repository for an app
|
|
1601
|
-
*/
|
|
1602
|
-
async getConnectedRepo(appName) {
|
|
1603
|
-
try {
|
|
1604
|
-
const { repo } = await this.request("GET", `/api/apps/${appName}/repo`);
|
|
1605
|
-
return repo;
|
|
1606
|
-
} catch (error) {
|
|
1607
|
-
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1608
|
-
return null;
|
|
1609
|
-
}
|
|
1610
|
-
throw error;
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
/**
|
|
1614
|
-
* Disconnect repository from an app
|
|
1615
|
-
*/
|
|
1616
|
-
async disconnectRepo(appName) {
|
|
1617
|
-
await this.request("DELETE", `/api/apps/${appName}/repo`);
|
|
1618
|
-
}
|
|
1619
|
-
// ==================== Builds ====================
|
|
1620
|
-
/**
|
|
1621
|
-
* List builds for an app
|
|
1622
|
-
*/
|
|
1623
|
-
async listBuilds(appName, limit = 10) {
|
|
1624
|
-
const { builds } = await this.request("GET", `/api/apps/${appName}/builds?limit=${limit}`);
|
|
1625
|
-
return builds;
|
|
1626
|
-
}
|
|
1627
|
-
/**
|
|
1628
|
-
* Get build details
|
|
1629
|
-
*/
|
|
1630
|
-
async getBuild(appName, buildId) {
|
|
1631
|
-
const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
|
|
1632
|
-
return build;
|
|
1633
|
-
}
|
|
1634
|
-
/**
|
|
1635
|
-
* Get build events (for live streaming)
|
|
1636
|
-
*/
|
|
1637
|
-
async getBuildEvents(appName, buildId, after) {
|
|
1638
|
-
let url = `/api/apps/${appName}/builds/${buildId}/events`;
|
|
1639
|
-
if (after) {
|
|
1640
|
-
url += `?after=${encodeURIComponent(after.toISOString())}`;
|
|
1641
|
-
}
|
|
1642
|
-
const { events } = await this.request("GET", url);
|
|
1643
|
-
return events;
|
|
1644
|
-
}
|
|
1645
|
-
// ==================== VPN Management ====================
|
|
1646
|
-
/**
|
|
1647
|
-
* Register VPN peer with the platform
|
|
1648
|
-
* This is idempotent - if already registered, returns existing config
|
|
1649
|
-
*/
|
|
1650
|
-
async registerVPNPeer(publicKey, location) {
|
|
1651
|
-
const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
|
|
1652
|
-
return vpnConfig;
|
|
1653
|
-
}
|
|
1654
|
-
/**
|
|
1655
|
-
* Get existing VPN config if registered
|
|
1985
|
+
* Download uncloud config from platform
|
|
1656
1986
|
*/
|
|
1657
|
-
async
|
|
1987
|
+
async downloadUncloudConfig() {
|
|
1658
1988
|
try {
|
|
1659
|
-
const {
|
|
1660
|
-
return
|
|
1989
|
+
const { config } = await this.request("GET", "/api/uncloud/config");
|
|
1990
|
+
return config;
|
|
1661
1991
|
} catch (error) {
|
|
1662
1992
|
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1663
1993
|
return null;
|
|
@@ -1665,631 +1995,289 @@ var PlatformClient = class {
|
|
|
1665
1995
|
throw error;
|
|
1666
1996
|
}
|
|
1667
1997
|
}
|
|
1998
|
+
// ==================== Gateway Management ====================
|
|
1668
1999
|
/**
|
|
1669
|
-
*
|
|
2000
|
+
* Get gateway info for a location
|
|
1670
2001
|
*/
|
|
1671
|
-
async
|
|
1672
|
-
await this.request("DELETE", "/api/vpn/unregister");
|
|
1673
|
-
}
|
|
1674
|
-
};
|
|
1675
|
-
|
|
1676
|
-
// src/lib/app-config.ts
|
|
1677
|
-
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1678
|
-
import { join as join3 } from "path";
|
|
1679
|
-
import { parse, stringify } from "yaml";
|
|
1680
|
-
var CONFIG_FILENAME = "hackerrun.yaml";
|
|
1681
|
-
function getConfigPath(directory = process.cwd()) {
|
|
1682
|
-
return join3(directory, CONFIG_FILENAME);
|
|
1683
|
-
}
|
|
1684
|
-
function hasAppConfig(directory = process.cwd()) {
|
|
1685
|
-
return existsSync3(getConfigPath(directory));
|
|
1686
|
-
}
|
|
1687
|
-
function readAppConfig(directory = process.cwd()) {
|
|
1688
|
-
const configPath = getConfigPath(directory);
|
|
1689
|
-
if (!existsSync3(configPath)) {
|
|
1690
|
-
return null;
|
|
1691
|
-
}
|
|
1692
|
-
try {
|
|
1693
|
-
const content = readFileSync3(configPath, "utf-8");
|
|
1694
|
-
const config = parse(content);
|
|
1695
|
-
if (!config.appName) {
|
|
1696
|
-
return null;
|
|
1697
|
-
}
|
|
1698
|
-
return config;
|
|
1699
|
-
} catch (error) {
|
|
1700
|
-
console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
|
|
1701
|
-
return null;
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
function writeAppConfig(config, directory = process.cwd()) {
|
|
1705
|
-
const configPath = getConfigPath(directory);
|
|
1706
|
-
const content = stringify(config);
|
|
1707
|
-
writeFileSync3(configPath, content, "utf-8");
|
|
1708
|
-
}
|
|
1709
|
-
function getAppName(directory = process.cwd()) {
|
|
1710
|
-
const config = readAppConfig(directory);
|
|
1711
|
-
if (config?.appName) {
|
|
1712
|
-
return config.appName;
|
|
1713
|
-
}
|
|
1714
|
-
const folderName = directory.split("/").pop() || "app";
|
|
1715
|
-
return folderName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
1716
|
-
}
|
|
1717
|
-
function linkApp(appName, directory = process.cwd()) {
|
|
1718
|
-
writeAppConfig({ appName }, directory);
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
// src/lib/uncloud-runner.ts
|
|
1722
|
-
import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
|
|
1723
|
-
|
|
1724
|
-
// src/lib/vpn.ts
|
|
1725
|
-
import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
|
|
1726
|
-
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync2, unlinkSync } from "fs";
|
|
1727
|
-
import { homedir as homedir2, platform as platform3 } from "os";
|
|
1728
|
-
import { join as join4 } from "path";
|
|
1729
|
-
import chalk6 from "chalk";
|
|
1730
|
-
function detectOS() {
|
|
1731
|
-
const os = platform3();
|
|
1732
|
-
if (os === "darwin") {
|
|
1733
|
-
return { type: "macos" };
|
|
1734
|
-
}
|
|
1735
|
-
if (os === "win32") {
|
|
1736
|
-
return { type: "windows" };
|
|
1737
|
-
}
|
|
1738
|
-
if (os === "linux") {
|
|
2002
|
+
async getGateway(location) {
|
|
1739
2003
|
try {
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
return { type: "linux", distro };
|
|
1746
|
-
}
|
|
2004
|
+
const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`, void 0, { operation: `Get gateway for '${location}'` });
|
|
2005
|
+
return gateway;
|
|
2006
|
+
} catch (error) {
|
|
2007
|
+
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
2008
|
+
return null;
|
|
1747
2009
|
}
|
|
1748
|
-
|
|
2010
|
+
throw error;
|
|
1749
2011
|
}
|
|
1750
|
-
return { type: "linux" };
|
|
1751
|
-
}
|
|
1752
|
-
return { type: "unknown" };
|
|
1753
|
-
}
|
|
1754
|
-
function getWireGuardInstallInstructions() {
|
|
1755
|
-
const os = detectOS();
|
|
1756
|
-
switch (os.type) {
|
|
1757
|
-
case "macos":
|
|
1758
|
-
return ` ${chalk6.cyan("macOS:")} brew install wireguard-tools`;
|
|
1759
|
-
case "windows":
|
|
1760
|
-
return ` ${chalk6.cyan("Windows:")} Download from https://www.wireguard.com/install/
|
|
1761
|
-
Or using winget: winget install WireGuard.WireGuard`;
|
|
1762
|
-
case "linux":
|
|
1763
|
-
switch (os.distro) {
|
|
1764
|
-
case "arch":
|
|
1765
|
-
case "manjaro":
|
|
1766
|
-
case "endeavouros":
|
|
1767
|
-
case "artix":
|
|
1768
|
-
return ` ${chalk6.cyan("Arch Linux:")} sudo pacman -S wireguard-tools`;
|
|
1769
|
-
case "ubuntu":
|
|
1770
|
-
case "debian":
|
|
1771
|
-
case "linuxmint":
|
|
1772
|
-
case "pop":
|
|
1773
|
-
case "elementary":
|
|
1774
|
-
case "zorin":
|
|
1775
|
-
return ` ${chalk6.cyan("Ubuntu/Debian:")} sudo apt install wireguard`;
|
|
1776
|
-
case "fedora":
|
|
1777
|
-
return ` ${chalk6.cyan("Fedora:")} sudo dnf install wireguard-tools`;
|
|
1778
|
-
case "rhel":
|
|
1779
|
-
case "centos":
|
|
1780
|
-
case "rocky":
|
|
1781
|
-
case "almalinux":
|
|
1782
|
-
return ` ${chalk6.cyan("RHEL/CentOS:")} sudo dnf install wireguard-tools
|
|
1783
|
-
(may need EPEL: sudo dnf install epel-release)`;
|
|
1784
|
-
case "opensuse":
|
|
1785
|
-
case "opensuse-leap":
|
|
1786
|
-
case "opensuse-tumbleweed":
|
|
1787
|
-
return ` ${chalk6.cyan("openSUSE:")} sudo zypper install wireguard-tools`;
|
|
1788
|
-
case "gentoo":
|
|
1789
|
-
return ` ${chalk6.cyan("Gentoo:")} sudo emerge net-vpn/wireguard-tools`;
|
|
1790
|
-
case "void":
|
|
1791
|
-
return ` ${chalk6.cyan("Void Linux:")} sudo xbps-install wireguard-tools`;
|
|
1792
|
-
case "alpine":
|
|
1793
|
-
return ` ${chalk6.cyan("Alpine:")} sudo apk add wireguard-tools`;
|
|
1794
|
-
case "nixos":
|
|
1795
|
-
return ` ${chalk6.cyan("NixOS:")} Add wireguard-tools to environment.systemPackages`;
|
|
1796
|
-
default:
|
|
1797
|
-
return ` ${chalk6.cyan("Linux:")} Install wireguard-tools using your package manager:
|
|
1798
|
-
Arch: sudo pacman -S wireguard-tools
|
|
1799
|
-
Ubuntu/Debian: sudo apt install wireguard
|
|
1800
|
-
Fedora: sudo dnf install wireguard-tools
|
|
1801
|
-
openSUSE: sudo zypper install wireguard-tools`;
|
|
1802
|
-
}
|
|
1803
|
-
default:
|
|
1804
|
-
return ` Visit https://www.wireguard.com/install/ for installation instructions`;
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
var CONFIG_DIR = join4(homedir2(), ".config", "hackerrun");
|
|
1808
|
-
var PRIVATE_KEY_FILE = join4(CONFIG_DIR, "wg-private-key");
|
|
1809
|
-
var WG_INTERFACE = "hackerrun";
|
|
1810
|
-
var WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
|
|
1811
|
-
function isWireGuardInstalled() {
|
|
1812
|
-
try {
|
|
1813
|
-
execSync4("which wg", { stdio: "ignore" });
|
|
1814
|
-
execSync4("which wg-quick", { stdio: "ignore" });
|
|
1815
|
-
return true;
|
|
1816
|
-
} catch {
|
|
1817
|
-
return false;
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
function ensureConfigDir() {
|
|
1821
|
-
if (!existsSync4(CONFIG_DIR)) {
|
|
1822
|
-
mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
|
-
function generateKeyPair() {
|
|
1826
|
-
const privateKey = execSync4("wg genkey", { encoding: "utf-8" }).trim();
|
|
1827
|
-
const publicKey = execSync4("wg pubkey", {
|
|
1828
|
-
input: privateKey,
|
|
1829
|
-
encoding: "utf-8"
|
|
1830
|
-
}).trim();
|
|
1831
|
-
return { privateKey, publicKey };
|
|
1832
|
-
}
|
|
1833
|
-
function getOrCreateKeyPair() {
|
|
1834
|
-
ensureConfigDir();
|
|
1835
|
-
if (existsSync4(PRIVATE_KEY_FILE)) {
|
|
1836
|
-
const privateKey2 = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
|
|
1837
|
-
const publicKey2 = execSync4("wg pubkey", {
|
|
1838
|
-
input: privateKey2,
|
|
1839
|
-
encoding: "utf-8"
|
|
1840
|
-
}).trim();
|
|
1841
|
-
return { privateKey: privateKey2, publicKey: publicKey2, isNew: false };
|
|
1842
|
-
}
|
|
1843
|
-
const { privateKey, publicKey } = generateKeyPair();
|
|
1844
|
-
writeFileSync4(PRIVATE_KEY_FILE, privateKey + "\n", { mode: 384 });
|
|
1845
|
-
return { privateKey, publicKey, isNew: true };
|
|
1846
|
-
}
|
|
1847
|
-
function getPublicKey() {
|
|
1848
|
-
if (!existsSync4(PRIVATE_KEY_FILE)) {
|
|
1849
|
-
return null;
|
|
1850
|
-
}
|
|
1851
|
-
const privateKey = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
|
|
1852
|
-
return execSync4("wg pubkey", {
|
|
1853
|
-
input: privateKey,
|
|
1854
|
-
encoding: "utf-8"
|
|
1855
|
-
}).trim();
|
|
1856
|
-
}
|
|
1857
|
-
function isVPNUp() {
|
|
1858
|
-
try {
|
|
1859
|
-
const result = execSync4(`ip link show ${WG_INTERFACE}`, {
|
|
1860
|
-
encoding: "utf-8",
|
|
1861
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1862
|
-
});
|
|
1863
|
-
return result.includes(WG_INTERFACE);
|
|
1864
|
-
} catch {
|
|
1865
|
-
return false;
|
|
1866
|
-
}
|
|
1867
|
-
}
|
|
1868
|
-
function isRoutedViaVPN(ipv6Address) {
|
|
1869
|
-
if (!isVPNUp()) {
|
|
1870
|
-
return false;
|
|
1871
|
-
}
|
|
1872
|
-
try {
|
|
1873
|
-
const result = execSync4(`ip -6 route get ${ipv6Address}`, {
|
|
1874
|
-
encoding: "utf-8",
|
|
1875
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1876
|
-
});
|
|
1877
|
-
return result.includes(`dev ${WG_INTERFACE}`);
|
|
1878
|
-
} catch {
|
|
1879
|
-
return false;
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
function getVPNStatus() {
|
|
1883
|
-
if (!isVPNUp()) {
|
|
1884
|
-
return { connected: false };
|
|
1885
2012
|
}
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
const
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
if (line.includes("endpoint:")) {
|
|
1898
|
-
endpoint = line.split("endpoint:")[1]?.trim();
|
|
1899
|
-
}
|
|
1900
|
-
if (line.includes("latest handshake:")) {
|
|
1901
|
-
latestHandshake = line.split("latest handshake:")[1]?.trim();
|
|
1902
|
-
}
|
|
1903
|
-
if (line.includes("transfer:")) {
|
|
1904
|
-
const transfer = line.split("transfer:")[1]?.trim();
|
|
1905
|
-
const parts = transfer?.split(",");
|
|
1906
|
-
transferRx = parts?.[0]?.trim();
|
|
1907
|
-
transferTx = parts?.[1]?.trim();
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
return {
|
|
1911
|
-
connected: true,
|
|
1912
|
-
interface: WG_INTERFACE,
|
|
1913
|
-
endpoint,
|
|
1914
|
-
latestHandshake,
|
|
1915
|
-
transferRx,
|
|
1916
|
-
transferTx
|
|
1917
|
-
};
|
|
1918
|
-
} catch {
|
|
1919
|
-
return { connected: isVPNUp(), interface: WG_INTERFACE };
|
|
2013
|
+
// ==================== Route Management ====================
|
|
2014
|
+
/**
|
|
2015
|
+
* Register a route for an app (maps hostname to VM IPv6)
|
|
2016
|
+
*/
|
|
2017
|
+
async registerRoute(appName, backendIpv6, backendPort = 443) {
|
|
2018
|
+
const { route } = await this.request("POST", "/api/routes", {
|
|
2019
|
+
appName,
|
|
2020
|
+
backendIpv6,
|
|
2021
|
+
backendPort
|
|
2022
|
+
}, { operation: `Register route for '${appName}'` });
|
|
2023
|
+
return route;
|
|
1920
2024
|
}
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
[Interface]
|
|
1927
|
-
PrivateKey = ${config.privateKey}
|
|
1928
|
-
Address = ${config.address}
|
|
1929
|
-
|
|
1930
|
-
[Peer]
|
|
1931
|
-
PublicKey = ${config.gatewayPublicKey}
|
|
1932
|
-
Endpoint = ${config.gatewayEndpoint}
|
|
1933
|
-
AllowedIPs = ${config.allowedIPs}
|
|
1934
|
-
PersistentKeepalive = 25
|
|
1935
|
-
`;
|
|
1936
|
-
}
|
|
1937
|
-
function writeWireGuardConfig(config) {
|
|
1938
|
-
const configContent = generateWireGuardConfig(config);
|
|
1939
|
-
const tempFile = join4(CONFIG_DIR, "wg-temp.conf");
|
|
1940
|
-
writeFileSync4(tempFile, configContent, { mode: 384 });
|
|
1941
|
-
try {
|
|
1942
|
-
execSync4("sudo mkdir -p /etc/wireguard", { stdio: "inherit" });
|
|
1943
|
-
execSync4(`sudo mv ${tempFile} ${WG_CONFIG_PATH}`, { stdio: "inherit" });
|
|
1944
|
-
execSync4(`sudo chmod 600 ${WG_CONFIG_PATH}`, { stdio: "inherit" });
|
|
1945
|
-
} catch (error) {
|
|
1946
|
-
if (existsSync4(tempFile)) {
|
|
1947
|
-
unlinkSync(tempFile);
|
|
1948
|
-
}
|
|
1949
|
-
throw error;
|
|
2025
|
+
/**
|
|
2026
|
+
* Delete routes for an app
|
|
2027
|
+
*/
|
|
2028
|
+
async deleteRoute(appName) {
|
|
2029
|
+
await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
|
|
1950
2030
|
}
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2031
|
+
// ==================== Gateway Route Sync ====================
|
|
2032
|
+
/**
|
|
2033
|
+
* Sync gateway Caddy configuration for a location
|
|
2034
|
+
* This updates the gateway's reverse proxy config with current routes
|
|
2035
|
+
*/
|
|
2036
|
+
async syncGatewayRoutes(location) {
|
|
2037
|
+
const result = await this.request("POST", "/api/gateway/sync", { location }, { operation: `Sync gateway routes for '${location}'` });
|
|
2038
|
+
return { routeCount: result.routeCount };
|
|
1955
2039
|
}
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
2040
|
+
// ==================== SSH Certificate Management ====================
|
|
2041
|
+
/**
|
|
2042
|
+
* Get platform SSH keys (CA public key + platform public key for VM creation)
|
|
2043
|
+
*/
|
|
2044
|
+
async getPlatformSSHKeys() {
|
|
2045
|
+
return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
|
|
1961
2046
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2047
|
+
/**
|
|
2048
|
+
* Initialize platform SSH keys (creates them if they don't exist)
|
|
2049
|
+
*/
|
|
2050
|
+
async initPlatformSSHKeys() {
|
|
2051
|
+
return this.request("POST", "/api/platform/ssh-keys");
|
|
1966
2052
|
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2053
|
+
/**
|
|
2054
|
+
* Request a signed SSH certificate for accessing an app's VMs
|
|
2055
|
+
* Returns a short-lived certificate (5 min) signed by the platform CA
|
|
2056
|
+
*/
|
|
2057
|
+
async requestSSHCertificate(appName, publicKey) {
|
|
2058
|
+
return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
|
|
1972
2059
|
}
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2060
|
+
// ==================== VM Setup ====================
|
|
2061
|
+
/**
|
|
2062
|
+
* Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
|
|
2063
|
+
* This runs all configuration using the platform SSH key
|
|
2064
|
+
*/
|
|
2065
|
+
async setupVM(vmIp, location, appName) {
|
|
2066
|
+
await this.request("POST", "/api/vms/setup", {
|
|
2067
|
+
vmIp,
|
|
2068
|
+
location,
|
|
2069
|
+
appName
|
|
2070
|
+
}, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3e3 });
|
|
1983
2071
|
}
|
|
1984
|
-
|
|
1985
|
-
var VPNManager = class {
|
|
1986
|
-
wasConnectedByUs = false;
|
|
2072
|
+
// ==================== Environment Variables ====================
|
|
1987
2073
|
/**
|
|
1988
|
-
*
|
|
1989
|
-
* Returns true if VPN was established (and should be torn down later)
|
|
2074
|
+
* Set a single environment variable
|
|
1990
2075
|
*/
|
|
1991
|
-
async
|
|
1992
|
-
|
|
1993
|
-
console.log(chalk6.green("\u2713 Direct IPv6 connectivity available"));
|
|
1994
|
-
return false;
|
|
1995
|
-
}
|
|
1996
|
-
console.log(chalk6.yellow("IPv6 not available, checking VPN..."));
|
|
1997
|
-
if (isVPNUp()) {
|
|
1998
|
-
if (testIPv6Connectivity2(targetIPv6)) {
|
|
1999
|
-
console.log(chalk6.green("\u2713 VPN already connected"));
|
|
2000
|
-
return false;
|
|
2001
|
-
}
|
|
2002
|
-
console.log(chalk6.yellow("VPN is up but cannot reach target, reconnecting..."));
|
|
2003
|
-
vpnDown();
|
|
2004
|
-
}
|
|
2005
|
-
if (!isWireGuardInstalled()) {
|
|
2006
|
-
const instructions = getWireGuardInstallInstructions();
|
|
2007
|
-
throw new Error(
|
|
2008
|
-
"WireGuard is not installed.\n\nWireGuard is needed for IPv6 connectivity to your app VMs.\nPlease install it and try again:\n\n" + instructions
|
|
2009
|
-
);
|
|
2010
|
-
}
|
|
2011
|
-
console.log(chalk6.cyan("Establishing VPN tunnel (requires sudo)..."));
|
|
2012
|
-
const { publicKey, isNew } = getOrCreateKeyPair();
|
|
2013
|
-
if (isNew) {
|
|
2014
|
-
console.log(chalk6.dim("Generated new WireGuard keypair"));
|
|
2015
|
-
}
|
|
2016
|
-
const vpnConfig = await getVPNConfig();
|
|
2017
|
-
writeWireGuardConfig(vpnConfig);
|
|
2018
|
-
vpnUp();
|
|
2019
|
-
if (!testIPv6Connectivity2(targetIPv6)) {
|
|
2020
|
-
vpnDown();
|
|
2021
|
-
throw new Error("VPN established but cannot reach target. Please try again.");
|
|
2022
|
-
}
|
|
2023
|
-
console.log(chalk6.green("\u2713 VPN connected"));
|
|
2024
|
-
this.wasConnectedByUs = true;
|
|
2025
|
-
return true;
|
|
2076
|
+
async setEnvVar(appName, key, value) {
|
|
2077
|
+
await this.request("POST", `/api/apps/${appName}/env`, { key, value });
|
|
2026
2078
|
}
|
|
2027
2079
|
/**
|
|
2028
|
-
*
|
|
2080
|
+
* Set multiple environment variables at once
|
|
2029
2081
|
*/
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
console.log(chalk6.dim("Disconnecting VPN..."));
|
|
2033
|
-
try {
|
|
2034
|
-
vpnDown();
|
|
2035
|
-
console.log(chalk6.green("\u2713 VPN disconnected"));
|
|
2036
|
-
} catch (error) {
|
|
2037
|
-
console.log(chalk6.yellow(`Warning: Failed to disconnect VPN: ${error.message}`));
|
|
2038
|
-
}
|
|
2039
|
-
this.wasConnectedByUs = false;
|
|
2040
|
-
}
|
|
2082
|
+
async setEnvVars(appName, vars) {
|
|
2083
|
+
await this.request("POST", `/api/apps/${appName}/env`, { vars });
|
|
2041
2084
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
this.platformClient = platformClient;
|
|
2049
|
-
this.certManager = new SSHCertManager(platformClient);
|
|
2085
|
+
/**
|
|
2086
|
+
* List environment variables (values are masked)
|
|
2087
|
+
*/
|
|
2088
|
+
async listEnvVars(appName) {
|
|
2089
|
+
const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
|
|
2090
|
+
return envVars;
|
|
2050
2091
|
}
|
|
2051
|
-
certManager;
|
|
2052
|
-
gatewayCache = /* @__PURE__ */ new Map();
|
|
2053
|
-
tunnelManager = new TunnelManager();
|
|
2054
|
-
vpnManager = new VPNManager();
|
|
2055
|
-
tempConfigPath = null;
|
|
2056
|
-
vpnEstablishedByUs = false;
|
|
2057
2092
|
/**
|
|
2058
|
-
*
|
|
2059
|
-
* Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
|
|
2093
|
+
* Remove an environment variable
|
|
2060
2094
|
*/
|
|
2061
|
-
async
|
|
2062
|
-
|
|
2063
|
-
if (!app) {
|
|
2064
|
-
throw new Error(`App '${appName}' not found`);
|
|
2065
|
-
}
|
|
2066
|
-
const primaryNode = app.nodes.find((n) => n.isPrimary);
|
|
2067
|
-
if (!primaryNode?.ipv6) {
|
|
2068
|
-
throw new Error(`App '${appName}' has no primary node with IPv6 address`);
|
|
2069
|
-
}
|
|
2070
|
-
const vmIp = primaryNode.ipv6;
|
|
2071
|
-
await this.certManager.getSession(appName, vmIp);
|
|
2072
|
-
const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
|
|
2073
|
-
if (canConnectDirect) {
|
|
2074
|
-
const viaVPN = isRoutedViaVPN(vmIp);
|
|
2075
|
-
return {
|
|
2076
|
-
url: `ssh://root@${vmIp}`,
|
|
2077
|
-
vmIp,
|
|
2078
|
-
viaGateway: false,
|
|
2079
|
-
viaVPN
|
|
2080
|
-
};
|
|
2081
|
-
}
|
|
2082
|
-
const vpnAvailable = isWireGuardInstalled();
|
|
2083
|
-
if (vpnAvailable) {
|
|
2084
|
-
console.log(chalk7.yellow("Direct IPv6 not available, trying VPN..."));
|
|
2085
|
-
try {
|
|
2086
|
-
if (isVPNUp() && testIPv6Connectivity2(vmIp, 3)) {
|
|
2087
|
-
console.log(chalk7.green("\u2713 VPN already connected"));
|
|
2088
|
-
return {
|
|
2089
|
-
url: `ssh://root@${vmIp}`,
|
|
2090
|
-
vmIp,
|
|
2091
|
-
viaGateway: false,
|
|
2092
|
-
viaVPN: true
|
|
2093
|
-
};
|
|
2094
|
-
}
|
|
2095
|
-
this.vpnEstablishedByUs = await this.vpnManager.ensureConnected(
|
|
2096
|
-
vmIp,
|
|
2097
|
-
async () => this.getVPNConfig(app.location)
|
|
2098
|
-
);
|
|
2099
|
-
if (testIPv6Connectivity2(vmIp, 5)) {
|
|
2100
|
-
console.log(chalk7.green("\u2713 Connected via VPN"));
|
|
2101
|
-
return {
|
|
2102
|
-
url: `ssh://root@${vmIp}`,
|
|
2103
|
-
vmIp,
|
|
2104
|
-
viaGateway: false,
|
|
2105
|
-
viaVPN: true
|
|
2106
|
-
};
|
|
2107
|
-
}
|
|
2108
|
-
} catch (error) {
|
|
2109
|
-
console.log(chalk7.yellow(`VPN setup failed: ${error.message}`));
|
|
2110
|
-
console.log(chalk7.dim("Falling back to SSH tunnel..."));
|
|
2111
|
-
}
|
|
2112
|
-
} else {
|
|
2113
|
-
console.log(chalk7.yellow("Direct IPv6 not available on your network."));
|
|
2114
|
-
console.log(chalk7.dim("For faster deploys, install WireGuard:"));
|
|
2115
|
-
console.log(chalk7.dim(getWireGuardInstallInstructions()));
|
|
2116
|
-
console.log();
|
|
2117
|
-
}
|
|
2118
|
-
console.log(chalk7.yellow("Using SSH tunnel (may be slower for large transfers)..."));
|
|
2119
|
-
const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
|
|
2120
|
-
return {
|
|
2121
|
-
url: `ssh://root@localhost:${tunnelInfo.localPort}`,
|
|
2122
|
-
vmIp,
|
|
2123
|
-
viaGateway: true,
|
|
2124
|
-
viaVPN: false,
|
|
2125
|
-
localPort: tunnelInfo.localPort
|
|
2126
|
-
};
|
|
2095
|
+
async unsetEnvVar(appName, key) {
|
|
2096
|
+
await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
|
|
2127
2097
|
}
|
|
2098
|
+
// ==================== GitHub Connection ====================
|
|
2128
2099
|
/**
|
|
2129
|
-
*
|
|
2100
|
+
* Create app metadata without creating VM (for connect-before-deploy flow)
|
|
2130
2101
|
*/
|
|
2131
|
-
async
|
|
2132
|
-
const {
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
|
|
2140
|
-
allowedIPs: vpnConfigResponse.allowedIPs
|
|
2141
|
-
};
|
|
2102
|
+
async createAppMetadata(appName, location) {
|
|
2103
|
+
const { app } = await this.request("POST", "/api/apps", {
|
|
2104
|
+
appName,
|
|
2105
|
+
location: location || "eu-central-h1",
|
|
2106
|
+
metadataOnly: true
|
|
2107
|
+
// Signal that we don't want to create VM yet
|
|
2108
|
+
});
|
|
2109
|
+
return app;
|
|
2142
2110
|
}
|
|
2143
2111
|
/**
|
|
2144
|
-
*
|
|
2145
|
-
* This prevents "Host key verification failed" errors from uncloud
|
|
2112
|
+
* Initiate GitHub App installation flow
|
|
2146
2113
|
*/
|
|
2147
|
-
|
|
2114
|
+
async initiateGitHubConnect() {
|
|
2115
|
+
return this.request("POST", "/api/github/connect");
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Poll for GitHub App installation completion
|
|
2119
|
+
*/
|
|
2120
|
+
async pollGitHubConnect(stateToken) {
|
|
2121
|
+
return this.request("GET", `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Get current user's GitHub App installation
|
|
2125
|
+
*/
|
|
2126
|
+
async getGitHubInstallation() {
|
|
2148
2127
|
try {
|
|
2149
|
-
const
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2128
|
+
const { installation } = await this.request("GET", "/api/github/installation");
|
|
2129
|
+
return installation;
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
2132
|
+
return null;
|
|
2133
|
+
}
|
|
2134
|
+
throw error;
|
|
2155
2135
|
}
|
|
2156
2136
|
}
|
|
2157
2137
|
/**
|
|
2158
|
-
*
|
|
2138
|
+
* Delete current user's GitHub App installation record
|
|
2139
|
+
* Used when the installation becomes stale/invalid
|
|
2140
|
+
*/
|
|
2141
|
+
async deleteGitHubInstallation() {
|
|
2142
|
+
await this.request("DELETE", "/api/github/installation");
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* List repositories accessible via GitHub App installation
|
|
2159
2146
|
*/
|
|
2160
|
-
async
|
|
2161
|
-
const
|
|
2162
|
-
|
|
2163
|
-
throw new Error(`No gateway found for location ${location}`);
|
|
2164
|
-
}
|
|
2165
|
-
return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
|
|
2147
|
+
async listAccessibleRepos() {
|
|
2148
|
+
const { repos } = await this.request("GET", "/api/github/repos");
|
|
2149
|
+
return repos;
|
|
2166
2150
|
}
|
|
2167
2151
|
/**
|
|
2168
|
-
*
|
|
2152
|
+
* Connect a repository to an app
|
|
2169
2153
|
*/
|
|
2170
|
-
async
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
}
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
});
|
|
2182
|
-
|
|
2183
|
-
|
|
2154
|
+
async connectRepo(appName, repoFullName, branch) {
|
|
2155
|
+
await this.request("POST", `/api/apps/${appName}/repo`, {
|
|
2156
|
+
repoFullName,
|
|
2157
|
+
branch: branch || "main"
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Get connected repository for an app
|
|
2162
|
+
*/
|
|
2163
|
+
async getConnectedRepo(appName) {
|
|
2164
|
+
try {
|
|
2165
|
+
const { repo } = await this.request("GET", `/api/apps/${appName}/repo`);
|
|
2166
|
+
return repo;
|
|
2167
|
+
} catch (error) {
|
|
2168
|
+
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
2169
|
+
return null;
|
|
2184
2170
|
}
|
|
2185
|
-
|
|
2186
|
-
const result = execSync5(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
|
|
2187
|
-
cwd: options.cwd,
|
|
2188
|
-
encoding: "utf-8",
|
|
2189
|
-
timeout: options.timeout
|
|
2190
|
-
});
|
|
2191
|
-
return result;
|
|
2171
|
+
throw error;
|
|
2192
2172
|
}
|
|
2193
2173
|
}
|
|
2194
2174
|
/**
|
|
2195
|
-
*
|
|
2175
|
+
* Disconnect repository from an app
|
|
2196
2176
|
*/
|
|
2197
|
-
async
|
|
2198
|
-
await this.
|
|
2177
|
+
async disconnectRepo(appName) {
|
|
2178
|
+
await this.request("DELETE", `/api/apps/${appName}/repo`);
|
|
2199
2179
|
}
|
|
2180
|
+
// ==================== Builds ====================
|
|
2200
2181
|
/**
|
|
2201
|
-
*
|
|
2182
|
+
* List builds for an app
|
|
2202
2183
|
*/
|
|
2203
|
-
async
|
|
2204
|
-
const
|
|
2205
|
-
|
|
2206
|
-
if (options.tail) args.push("--tail", String(options.tail));
|
|
2207
|
-
await this.run(appName, "service", ["logs", ...args], { stdio: "inherit" });
|
|
2184
|
+
async listBuilds(appName, limit = 10) {
|
|
2185
|
+
const { builds } = await this.request("GET", `/api/apps/${appName}/builds?limit=${limit}`);
|
|
2186
|
+
return builds;
|
|
2208
2187
|
}
|
|
2209
2188
|
/**
|
|
2210
|
-
*
|
|
2189
|
+
* Get build details
|
|
2211
2190
|
*/
|
|
2212
|
-
async
|
|
2213
|
-
const
|
|
2214
|
-
return
|
|
2191
|
+
async getBuild(appName, buildId) {
|
|
2192
|
+
const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
|
|
2193
|
+
return build;
|
|
2215
2194
|
}
|
|
2216
2195
|
/**
|
|
2217
|
-
*
|
|
2218
|
-
* This is different - it runs on the VM directly, not via uncloud connector
|
|
2196
|
+
* Get build events (for live streaming)
|
|
2219
2197
|
*/
|
|
2220
|
-
async
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
|
|
2225
|
-
} else {
|
|
2226
|
-
sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
|
|
2198
|
+
async getBuildEvents(appName, buildId, after) {
|
|
2199
|
+
let url = `/api/apps/${appName}/builds/${buildId}/events`;
|
|
2200
|
+
if (after) {
|
|
2201
|
+
url += `?after=${encodeURIComponent(after.toISOString())}`;
|
|
2227
2202
|
}
|
|
2228
|
-
const
|
|
2229
|
-
return
|
|
2203
|
+
const { events } = await this.request("GET", url);
|
|
2204
|
+
return events;
|
|
2230
2205
|
}
|
|
2206
|
+
// ==================== VPN Management ====================
|
|
2231
2207
|
/**
|
|
2232
|
-
*
|
|
2208
|
+
* Register VPN peer with the platform
|
|
2209
|
+
* This is idempotent - if already registered, returns existing config
|
|
2233
2210
|
*/
|
|
2234
|
-
async
|
|
2235
|
-
const
|
|
2236
|
-
|
|
2237
|
-
if (connInfo.viaGateway && connInfo.localPort) {
|
|
2238
|
-
sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
|
|
2239
|
-
} else {
|
|
2240
|
-
sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
|
|
2241
|
-
}
|
|
2242
|
-
return execSync5(`${sshCmd2} "${command}"`, { encoding: "utf-8" }).trim();
|
|
2211
|
+
async registerVPNPeer(publicKey, location) {
|
|
2212
|
+
const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
|
|
2213
|
+
return vpnConfig;
|
|
2243
2214
|
}
|
|
2244
2215
|
/**
|
|
2245
|
-
*
|
|
2246
|
-
* Uses 'uc machine rm' to drain containers and remove from cluster
|
|
2216
|
+
* Get existing VPN config if registered
|
|
2247
2217
|
*/
|
|
2248
|
-
async
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
throw
|
|
2218
|
+
async getVPNConfig(location) {
|
|
2219
|
+
try {
|
|
2220
|
+
const { vpnConfig } = await this.request("GET", `/api/vpn/config?location=${encodeURIComponent(location)}`);
|
|
2221
|
+
return vpnConfig;
|
|
2222
|
+
} catch (error) {
|
|
2223
|
+
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
2224
|
+
return null;
|
|
2225
|
+
}
|
|
2226
|
+
throw error;
|
|
2257
2227
|
}
|
|
2258
2228
|
}
|
|
2259
2229
|
/**
|
|
2260
|
-
*
|
|
2230
|
+
* Unregister VPN peer
|
|
2261
2231
|
*/
|
|
2262
|
-
async
|
|
2263
|
-
|
|
2264
|
-
return result;
|
|
2232
|
+
async unregisterVPNPeer() {
|
|
2233
|
+
await this.request("DELETE", "/api/vpn/unregister");
|
|
2265
2234
|
}
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2235
|
+
};
|
|
2236
|
+
|
|
2237
|
+
// src/lib/app-config.ts
|
|
2238
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
2239
|
+
import { join as join4 } from "path";
|
|
2240
|
+
import { parse, stringify } from "yaml";
|
|
2241
|
+
var CONFIG_FILENAME = "hackerrun.yaml";
|
|
2242
|
+
function getConfigPath(directory = process.cwd()) {
|
|
2243
|
+
return join4(directory, CONFIG_FILENAME);
|
|
2244
|
+
}
|
|
2245
|
+
function hasAppConfig(directory = process.cwd()) {
|
|
2246
|
+
return existsSync4(getConfigPath(directory));
|
|
2247
|
+
}
|
|
2248
|
+
function readAppConfig(directory = process.cwd()) {
|
|
2249
|
+
const configPath = getConfigPath(directory);
|
|
2250
|
+
if (!existsSync4(configPath)) {
|
|
2279
2251
|
return null;
|
|
2280
2252
|
}
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
this.certManager.cleanupAll();
|
|
2287
|
-
if (this.vpnEstablishedByUs) {
|
|
2288
|
-
this.vpnManager.disconnect();
|
|
2289
|
-
this.vpnEstablishedByUs = false;
|
|
2253
|
+
try {
|
|
2254
|
+
const content = readFileSync4(configPath, "utf-8");
|
|
2255
|
+
const config = parse(content);
|
|
2256
|
+
if (!config.appName) {
|
|
2257
|
+
return null;
|
|
2290
2258
|
}
|
|
2259
|
+
return config;
|
|
2260
|
+
} catch (error) {
|
|
2261
|
+
console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
|
|
2262
|
+
return null;
|
|
2291
2263
|
}
|
|
2292
|
-
}
|
|
2264
|
+
}
|
|
2265
|
+
function writeAppConfig(config, directory = process.cwd()) {
|
|
2266
|
+
const configPath = getConfigPath(directory);
|
|
2267
|
+
const content = stringify(config);
|
|
2268
|
+
writeFileSync4(configPath, content, "utf-8");
|
|
2269
|
+
}
|
|
2270
|
+
function getAppName(directory = process.cwd()) {
|
|
2271
|
+
const config = readAppConfig(directory);
|
|
2272
|
+
if (config?.appName) {
|
|
2273
|
+
return config.appName;
|
|
2274
|
+
}
|
|
2275
|
+
const folderName = directory.split("/").pop() || "app";
|
|
2276
|
+
return folderName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
2277
|
+
}
|
|
2278
|
+
function linkApp(appName, directory = process.cwd()) {
|
|
2279
|
+
writeAppConfig({ appName }, directory);
|
|
2280
|
+
}
|
|
2293
2281
|
|
|
2294
2282
|
// src/commands/deploy.ts
|
|
2295
2283
|
var DEFAULT_LOCATION = "eu-central-h1";
|
|
@@ -3739,19 +3727,33 @@ Error: ${error.message}
|
|
|
3739
3727
|
import { Command as Command10 } from "commander";
|
|
3740
3728
|
import chalk16 from "chalk";
|
|
3741
3729
|
import ora10 from "ora";
|
|
3742
|
-
import { confirm } from "@inquirer/prompts";
|
|
3743
|
-
var DEFAULT_VM_SIZE = "
|
|
3730
|
+
import { select as select2, confirm } from "@inquirer/prompts";
|
|
3731
|
+
var DEFAULT_VM_SIZE = "burstable-1";
|
|
3744
3732
|
var DEFAULT_BOOT_IMAGE = "ubuntu-noble";
|
|
3733
|
+
function parseServiceList(output) {
|
|
3734
|
+
const lines = output.trim().split("\n");
|
|
3735
|
+
const services = [];
|
|
3736
|
+
for (const line of lines) {
|
|
3737
|
+
const trimmed = line.trim();
|
|
3738
|
+
if (!trimmed || trimmed.startsWith("NAME") || trimmed.startsWith("Connecting") || trimmed.startsWith("Connected")) {
|
|
3739
|
+
continue;
|
|
3740
|
+
}
|
|
3741
|
+
const svcName = trimmed.split(/\s+/)[0];
|
|
3742
|
+
if (svcName) {
|
|
3743
|
+
services.push(svcName);
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
return services;
|
|
3747
|
+
}
|
|
3745
3748
|
function createScaleCommand() {
|
|
3746
3749
|
const cmd = new Command10("scale");
|
|
3747
|
-
cmd.description("Scale
|
|
3750
|
+
cmd.description("Scale a service in your app").argument("<app>", "App name").argument("[service]", "Service name (optional - will prompt if not provided)").argument("[replicas]", "Number of replicas").option("--add-machines", "Automatically add machines if needed").option("--no-add-machine", "Scale on existing machines only").option("--size <size>", `VM size for new machines (default: ${DEFAULT_VM_SIZE})`).action(async (appName, serviceArg, replicasArg, options) => {
|
|
3748
3751
|
let uncloudRunner = null;
|
|
3749
3752
|
try {
|
|
3750
|
-
const appName = options.app || getAppName();
|
|
3751
3753
|
const platformToken = getPlatformToken();
|
|
3752
3754
|
const platformClient = new PlatformClient(platformToken);
|
|
3753
|
-
const clusterManager = new ClusterManager(platformClient);
|
|
3754
3755
|
uncloudRunner = new UncloudRunner(platformClient);
|
|
3756
|
+
const clusterManager = new ClusterManager(platformClient, uncloudRunner);
|
|
3755
3757
|
const app = await platformClient.getApp(appName);
|
|
3756
3758
|
if (!app) {
|
|
3757
3759
|
console.log(chalk16.red(`
|
|
@@ -3761,146 +3763,122 @@ App '${appName}' not found
|
|
|
3761
3763
|
`);
|
|
3762
3764
|
process.exit(1);
|
|
3763
3765
|
}
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
+
let services;
|
|
3767
|
+
const spinner = ora10("Fetching services...").start();
|
|
3768
|
+
try {
|
|
3769
|
+
const output = await uncloudRunner.serviceList(appName);
|
|
3770
|
+
services = parseServiceList(output);
|
|
3771
|
+
spinner.stop();
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
spinner.fail("Failed to fetch services");
|
|
3774
|
+
console.log(chalk16.yellow("\nMake sure the app is deployed and running.\n"));
|
|
3775
|
+
process.exit(1);
|
|
3776
|
+
}
|
|
3777
|
+
if (services.length === 0) {
|
|
3778
|
+
console.log(chalk16.yellow(`
|
|
3779
|
+
No services found in app '${appName}'`));
|
|
3780
|
+
console.log(`Run ${chalk16.bold("hackerrun deploy")} to deploy your app first.
|
|
3781
|
+
`);
|
|
3782
|
+
process.exit(1);
|
|
3783
|
+
}
|
|
3784
|
+
let serviceName;
|
|
3785
|
+
let replicas;
|
|
3786
|
+
if (!serviceArg) {
|
|
3766
3787
|
console.log(chalk16.cyan(`
|
|
3767
|
-
|
|
3788
|
+
Services in '${appName}':
|
|
3768
3789
|
`));
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
const primaryLabel = node.isPrimary ? chalk16.yellow(" (primary)") : "";
|
|
3773
|
-
console.log(` ${index + 1}. ${chalk16.bold(node.name)}${primaryLabel}`);
|
|
3774
|
-
console.log(` IPv6: ${node.ipv6 || "pending"}`);
|
|
3775
|
-
console.log();
|
|
3790
|
+
serviceName = await select2({
|
|
3791
|
+
message: "Which service would you like to scale?",
|
|
3792
|
+
choices: services.map((s) => ({ name: s, value: s }))
|
|
3776
3793
|
});
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
console.log(chalk16.dim(` hackerrun scale -1 # Remove 1 node`));
|
|
3781
|
-
console.log();
|
|
3782
|
-
return;
|
|
3783
|
-
}
|
|
3784
|
-
let targetCount;
|
|
3785
|
-
if (countArg.startsWith("+")) {
|
|
3786
|
-
const delta = parseInt(countArg.slice(1), 10);
|
|
3787
|
-
if (isNaN(delta) || delta <= 0) {
|
|
3788
|
-
console.log(chalk16.red(`
|
|
3789
|
-
Invalid count: ${countArg}
|
|
3794
|
+
if (!replicasArg) {
|
|
3795
|
+
console.log(chalk16.red("\nPlease specify the number of replicas.\n"));
|
|
3796
|
+
console.log(chalk16.dim(`Usage: hackerrun scale ${appName} ${serviceName} <replicas>
|
|
3790
3797
|
`));
|
|
3791
3798
|
process.exit(1);
|
|
3792
3799
|
}
|
|
3793
|
-
|
|
3794
|
-
} else if (
|
|
3795
|
-
const
|
|
3796
|
-
if (isNaN(
|
|
3797
|
-
|
|
3798
|
-
|
|
3800
|
+
replicas = parseInt(replicasArg, 10);
|
|
3801
|
+
} else if (!replicasArg) {
|
|
3802
|
+
const maybeReplicas = parseInt(serviceArg, 10);
|
|
3803
|
+
if (!isNaN(maybeReplicas)) {
|
|
3804
|
+
replicas = maybeReplicas;
|
|
3805
|
+
console.log(chalk16.cyan(`
|
|
3806
|
+
Services in '${appName}':
|
|
3807
|
+
`));
|
|
3808
|
+
serviceName = await select2({
|
|
3809
|
+
message: "Which service would you like to scale?",
|
|
3810
|
+
choices: services.map((s) => ({ name: s, value: s }))
|
|
3811
|
+
});
|
|
3812
|
+
} else {
|
|
3813
|
+
console.log(chalk16.red("\nPlease specify the number of replicas.\n"));
|
|
3814
|
+
console.log(chalk16.dim(`Usage: hackerrun scale ${appName} ${serviceArg} <replicas>
|
|
3799
3815
|
`));
|
|
3800
3816
|
process.exit(1);
|
|
3801
3817
|
}
|
|
3802
|
-
targetCount = currentNodeCount - delta;
|
|
3803
3818
|
} else {
|
|
3804
|
-
|
|
3805
|
-
|
|
3819
|
+
serviceName = serviceArg;
|
|
3820
|
+
replicas = parseInt(replicasArg, 10);
|
|
3821
|
+
if (!services.includes(serviceName)) {
|
|
3806
3822
|
console.log(chalk16.red(`
|
|
3807
|
-
|
|
3823
|
+
Service '${serviceName}' not found in app '${appName}'.
|
|
3808
3824
|
`));
|
|
3825
|
+
console.log(chalk16.cyan("Available services:"));
|
|
3826
|
+
services.forEach((s) => console.log(` - ${s}`));
|
|
3827
|
+
console.log();
|
|
3809
3828
|
process.exit(1);
|
|
3810
3829
|
}
|
|
3811
3830
|
}
|
|
3812
|
-
if (
|
|
3813
|
-
console.log(chalk16.red(
|
|
3814
|
-
Cannot scale below 1 node. Use 'hackerrun destroy' to remove the app.
|
|
3815
|
-
`));
|
|
3831
|
+
if (isNaN(replicas) || replicas < 1) {
|
|
3832
|
+
console.log(chalk16.red("\nReplicas must be a positive number.\n"));
|
|
3816
3833
|
process.exit(1);
|
|
3817
3834
|
}
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3835
|
+
const currentMachineCount = app.nodes.length;
|
|
3836
|
+
console.log(chalk16.cyan(`
|
|
3837
|
+
Scaling '${serviceName}' to ${replicas} replica(s)...`));
|
|
3838
|
+
console.log(chalk16.dim(`Current machines: ${currentMachineCount}
|
|
3821
3839
|
`));
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
Scaling
|
|
3840
|
+
let shouldAddMachines = false;
|
|
3841
|
+
if (replicas > currentMachineCount) {
|
|
3842
|
+
if (options.addMachines) {
|
|
3843
|
+
shouldAddMachines = true;
|
|
3844
|
+
} else if (options.addMachine === false) {
|
|
3845
|
+
shouldAddMachines = false;
|
|
3846
|
+
console.log(chalk16.yellow(`Scaling ${replicas} replicas across ${currentMachineCount} machine(s).
|
|
3829
3847
|
`));
|
|
3830
|
-
|
|
3831
|
-
console.log(chalk16.
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
console.log(chalk16.
|
|
3835
|
-
}
|
|
3836
|
-
const updatedApp = await platformClient.getApp(appName);
|
|
3837
|
-
const syncSpinner = ora10("Syncing gateway routes...").start();
|
|
3838
|
-
try {
|
|
3839
|
-
await platformClient.syncGatewayRoutes(app.location);
|
|
3840
|
-
syncSpinner.succeed("Gateway routes synced");
|
|
3841
|
-
} catch (error) {
|
|
3842
|
-
syncSpinner.warn(`Gateway sync failed: ${error.message}`);
|
|
3843
|
-
}
|
|
3844
|
-
console.log(chalk16.green(`
|
|
3845
|
-
\u2713 Scaled to ${updatedApp?.nodes.length} nodes
|
|
3846
|
-
`));
|
|
3847
|
-
console.log(chalk16.dim("To deploy to specific nodes, use x-machines in your compose file:"));
|
|
3848
|
-
console.log(chalk16.dim(" services:"));
|
|
3849
|
-
console.log(chalk16.dim(" web:"));
|
|
3850
|
-
console.log(chalk16.dim(" x-machines:"));
|
|
3851
|
-
updatedApp?.nodes.forEach((node) => {
|
|
3852
|
-
console.log(chalk16.dim(` - ${node.name}`));
|
|
3853
|
-
});
|
|
3854
|
-
console.log();
|
|
3855
|
-
} else {
|
|
3856
|
-
const nodesToRemove = currentNodeCount - targetCount;
|
|
3857
|
-
const sortedNodes = [...app.nodes].sort((a, b) => {
|
|
3858
|
-
const numA = parseInt(a.name.split("-").pop() || "0", 10);
|
|
3859
|
-
const numB = parseInt(b.name.split("-").pop() || "0", 10);
|
|
3860
|
-
return numB - numA;
|
|
3861
|
-
});
|
|
3862
|
-
const nodesToRemoveList = sortedNodes.slice(0, nodesToRemove);
|
|
3863
|
-
console.log(chalk16.yellow(`
|
|
3864
|
-
Scaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (-${nodesToRemove})
|
|
3848
|
+
} else {
|
|
3849
|
+
console.log(chalk16.yellow(`You want ${replicas} replicas but only have ${currentMachineCount} machine(s).`));
|
|
3850
|
+
console.log(chalk16.dim(`You can either:`));
|
|
3851
|
+
console.log(chalk16.dim(` \u2022 Add ${replicas - currentMachineCount} new machine(s) for dedicated capacity`));
|
|
3852
|
+
console.log(chalk16.dim(` \u2022 Run ${replicas} replicas on your existing ${currentMachineCount} machine(s)
|
|
3865
3853
|
`));
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
console.log(` - ${node.name} (${node.ipv6})`);
|
|
3869
|
-
});
|
|
3870
|
-
console.log();
|
|
3871
|
-
if (!options.force) {
|
|
3872
|
-
console.log(chalk16.yellow("Warning: All containers on these nodes will be stopped and the VMs deleted."));
|
|
3873
|
-
console.log(chalk16.yellow("Data on these nodes will be lost.\n"));
|
|
3874
|
-
const confirmed = await confirm({
|
|
3875
|
-
message: "Are you sure you want to remove these nodes?",
|
|
3854
|
+
shouldAddMachines = await confirm({
|
|
3855
|
+
message: `Add ${replicas - currentMachineCount} new machine(s)?`,
|
|
3876
3856
|
default: false
|
|
3877
3857
|
});
|
|
3878
|
-
if (!
|
|
3879
|
-
console.log(chalk16.dim(
|
|
3880
|
-
|
|
3858
|
+
if (!shouldAddMachines) {
|
|
3859
|
+
console.log(chalk16.dim(`
|
|
3860
|
+
Scaling ${replicas} replicas across ${currentMachineCount} existing machine(s).
|
|
3861
|
+
`));
|
|
3881
3862
|
}
|
|
3882
3863
|
}
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3864
|
+
}
|
|
3865
|
+
if (shouldAddMachines && replicas > currentMachineCount) {
|
|
3866
|
+
const machinesToAdd = replicas - currentMachineCount;
|
|
3867
|
+
const vmSize = options.size || DEFAULT_VM_SIZE;
|
|
3868
|
+
console.log(chalk16.cyan(`
|
|
3869
|
+
Adding ${machinesToAdd} machine(s)...
|
|
3870
|
+
`));
|
|
3871
|
+
for (let i = 0; i < machinesToAdd; i++) {
|
|
3872
|
+
console.log(chalk16.cyan(`Adding machine ${i + 1} of ${machinesToAdd}...`));
|
|
3886
3873
|
try {
|
|
3887
|
-
const
|
|
3888
|
-
|
|
3889
|
-
spinner.succeed("Removed from cluster");
|
|
3890
|
-
spinner.start("Deleting VM...");
|
|
3891
|
-
await platformClient.deleteVM(app.location, node.name);
|
|
3892
|
-
spinner.succeed("VM deleted");
|
|
3893
|
-
spinner.start("Updating app state...");
|
|
3894
|
-
const updatedNodes = app.nodes.filter((n) => n.name !== node.name);
|
|
3895
|
-
app.nodes = updatedNodes;
|
|
3896
|
-
await platformClient.saveApp(app);
|
|
3897
|
-
spinner.succeed("App state updated");
|
|
3898
|
-
console.log(chalk16.green(` Removed: ${node.name}`));
|
|
3874
|
+
const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
|
|
3875
|
+
console.log(chalk16.green(` Added: ${newNode.name} (${newNode.ipv6})`));
|
|
3899
3876
|
} catch (error) {
|
|
3900
|
-
console.log(chalk16.red(` Failed to
|
|
3877
|
+
console.log(chalk16.red(` Failed to add machine: ${error.message}`));
|
|
3878
|
+
console.log(chalk16.yellow(" Continuing with available machines...\n"));
|
|
3879
|
+
break;
|
|
3901
3880
|
}
|
|
3902
3881
|
}
|
|
3903
|
-
const updatedApp = await platformClient.getApp(appName);
|
|
3904
3882
|
const syncSpinner = ora10("Syncing gateway routes...").start();
|
|
3905
3883
|
try {
|
|
3906
3884
|
await platformClient.syncGatewayRoutes(app.location);
|
|
@@ -3908,10 +3886,22 @@ Removing node '${node.name}'...`));
|
|
|
3908
3886
|
} catch (error) {
|
|
3909
3887
|
syncSpinner.warn(`Gateway sync failed: ${error.message}`);
|
|
3910
3888
|
}
|
|
3911
|
-
console.log(chalk16.green(`
|
|
3912
|
-
\u2713 Scaled to ${updatedApp?.nodes.length} nodes
|
|
3913
|
-
`));
|
|
3914
3889
|
}
|
|
3890
|
+
const scaleSpinner = ora10(`Scaling ${serviceName} to ${replicas} replicas...`).start();
|
|
3891
|
+
try {
|
|
3892
|
+
await uncloudRunner.run(appName, "scale", [serviceName, String(replicas)], { stdio: "pipe" });
|
|
3893
|
+
scaleSpinner.succeed(chalk16.green(`Scaled '${serviceName}' to ${replicas} replica(s)`));
|
|
3894
|
+
} catch (error) {
|
|
3895
|
+
scaleSpinner.fail(`Failed to scale service: ${error.message}`);
|
|
3896
|
+
process.exit(1);
|
|
3897
|
+
}
|
|
3898
|
+
const updatedApp = await platformClient.getApp(appName);
|
|
3899
|
+
console.log(chalk16.cyan("\nScale Summary:\n"));
|
|
3900
|
+
console.log(` App: ${chalk16.bold(appName)}`);
|
|
3901
|
+
console.log(` Service: ${chalk16.bold(serviceName)}`);
|
|
3902
|
+
console.log(` Replicas: ${chalk16.bold(String(replicas))}`);
|
|
3903
|
+
console.log(` Machines: ${chalk16.bold(String(updatedApp?.nodes.length || currentMachineCount))}`);
|
|
3904
|
+
console.log();
|
|
3915
3905
|
} catch (error) {
|
|
3916
3906
|
console.error(chalk16.red(`
|
|
3917
3907
|
Error: ${error.message}
|