hackerrun 0.1.10 → 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 +1001 -1002
- 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
|
|
@@ -868,7 +1438,7 @@ echo "Docker NAT64 configuration complete"
|
|
|
868
1438
|
sshHost = "localhost";
|
|
869
1439
|
sshPortArgs = `-p ${tunnel.localPort}`;
|
|
870
1440
|
}
|
|
871
|
-
|
|
1441
|
+
execSync4(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
|
|
872
1442
|
${setupScript}
|
|
873
1443
|
REMOTESCRIPT`, {
|
|
874
1444
|
stdio: "inherit",
|
|
@@ -894,13 +1464,13 @@ REMOTESCRIPT`, {
|
|
|
894
1464
|
while (Date.now() - startTime < timeoutMs) {
|
|
895
1465
|
const vm = await this.platformClient.getVM(location, vmName);
|
|
896
1466
|
if (vm.status && vm.status !== lastStatus) {
|
|
897
|
-
console.log(
|
|
1467
|
+
console.log(chalk4.dim(` Status: ${vm.status}`));
|
|
898
1468
|
lastStatus = vm.status;
|
|
899
1469
|
}
|
|
900
1470
|
const hasRequiredIp = requireIpv4 ? vm.ip4 : vm.ip6;
|
|
901
1471
|
if (hasRequiredIp) {
|
|
902
1472
|
const ipDisplay = requireIpv4 ? vm.ip4 : vm.ip6;
|
|
903
|
-
console.log(
|
|
1473
|
+
console.log(chalk4.green(` IP assigned: ${ipDisplay}`));
|
|
904
1474
|
return vm;
|
|
905
1475
|
}
|
|
906
1476
|
const elapsed = Math.floor((Date.now() - startTime) / 1e3);
|
|
@@ -929,27 +1499,27 @@ The VM may still be provisioning. You can:
|
|
|
929
1499
|
const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
|
|
930
1500
|
const proxyJump = !canConnectDirect && gatewayIp ? `-J root@${gatewayIp}` : "";
|
|
931
1501
|
const sshBase = `ssh ${proxyJump} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 root@${vmIp}`;
|
|
932
|
-
console.log(
|
|
1502
|
+
console.log(chalk4.dim(" Waiting for uncloud services..."));
|
|
933
1503
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
934
1504
|
try {
|
|
935
|
-
const result =
|
|
1505
|
+
const result = execSync4(
|
|
936
1506
|
`${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
|
|
937
1507
|
{ encoding: "utf-8", timeout: 2e4, stdio: ["pipe", "pipe", "pipe"] }
|
|
938
1508
|
).trim();
|
|
939
1509
|
if (result.includes("ready")) {
|
|
940
|
-
console.log(
|
|
1510
|
+
console.log(chalk4.dim(" Uncloud services ready"));
|
|
941
1511
|
return;
|
|
942
1512
|
}
|
|
943
1513
|
} catch (error) {
|
|
944
1514
|
}
|
|
945
1515
|
if (attempt % 5 === 0) {
|
|
946
|
-
console.log(
|
|
1516
|
+
console.log(chalk4.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
|
|
947
1517
|
}
|
|
948
1518
|
if (attempt < maxAttempts) {
|
|
949
1519
|
await this.sleep(checkInterval);
|
|
950
1520
|
}
|
|
951
1521
|
}
|
|
952
|
-
console.log(
|
|
1522
|
+
console.log(chalk4.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
|
|
953
1523
|
}
|
|
954
1524
|
/**
|
|
955
1525
|
* Generate a sequential node name: appName-1, appName-2, etc.
|
|
@@ -978,21 +1548,21 @@ The VM may still be provisioning. You can:
|
|
|
978
1548
|
};
|
|
979
1549
|
|
|
980
1550
|
// src/lib/platform.ts
|
|
981
|
-
import { platform } from "os";
|
|
982
|
-
import
|
|
1551
|
+
import { platform as platform2 } from "os";
|
|
1552
|
+
import chalk5 from "chalk";
|
|
983
1553
|
var PlatformDetector = class {
|
|
984
1554
|
/**
|
|
985
1555
|
* Check if running on Windows
|
|
986
1556
|
*/
|
|
987
1557
|
static isWindows() {
|
|
988
|
-
return
|
|
1558
|
+
return platform2() === "win32";
|
|
989
1559
|
}
|
|
990
1560
|
/**
|
|
991
1561
|
* Check if running inside WSL (Windows Subsystem for Linux)
|
|
992
1562
|
*/
|
|
993
1563
|
static isWSL() {
|
|
994
1564
|
if (!this.isWindows()) {
|
|
995
|
-
if (
|
|
1565
|
+
if (platform2() === "linux") {
|
|
996
1566
|
try {
|
|
997
1567
|
const fs = __require("fs");
|
|
998
1568
|
const procVersion = fs.readFileSync("/proc/version", "utf8").toLowerCase();
|
|
@@ -1009,40 +1579,40 @@ var PlatformDetector = class {
|
|
|
1009
1579
|
* Check if platform is supported for hackerrun
|
|
1010
1580
|
*/
|
|
1011
1581
|
static isSupported() {
|
|
1012
|
-
return
|
|
1582
|
+
return platform2() === "darwin" || platform2() === "linux" || this.isWSL();
|
|
1013
1583
|
}
|
|
1014
1584
|
/**
|
|
1015
1585
|
* Get platform name for display
|
|
1016
1586
|
*/
|
|
1017
1587
|
static getPlatformName() {
|
|
1018
1588
|
if (this.isWSL()) return "WSL (Windows Subsystem for Linux)";
|
|
1019
|
-
if (
|
|
1020
|
-
if (
|
|
1021
|
-
if (
|
|
1022
|
-
return
|
|
1589
|
+
if (platform2() === "win32") return "Windows";
|
|
1590
|
+
if (platform2() === "darwin") return "macOS";
|
|
1591
|
+
if (platform2() === "linux") return "Linux";
|
|
1592
|
+
return platform2();
|
|
1023
1593
|
}
|
|
1024
1594
|
/**
|
|
1025
1595
|
* Ensure platform is supported, exit with helpful message if not
|
|
1026
1596
|
*/
|
|
1027
1597
|
static ensureSupported() {
|
|
1028
1598
|
if (this.isWindows() && !this.isWSL()) {
|
|
1029
|
-
console.log(
|
|
1599
|
+
console.log(chalk5.yellow("\n\u26A0\uFE0F Windows detected\n"));
|
|
1030
1600
|
console.log("Hackerrun requires WSL (Windows Subsystem for Linux) to run.");
|
|
1031
1601
|
console.log("Uncloud and SSH tools work best in a Linux environment.\n");
|
|
1032
|
-
console.log(
|
|
1602
|
+
console.log(chalk5.cyan("How to set up WSL:\n"));
|
|
1033
1603
|
console.log("1. Open PowerShell as Administrator and run:");
|
|
1034
|
-
console.log(
|
|
1604
|
+
console.log(chalk5.bold(" wsl --install\n"));
|
|
1035
1605
|
console.log("2. Restart your computer\n");
|
|
1036
1606
|
console.log("3. Install Ubuntu from Microsoft Store (or use default Linux)\n");
|
|
1037
1607
|
console.log("4. Open WSL terminal and install hackerrun:");
|
|
1038
|
-
console.log(
|
|
1608
|
+
console.log(chalk5.bold(" npm install -g hackerrun\n"));
|
|
1039
1609
|
console.log("Learn more: https://docs.microsoft.com/en-us/windows/wsl/install\n");
|
|
1040
|
-
console.log(
|
|
1610
|
+
console.log(chalk5.red("Please install WSL and run hackerrun from WSL terminal.\n"));
|
|
1041
1611
|
process.exit(1);
|
|
1042
1612
|
}
|
|
1043
1613
|
if (!this.isSupported()) {
|
|
1044
|
-
console.log(
|
|
1045
|
-
\u274C Platform '${
|
|
1614
|
+
console.log(chalk5.red(`
|
|
1615
|
+
\u274C Platform '${platform2()}' is not supported
|
|
1046
1616
|
`));
|
|
1047
1617
|
console.log("Hackerrun supports:");
|
|
1048
1618
|
console.log(" - macOS");
|
|
@@ -1054,9 +1624,9 @@ var PlatformDetector = class {
|
|
|
1054
1624
|
};
|
|
1055
1625
|
|
|
1056
1626
|
// src/lib/uncloud.ts
|
|
1057
|
-
import { execSync as
|
|
1058
|
-
import { platform as
|
|
1059
|
-
import
|
|
1627
|
+
import { execSync as execSync5 } from "child_process";
|
|
1628
|
+
import { platform as platform3 } from "os";
|
|
1629
|
+
import chalk6 from "chalk";
|
|
1060
1630
|
import ora3 from "ora";
|
|
1061
1631
|
var UncloudManager = class {
|
|
1062
1632
|
/**
|
|
@@ -1064,7 +1634,7 @@ var UncloudManager = class {
|
|
|
1064
1634
|
*/
|
|
1065
1635
|
static isInstalled() {
|
|
1066
1636
|
try {
|
|
1067
|
-
|
|
1637
|
+
execSync5("uc --version", { stdio: "pipe" });
|
|
1068
1638
|
return true;
|
|
1069
1639
|
} catch {
|
|
1070
1640
|
return false;
|
|
@@ -1075,7 +1645,7 @@ var UncloudManager = class {
|
|
|
1075
1645
|
*/
|
|
1076
1646
|
static getVersion() {
|
|
1077
1647
|
try {
|
|
1078
|
-
const version =
|
|
1648
|
+
const version = execSync5("uc --version", { encoding: "utf-8" }).trim();
|
|
1079
1649
|
return version;
|
|
1080
1650
|
} catch {
|
|
1081
1651
|
return null;
|
|
@@ -1090,34 +1660,34 @@ var UncloudManager = class {
|
|
|
1090
1660
|
return;
|
|
1091
1661
|
}
|
|
1092
1662
|
if (options?.nonInteractive) {
|
|
1093
|
-
console.error(
|
|
1663
|
+
console.error(chalk6.red("\n\u274C Uncloud CLI (uc) not found\n"));
|
|
1094
1664
|
console.error("The uncloud CLI must be installed on the build VM.");
|
|
1095
1665
|
console.error("This is a build infrastructure issue - the uc binary should be pre-installed.\n");
|
|
1096
1666
|
process.exit(1);
|
|
1097
1667
|
}
|
|
1098
|
-
console.log(
|
|
1668
|
+
console.log(chalk6.yellow("\n\u26A0\uFE0F Uncloud CLI not found\n"));
|
|
1099
1669
|
console.log("Uncloud is required to deploy and manage your apps.");
|
|
1100
1670
|
console.log("Learn more: https://uncloud.run\n");
|
|
1101
|
-
const os =
|
|
1671
|
+
const os = platform3();
|
|
1102
1672
|
if (os === "darwin" || os === "linux") {
|
|
1103
1673
|
this.showInstallInstructions();
|
|
1104
|
-
console.log(
|
|
1674
|
+
console.log(chalk6.cyan("Would you like to install uncloud now? (Y/n): "));
|
|
1105
1675
|
const answer = await this.promptUser();
|
|
1106
1676
|
if (answer === "y" || answer === "yes" || answer === "") {
|
|
1107
1677
|
try {
|
|
1108
1678
|
await this.autoInstall();
|
|
1109
1679
|
return;
|
|
1110
1680
|
} catch (error) {
|
|
1111
|
-
console.log(
|
|
1681
|
+
console.log(chalk6.red("\nAuto-installation failed. Please install manually.\n"));
|
|
1112
1682
|
process.exit(1);
|
|
1113
1683
|
}
|
|
1114
1684
|
} else {
|
|
1115
|
-
console.log(
|
|
1685
|
+
console.log(chalk6.yellow("\nPlease install uncloud manually and run hackerrun again.\n"));
|
|
1116
1686
|
process.exit(1);
|
|
1117
1687
|
}
|
|
1118
1688
|
} else {
|
|
1119
1689
|
this.showInstallInstructions();
|
|
1120
|
-
console.log(
|
|
1690
|
+
console.log(chalk6.red("Please install uncloud and run hackerrun again.\n"));
|
|
1121
1691
|
process.exit(1);
|
|
1122
1692
|
}
|
|
1123
1693
|
}
|
|
@@ -1125,20 +1695,20 @@ var UncloudManager = class {
|
|
|
1125
1695
|
* Show installation instructions based on platform
|
|
1126
1696
|
*/
|
|
1127
1697
|
static showInstallInstructions() {
|
|
1128
|
-
const os =
|
|
1129
|
-
console.log(
|
|
1698
|
+
const os = platform3();
|
|
1699
|
+
console.log(chalk6.cyan("Installation instructions:\n"));
|
|
1130
1700
|
if (os === "darwin" || os === "linux") {
|
|
1131
|
-
console.log(
|
|
1132
|
-
console.log(
|
|
1133
|
-
console.log(
|
|
1134
|
-
console.log(
|
|
1135
|
-
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"));
|
|
1136
1706
|
console.log(" Download from: https://github.com/psviderski/uncloud/releases/latest");
|
|
1137
1707
|
console.log(" Extract and move to /usr/local/bin\n");
|
|
1138
1708
|
} else if (os === "win32") {
|
|
1139
|
-
console.log(
|
|
1709
|
+
console.log(chalk6.bold("For Windows (via WSL):"));
|
|
1140
1710
|
console.log(" Open your WSL terminal and run:");
|
|
1141
|
-
console.log(
|
|
1711
|
+
console.log(chalk6.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
|
|
1142
1712
|
}
|
|
1143
1713
|
console.log("Documentation: https://uncloud.run/docs/getting-started/install-cli\n");
|
|
1144
1714
|
}
|
|
@@ -1163,27 +1733,27 @@ var UncloudManager = class {
|
|
|
1163
1733
|
static async autoInstall() {
|
|
1164
1734
|
const spinner = ora3("Installing uncloud...").start();
|
|
1165
1735
|
try {
|
|
1166
|
-
|
|
1736
|
+
execSync5("curl -fsS https://get.uncloud.run/install.sh | sh", {
|
|
1167
1737
|
stdio: "inherit"
|
|
1168
1738
|
});
|
|
1169
|
-
spinner.succeed(
|
|
1739
|
+
spinner.succeed(chalk6.green("Uncloud installed successfully!"));
|
|
1170
1740
|
if (this.isInstalled()) {
|
|
1171
1741
|
const version = this.getVersion();
|
|
1172
|
-
console.log(
|
|
1742
|
+
console.log(chalk6.cyan(`\u2713 Uncloud ${version} is ready to use
|
|
1173
1743
|
`));
|
|
1174
1744
|
} else {
|
|
1175
|
-
spinner.warn(
|
|
1176
|
-
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:"));
|
|
1177
1747
|
console.log(" 1. Restart your terminal");
|
|
1178
1748
|
console.log(" 2. Or run: source ~/.bashrc (or ~/.zshrc)\n");
|
|
1179
1749
|
throw new Error("Please restart your terminal and try again");
|
|
1180
1750
|
}
|
|
1181
1751
|
} catch (error) {
|
|
1182
|
-
spinner.fail(
|
|
1183
|
-
console.log(
|
|
1752
|
+
spinner.fail(chalk6.red("Failed to install uncloud"));
|
|
1753
|
+
console.log(chalk6.red(`
|
|
1184
1754
|
Error: ${error.message}
|
|
1185
1755
|
`));
|
|
1186
|
-
console.log(
|
|
1756
|
+
console.log(chalk6.yellow("Please try manual installation:"));
|
|
1187
1757
|
console.log(" curl -fsS https://get.uncloud.run/install.sh | sh\n");
|
|
1188
1758
|
throw error;
|
|
1189
1759
|
}
|
|
@@ -1191,16 +1761,16 @@ Error: ${error.message}
|
|
|
1191
1761
|
};
|
|
1192
1762
|
|
|
1193
1763
|
// src/lib/platform-auth.ts
|
|
1194
|
-
import
|
|
1764
|
+
import chalk7 from "chalk";
|
|
1195
1765
|
function getPlatformToken() {
|
|
1196
1766
|
const configManager = new ConfigManager();
|
|
1197
1767
|
try {
|
|
1198
1768
|
const config = configManager.load();
|
|
1199
1769
|
return config.apiToken;
|
|
1200
1770
|
} catch (error) {
|
|
1201
|
-
console.error(
|
|
1202
|
-
console.log(
|
|
1203
|
-
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")}
|
|
1204
1774
|
`);
|
|
1205
1775
|
process.exit(1);
|
|
1206
1776
|
}
|
|
@@ -1412,243 +1982,12 @@ var PlatformClient = class {
|
|
|
1412
1982
|
await this.request("POST", "/api/uncloud/config", { config: configYaml });
|
|
1413
1983
|
}
|
|
1414
1984
|
/**
|
|
1415
|
-
* Download uncloud config from platform
|
|
1416
|
-
*/
|
|
1417
|
-
async downloadUncloudConfig() {
|
|
1418
|
-
try {
|
|
1419
|
-
const { config } = await this.request("GET", "/api/uncloud/config");
|
|
1420
|
-
return config;
|
|
1421
|
-
} catch (error) {
|
|
1422
|
-
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1423
|
-
return null;
|
|
1424
|
-
}
|
|
1425
|
-
throw error;
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
// ==================== Gateway Management ====================
|
|
1429
|
-
/**
|
|
1430
|
-
* Get gateway info for a location
|
|
1431
|
-
*/
|
|
1432
|
-
async getGateway(location) {
|
|
1433
|
-
try {
|
|
1434
|
-
const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`, void 0, { operation: `Get gateway for '${location}'` });
|
|
1435
|
-
return gateway;
|
|
1436
|
-
} catch (error) {
|
|
1437
|
-
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1438
|
-
return null;
|
|
1439
|
-
}
|
|
1440
|
-
throw error;
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
// ==================== Route Management ====================
|
|
1444
|
-
/**
|
|
1445
|
-
* Register a route for an app (maps hostname to VM IPv6)
|
|
1446
|
-
*/
|
|
1447
|
-
async registerRoute(appName, backendIpv6, backendPort = 443) {
|
|
1448
|
-
const { route } = await this.request("POST", "/api/routes", {
|
|
1449
|
-
appName,
|
|
1450
|
-
backendIpv6,
|
|
1451
|
-
backendPort
|
|
1452
|
-
}, { operation: `Register route for '${appName}'` });
|
|
1453
|
-
return route;
|
|
1454
|
-
}
|
|
1455
|
-
/**
|
|
1456
|
-
* Delete routes for an app
|
|
1457
|
-
*/
|
|
1458
|
-
async deleteRoute(appName) {
|
|
1459
|
-
await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
|
|
1460
|
-
}
|
|
1461
|
-
// ==================== Gateway Route Sync ====================
|
|
1462
|
-
/**
|
|
1463
|
-
* Sync gateway Caddy configuration for a location
|
|
1464
|
-
* This updates the gateway's reverse proxy config with current routes
|
|
1465
|
-
*/
|
|
1466
|
-
async syncGatewayRoutes(location) {
|
|
1467
|
-
const result = await this.request("POST", "/api/gateway/sync", { location }, { operation: `Sync gateway routes for '${location}'` });
|
|
1468
|
-
return { routeCount: result.routeCount };
|
|
1469
|
-
}
|
|
1470
|
-
// ==================== SSH Certificate Management ====================
|
|
1471
|
-
/**
|
|
1472
|
-
* Get platform SSH keys (CA public key + platform public key for VM creation)
|
|
1473
|
-
*/
|
|
1474
|
-
async getPlatformSSHKeys() {
|
|
1475
|
-
return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
|
|
1476
|
-
}
|
|
1477
|
-
/**
|
|
1478
|
-
* Initialize platform SSH keys (creates them if they don't exist)
|
|
1479
|
-
*/
|
|
1480
|
-
async initPlatformSSHKeys() {
|
|
1481
|
-
return this.request("POST", "/api/platform/ssh-keys");
|
|
1482
|
-
}
|
|
1483
|
-
/**
|
|
1484
|
-
* Request a signed SSH certificate for accessing an app's VMs
|
|
1485
|
-
* Returns a short-lived certificate (5 min) signed by the platform CA
|
|
1486
|
-
*/
|
|
1487
|
-
async requestSSHCertificate(appName, publicKey) {
|
|
1488
|
-
return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
|
|
1489
|
-
}
|
|
1490
|
-
// ==================== VM Setup ====================
|
|
1491
|
-
/**
|
|
1492
|
-
* Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
|
|
1493
|
-
* This runs all configuration using the platform SSH key
|
|
1494
|
-
*/
|
|
1495
|
-
async setupVM(vmIp, location, appName) {
|
|
1496
|
-
await this.request("POST", "/api/vms/setup", {
|
|
1497
|
-
vmIp,
|
|
1498
|
-
location,
|
|
1499
|
-
appName
|
|
1500
|
-
}, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3e3 });
|
|
1501
|
-
}
|
|
1502
|
-
// ==================== Environment Variables ====================
|
|
1503
|
-
/**
|
|
1504
|
-
* Set a single environment variable
|
|
1505
|
-
*/
|
|
1506
|
-
async setEnvVar(appName, key, value) {
|
|
1507
|
-
await this.request("POST", `/api/apps/${appName}/env`, { key, value });
|
|
1508
|
-
}
|
|
1509
|
-
/**
|
|
1510
|
-
* Set multiple environment variables at once
|
|
1511
|
-
*/
|
|
1512
|
-
async setEnvVars(appName, vars) {
|
|
1513
|
-
await this.request("POST", `/api/apps/${appName}/env`, { vars });
|
|
1514
|
-
}
|
|
1515
|
-
/**
|
|
1516
|
-
* List environment variables (values are masked)
|
|
1517
|
-
*/
|
|
1518
|
-
async listEnvVars(appName) {
|
|
1519
|
-
const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
|
|
1520
|
-
return envVars;
|
|
1521
|
-
}
|
|
1522
|
-
/**
|
|
1523
|
-
* Remove an environment variable
|
|
1524
|
-
*/
|
|
1525
|
-
async unsetEnvVar(appName, key) {
|
|
1526
|
-
await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
|
|
1527
|
-
}
|
|
1528
|
-
// ==================== GitHub Connection ====================
|
|
1529
|
-
/**
|
|
1530
|
-
* Create app metadata without creating VM (for connect-before-deploy flow)
|
|
1531
|
-
*/
|
|
1532
|
-
async createAppMetadata(appName, location) {
|
|
1533
|
-
const { app } = await this.request("POST", "/api/apps", {
|
|
1534
|
-
appName,
|
|
1535
|
-
location: location || "eu-central-h1",
|
|
1536
|
-
metadataOnly: true
|
|
1537
|
-
// Signal that we don't want to create VM yet
|
|
1538
|
-
});
|
|
1539
|
-
return app;
|
|
1540
|
-
}
|
|
1541
|
-
/**
|
|
1542
|
-
* Initiate GitHub App installation flow
|
|
1543
|
-
*/
|
|
1544
|
-
async initiateGitHubConnect() {
|
|
1545
|
-
return this.request("POST", "/api/github/connect");
|
|
1546
|
-
}
|
|
1547
|
-
/**
|
|
1548
|
-
* Poll for GitHub App installation completion
|
|
1549
|
-
*/
|
|
1550
|
-
async pollGitHubConnect(stateToken) {
|
|
1551
|
-
return this.request("GET", `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
|
|
1552
|
-
}
|
|
1553
|
-
/**
|
|
1554
|
-
* Get current user's GitHub App installation
|
|
1555
|
-
*/
|
|
1556
|
-
async getGitHubInstallation() {
|
|
1557
|
-
try {
|
|
1558
|
-
const { installation } = await this.request("GET", "/api/github/installation");
|
|
1559
|
-
return installation;
|
|
1560
|
-
} catch (error) {
|
|
1561
|
-
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1562
|
-
return null;
|
|
1563
|
-
}
|
|
1564
|
-
throw error;
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
/**
|
|
1568
|
-
* Delete current user's GitHub App installation record
|
|
1569
|
-
* Used when the installation becomes stale/invalid
|
|
1570
|
-
*/
|
|
1571
|
-
async deleteGitHubInstallation() {
|
|
1572
|
-
await this.request("DELETE", "/api/github/installation");
|
|
1573
|
-
}
|
|
1574
|
-
/**
|
|
1575
|
-
* List repositories accessible via GitHub App installation
|
|
1576
|
-
*/
|
|
1577
|
-
async listAccessibleRepos() {
|
|
1578
|
-
const { repos } = await this.request("GET", "/api/github/repos");
|
|
1579
|
-
return repos;
|
|
1580
|
-
}
|
|
1581
|
-
/**
|
|
1582
|
-
* Connect a repository to an app
|
|
1583
|
-
*/
|
|
1584
|
-
async connectRepo(appName, repoFullName, branch) {
|
|
1585
|
-
await this.request("POST", `/api/apps/${appName}/repo`, {
|
|
1586
|
-
repoFullName,
|
|
1587
|
-
branch: branch || "main"
|
|
1588
|
-
});
|
|
1589
|
-
}
|
|
1590
|
-
/**
|
|
1591
|
-
* Get connected repository for an app
|
|
1592
|
-
*/
|
|
1593
|
-
async getConnectedRepo(appName) {
|
|
1594
|
-
try {
|
|
1595
|
-
const { repo } = await this.request("GET", `/api/apps/${appName}/repo`);
|
|
1596
|
-
return repo;
|
|
1597
|
-
} catch (error) {
|
|
1598
|
-
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1599
|
-
return null;
|
|
1600
|
-
}
|
|
1601
|
-
throw error;
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
/**
|
|
1605
|
-
* Disconnect repository from an app
|
|
1606
|
-
*/
|
|
1607
|
-
async disconnectRepo(appName) {
|
|
1608
|
-
await this.request("DELETE", `/api/apps/${appName}/repo`);
|
|
1609
|
-
}
|
|
1610
|
-
// ==================== Builds ====================
|
|
1611
|
-
/**
|
|
1612
|
-
* List builds for an app
|
|
1613
|
-
*/
|
|
1614
|
-
async listBuilds(appName, limit = 10) {
|
|
1615
|
-
const { builds } = await this.request("GET", `/api/apps/${appName}/builds?limit=${limit}`);
|
|
1616
|
-
return builds;
|
|
1617
|
-
}
|
|
1618
|
-
/**
|
|
1619
|
-
* Get build details
|
|
1620
|
-
*/
|
|
1621
|
-
async getBuild(appName, buildId) {
|
|
1622
|
-
const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
|
|
1623
|
-
return build;
|
|
1624
|
-
}
|
|
1625
|
-
/**
|
|
1626
|
-
* Get build events (for live streaming)
|
|
1627
|
-
*/
|
|
1628
|
-
async getBuildEvents(appName, buildId, after) {
|
|
1629
|
-
let url = `/api/apps/${appName}/builds/${buildId}/events`;
|
|
1630
|
-
if (after) {
|
|
1631
|
-
url += `?after=${encodeURIComponent(after.toISOString())}`;
|
|
1632
|
-
}
|
|
1633
|
-
const { events } = await this.request("GET", url);
|
|
1634
|
-
return events;
|
|
1635
|
-
}
|
|
1636
|
-
// ==================== VPN Management ====================
|
|
1637
|
-
/**
|
|
1638
|
-
* Register VPN peer with the platform
|
|
1639
|
-
* This is idempotent - if already registered, returns existing config
|
|
1640
|
-
*/
|
|
1641
|
-
async registerVPNPeer(publicKey, location) {
|
|
1642
|
-
const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
|
|
1643
|
-
return vpnConfig;
|
|
1644
|
-
}
|
|
1645
|
-
/**
|
|
1646
|
-
* Get existing VPN config if registered
|
|
1985
|
+
* Download uncloud config from platform
|
|
1647
1986
|
*/
|
|
1648
|
-
async
|
|
1987
|
+
async downloadUncloudConfig() {
|
|
1649
1988
|
try {
|
|
1650
|
-
const {
|
|
1651
|
-
return
|
|
1989
|
+
const { config } = await this.request("GET", "/api/uncloud/config");
|
|
1990
|
+
return config;
|
|
1652
1991
|
} catch (error) {
|
|
1653
1992
|
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1654
1993
|
return null;
|
|
@@ -1656,631 +1995,289 @@ var PlatformClient = class {
|
|
|
1656
1995
|
throw error;
|
|
1657
1996
|
}
|
|
1658
1997
|
}
|
|
1998
|
+
// ==================== Gateway Management ====================
|
|
1659
1999
|
/**
|
|
1660
|
-
*
|
|
2000
|
+
* Get gateway info for a location
|
|
1661
2001
|
*/
|
|
1662
|
-
async
|
|
1663
|
-
await this.request("DELETE", "/api/vpn/unregister");
|
|
1664
|
-
}
|
|
1665
|
-
};
|
|
1666
|
-
|
|
1667
|
-
// src/lib/app-config.ts
|
|
1668
|
-
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1669
|
-
import { join as join3 } from "path";
|
|
1670
|
-
import { parse, stringify } from "yaml";
|
|
1671
|
-
var CONFIG_FILENAME = "hackerrun.yaml";
|
|
1672
|
-
function getConfigPath(directory = process.cwd()) {
|
|
1673
|
-
return join3(directory, CONFIG_FILENAME);
|
|
1674
|
-
}
|
|
1675
|
-
function hasAppConfig(directory = process.cwd()) {
|
|
1676
|
-
return existsSync3(getConfigPath(directory));
|
|
1677
|
-
}
|
|
1678
|
-
function readAppConfig(directory = process.cwd()) {
|
|
1679
|
-
const configPath = getConfigPath(directory);
|
|
1680
|
-
if (!existsSync3(configPath)) {
|
|
1681
|
-
return null;
|
|
1682
|
-
}
|
|
1683
|
-
try {
|
|
1684
|
-
const content = readFileSync3(configPath, "utf-8");
|
|
1685
|
-
const config = parse(content);
|
|
1686
|
-
if (!config.appName) {
|
|
1687
|
-
return null;
|
|
1688
|
-
}
|
|
1689
|
-
return config;
|
|
1690
|
-
} catch (error) {
|
|
1691
|
-
console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
|
|
1692
|
-
return null;
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
function writeAppConfig(config, directory = process.cwd()) {
|
|
1696
|
-
const configPath = getConfigPath(directory);
|
|
1697
|
-
const content = stringify(config);
|
|
1698
|
-
writeFileSync3(configPath, content, "utf-8");
|
|
1699
|
-
}
|
|
1700
|
-
function getAppName(directory = process.cwd()) {
|
|
1701
|
-
const config = readAppConfig(directory);
|
|
1702
|
-
if (config?.appName) {
|
|
1703
|
-
return config.appName;
|
|
1704
|
-
}
|
|
1705
|
-
const folderName = directory.split("/").pop() || "app";
|
|
1706
|
-
return folderName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
1707
|
-
}
|
|
1708
|
-
function linkApp(appName, directory = process.cwd()) {
|
|
1709
|
-
writeAppConfig({ appName }, directory);
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
// src/lib/uncloud-runner.ts
|
|
1713
|
-
import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
|
|
1714
|
-
|
|
1715
|
-
// src/lib/vpn.ts
|
|
1716
|
-
import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
|
|
1717
|
-
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync2, unlinkSync } from "fs";
|
|
1718
|
-
import { homedir as homedir2, platform as platform3 } from "os";
|
|
1719
|
-
import { join as join4 } from "path";
|
|
1720
|
-
import chalk6 from "chalk";
|
|
1721
|
-
function detectOS() {
|
|
1722
|
-
const os = platform3();
|
|
1723
|
-
if (os === "darwin") {
|
|
1724
|
-
return { type: "macos" };
|
|
1725
|
-
}
|
|
1726
|
-
if (os === "win32") {
|
|
1727
|
-
return { type: "windows" };
|
|
1728
|
-
}
|
|
1729
|
-
if (os === "linux") {
|
|
2002
|
+
async getGateway(location) {
|
|
1730
2003
|
try {
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
return { type: "linux", distro };
|
|
1737
|
-
}
|
|
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;
|
|
1738
2009
|
}
|
|
1739
|
-
|
|
2010
|
+
throw error;
|
|
1740
2011
|
}
|
|
1741
|
-
return { type: "linux" };
|
|
1742
|
-
}
|
|
1743
|
-
return { type: "unknown" };
|
|
1744
|
-
}
|
|
1745
|
-
function getWireGuardInstallInstructions() {
|
|
1746
|
-
const os = detectOS();
|
|
1747
|
-
switch (os.type) {
|
|
1748
|
-
case "macos":
|
|
1749
|
-
return ` ${chalk6.cyan("macOS:")} brew install wireguard-tools`;
|
|
1750
|
-
case "windows":
|
|
1751
|
-
return ` ${chalk6.cyan("Windows:")} Download from https://www.wireguard.com/install/
|
|
1752
|
-
Or using winget: winget install WireGuard.WireGuard`;
|
|
1753
|
-
case "linux":
|
|
1754
|
-
switch (os.distro) {
|
|
1755
|
-
case "arch":
|
|
1756
|
-
case "manjaro":
|
|
1757
|
-
case "endeavouros":
|
|
1758
|
-
case "artix":
|
|
1759
|
-
return ` ${chalk6.cyan("Arch Linux:")} sudo pacman -S wireguard-tools`;
|
|
1760
|
-
case "ubuntu":
|
|
1761
|
-
case "debian":
|
|
1762
|
-
case "linuxmint":
|
|
1763
|
-
case "pop":
|
|
1764
|
-
case "elementary":
|
|
1765
|
-
case "zorin":
|
|
1766
|
-
return ` ${chalk6.cyan("Ubuntu/Debian:")} sudo apt install wireguard`;
|
|
1767
|
-
case "fedora":
|
|
1768
|
-
return ` ${chalk6.cyan("Fedora:")} sudo dnf install wireguard-tools`;
|
|
1769
|
-
case "rhel":
|
|
1770
|
-
case "centos":
|
|
1771
|
-
case "rocky":
|
|
1772
|
-
case "almalinux":
|
|
1773
|
-
return ` ${chalk6.cyan("RHEL/CentOS:")} sudo dnf install wireguard-tools
|
|
1774
|
-
(may need EPEL: sudo dnf install epel-release)`;
|
|
1775
|
-
case "opensuse":
|
|
1776
|
-
case "opensuse-leap":
|
|
1777
|
-
case "opensuse-tumbleweed":
|
|
1778
|
-
return ` ${chalk6.cyan("openSUSE:")} sudo zypper install wireguard-tools`;
|
|
1779
|
-
case "gentoo":
|
|
1780
|
-
return ` ${chalk6.cyan("Gentoo:")} sudo emerge net-vpn/wireguard-tools`;
|
|
1781
|
-
case "void":
|
|
1782
|
-
return ` ${chalk6.cyan("Void Linux:")} sudo xbps-install wireguard-tools`;
|
|
1783
|
-
case "alpine":
|
|
1784
|
-
return ` ${chalk6.cyan("Alpine:")} sudo apk add wireguard-tools`;
|
|
1785
|
-
case "nixos":
|
|
1786
|
-
return ` ${chalk6.cyan("NixOS:")} Add wireguard-tools to environment.systemPackages`;
|
|
1787
|
-
default:
|
|
1788
|
-
return ` ${chalk6.cyan("Linux:")} Install wireguard-tools using your package manager:
|
|
1789
|
-
Arch: sudo pacman -S wireguard-tools
|
|
1790
|
-
Ubuntu/Debian: sudo apt install wireguard
|
|
1791
|
-
Fedora: sudo dnf install wireguard-tools
|
|
1792
|
-
openSUSE: sudo zypper install wireguard-tools`;
|
|
1793
|
-
}
|
|
1794
|
-
default:
|
|
1795
|
-
return ` Visit https://www.wireguard.com/install/ for installation instructions`;
|
|
1796
|
-
}
|
|
1797
|
-
}
|
|
1798
|
-
var CONFIG_DIR = join4(homedir2(), ".config", "hackerrun");
|
|
1799
|
-
var PRIVATE_KEY_FILE = join4(CONFIG_DIR, "wg-private-key");
|
|
1800
|
-
var WG_INTERFACE = "hackerrun";
|
|
1801
|
-
var WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
|
|
1802
|
-
function isWireGuardInstalled() {
|
|
1803
|
-
try {
|
|
1804
|
-
execSync4("which wg", { stdio: "ignore" });
|
|
1805
|
-
execSync4("which wg-quick", { stdio: "ignore" });
|
|
1806
|
-
return true;
|
|
1807
|
-
} catch {
|
|
1808
|
-
return false;
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
function ensureConfigDir() {
|
|
1812
|
-
if (!existsSync4(CONFIG_DIR)) {
|
|
1813
|
-
mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
1816
|
-
function generateKeyPair() {
|
|
1817
|
-
const privateKey = execSync4("wg genkey", { encoding: "utf-8" }).trim();
|
|
1818
|
-
const publicKey = execSync4("wg pubkey", {
|
|
1819
|
-
input: privateKey,
|
|
1820
|
-
encoding: "utf-8"
|
|
1821
|
-
}).trim();
|
|
1822
|
-
return { privateKey, publicKey };
|
|
1823
|
-
}
|
|
1824
|
-
function getOrCreateKeyPair() {
|
|
1825
|
-
ensureConfigDir();
|
|
1826
|
-
if (existsSync4(PRIVATE_KEY_FILE)) {
|
|
1827
|
-
const privateKey2 = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
|
|
1828
|
-
const publicKey2 = execSync4("wg pubkey", {
|
|
1829
|
-
input: privateKey2,
|
|
1830
|
-
encoding: "utf-8"
|
|
1831
|
-
}).trim();
|
|
1832
|
-
return { privateKey: privateKey2, publicKey: publicKey2, isNew: false };
|
|
1833
|
-
}
|
|
1834
|
-
const { privateKey, publicKey } = generateKeyPair();
|
|
1835
|
-
writeFileSync4(PRIVATE_KEY_FILE, privateKey + "\n", { mode: 384 });
|
|
1836
|
-
return { privateKey, publicKey, isNew: true };
|
|
1837
|
-
}
|
|
1838
|
-
function getPublicKey() {
|
|
1839
|
-
if (!existsSync4(PRIVATE_KEY_FILE)) {
|
|
1840
|
-
return null;
|
|
1841
|
-
}
|
|
1842
|
-
const privateKey = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
|
|
1843
|
-
return execSync4("wg pubkey", {
|
|
1844
|
-
input: privateKey,
|
|
1845
|
-
encoding: "utf-8"
|
|
1846
|
-
}).trim();
|
|
1847
|
-
}
|
|
1848
|
-
function isVPNUp() {
|
|
1849
|
-
try {
|
|
1850
|
-
const result = execSync4(`ip link show ${WG_INTERFACE}`, {
|
|
1851
|
-
encoding: "utf-8",
|
|
1852
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1853
|
-
});
|
|
1854
|
-
return result.includes(WG_INTERFACE);
|
|
1855
|
-
} catch {
|
|
1856
|
-
return false;
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
function isRoutedViaVPN(ipv6Address) {
|
|
1860
|
-
if (!isVPNUp()) {
|
|
1861
|
-
return false;
|
|
1862
|
-
}
|
|
1863
|
-
try {
|
|
1864
|
-
const result = execSync4(`ip -6 route get ${ipv6Address}`, {
|
|
1865
|
-
encoding: "utf-8",
|
|
1866
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1867
|
-
});
|
|
1868
|
-
return result.includes(`dev ${WG_INTERFACE}`);
|
|
1869
|
-
} catch {
|
|
1870
|
-
return false;
|
|
1871
|
-
}
|
|
1872
|
-
}
|
|
1873
|
-
function getVPNStatus() {
|
|
1874
|
-
if (!isVPNUp()) {
|
|
1875
|
-
return { connected: false };
|
|
1876
2012
|
}
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
const
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
if (line.includes("endpoint:")) {
|
|
1889
|
-
endpoint = line.split("endpoint:")[1]?.trim();
|
|
1890
|
-
}
|
|
1891
|
-
if (line.includes("latest handshake:")) {
|
|
1892
|
-
latestHandshake = line.split("latest handshake:")[1]?.trim();
|
|
1893
|
-
}
|
|
1894
|
-
if (line.includes("transfer:")) {
|
|
1895
|
-
const transfer = line.split("transfer:")[1]?.trim();
|
|
1896
|
-
const parts = transfer?.split(",");
|
|
1897
|
-
transferRx = parts?.[0]?.trim();
|
|
1898
|
-
transferTx = parts?.[1]?.trim();
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
return {
|
|
1902
|
-
connected: true,
|
|
1903
|
-
interface: WG_INTERFACE,
|
|
1904
|
-
endpoint,
|
|
1905
|
-
latestHandshake,
|
|
1906
|
-
transferRx,
|
|
1907
|
-
transferTx
|
|
1908
|
-
};
|
|
1909
|
-
} catch {
|
|
1910
|
-
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;
|
|
1911
2024
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
[Interface]
|
|
1918
|
-
PrivateKey = ${config.privateKey}
|
|
1919
|
-
Address = ${config.address}
|
|
1920
|
-
|
|
1921
|
-
[Peer]
|
|
1922
|
-
PublicKey = ${config.gatewayPublicKey}
|
|
1923
|
-
Endpoint = ${config.gatewayEndpoint}
|
|
1924
|
-
AllowedIPs = ${config.allowedIPs}
|
|
1925
|
-
PersistentKeepalive = 25
|
|
1926
|
-
`;
|
|
1927
|
-
}
|
|
1928
|
-
function writeWireGuardConfig(config) {
|
|
1929
|
-
const configContent = generateWireGuardConfig(config);
|
|
1930
|
-
const tempFile = join4(CONFIG_DIR, "wg-temp.conf");
|
|
1931
|
-
writeFileSync4(tempFile, configContent, { mode: 384 });
|
|
1932
|
-
try {
|
|
1933
|
-
execSync4("sudo mkdir -p /etc/wireguard", { stdio: "inherit" });
|
|
1934
|
-
execSync4(`sudo mv ${tempFile} ${WG_CONFIG_PATH}`, { stdio: "inherit" });
|
|
1935
|
-
execSync4(`sudo chmod 600 ${WG_CONFIG_PATH}`, { stdio: "inherit" });
|
|
1936
|
-
} catch (error) {
|
|
1937
|
-
if (existsSync4(tempFile)) {
|
|
1938
|
-
unlinkSync(tempFile);
|
|
1939
|
-
}
|
|
1940
|
-
throw error;
|
|
2025
|
+
/**
|
|
2026
|
+
* Delete routes for an app
|
|
2027
|
+
*/
|
|
2028
|
+
async deleteRoute(appName) {
|
|
2029
|
+
await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
|
|
1941
2030
|
}
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
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 };
|
|
1946
2039
|
}
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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" });
|
|
1952
2046
|
}
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
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");
|
|
1957
2052
|
}
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
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}'` });
|
|
1963
2059
|
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
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 });
|
|
1974
2071
|
}
|
|
1975
|
-
|
|
1976
|
-
var VPNManager = class {
|
|
1977
|
-
wasConnectedByUs = false;
|
|
2072
|
+
// ==================== Environment Variables ====================
|
|
1978
2073
|
/**
|
|
1979
|
-
*
|
|
1980
|
-
* Returns true if VPN was established (and should be torn down later)
|
|
2074
|
+
* Set a single environment variable
|
|
1981
2075
|
*/
|
|
1982
|
-
async
|
|
1983
|
-
|
|
1984
|
-
console.log(chalk6.green("\u2713 Direct IPv6 connectivity available"));
|
|
1985
|
-
return false;
|
|
1986
|
-
}
|
|
1987
|
-
console.log(chalk6.yellow("IPv6 not available, checking VPN..."));
|
|
1988
|
-
if (isVPNUp()) {
|
|
1989
|
-
if (testIPv6Connectivity2(targetIPv6)) {
|
|
1990
|
-
console.log(chalk6.green("\u2713 VPN already connected"));
|
|
1991
|
-
return false;
|
|
1992
|
-
}
|
|
1993
|
-
console.log(chalk6.yellow("VPN is up but cannot reach target, reconnecting..."));
|
|
1994
|
-
vpnDown();
|
|
1995
|
-
}
|
|
1996
|
-
if (!isWireGuardInstalled()) {
|
|
1997
|
-
const instructions = getWireGuardInstallInstructions();
|
|
1998
|
-
throw new Error(
|
|
1999
|
-
"WireGuard is not installed.\n\nWireGuard is needed for IPv6 connectivity to your app VMs.\nPlease install it and try again:\n\n" + instructions
|
|
2000
|
-
);
|
|
2001
|
-
}
|
|
2002
|
-
console.log(chalk6.cyan("Establishing VPN tunnel (requires sudo)..."));
|
|
2003
|
-
const { publicKey, isNew } = getOrCreateKeyPair();
|
|
2004
|
-
if (isNew) {
|
|
2005
|
-
console.log(chalk6.dim("Generated new WireGuard keypair"));
|
|
2006
|
-
}
|
|
2007
|
-
const vpnConfig = await getVPNConfig();
|
|
2008
|
-
writeWireGuardConfig(vpnConfig);
|
|
2009
|
-
vpnUp();
|
|
2010
|
-
if (!testIPv6Connectivity2(targetIPv6)) {
|
|
2011
|
-
vpnDown();
|
|
2012
|
-
throw new Error("VPN established but cannot reach target. Please try again.");
|
|
2013
|
-
}
|
|
2014
|
-
console.log(chalk6.green("\u2713 VPN connected"));
|
|
2015
|
-
this.wasConnectedByUs = true;
|
|
2016
|
-
return true;
|
|
2076
|
+
async setEnvVar(appName, key, value) {
|
|
2077
|
+
await this.request("POST", `/api/apps/${appName}/env`, { key, value });
|
|
2017
2078
|
}
|
|
2018
2079
|
/**
|
|
2019
|
-
*
|
|
2080
|
+
* Set multiple environment variables at once
|
|
2020
2081
|
*/
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
console.log(chalk6.dim("Disconnecting VPN..."));
|
|
2024
|
-
try {
|
|
2025
|
-
vpnDown();
|
|
2026
|
-
console.log(chalk6.green("\u2713 VPN disconnected"));
|
|
2027
|
-
} catch (error) {
|
|
2028
|
-
console.log(chalk6.yellow(`Warning: Failed to disconnect VPN: ${error.message}`));
|
|
2029
|
-
}
|
|
2030
|
-
this.wasConnectedByUs = false;
|
|
2031
|
-
}
|
|
2082
|
+
async setEnvVars(appName, vars) {
|
|
2083
|
+
await this.request("POST", `/api/apps/${appName}/env`, { vars });
|
|
2032
2084
|
}
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
this.platformClient = platformClient;
|
|
2040
|
-
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;
|
|
2041
2091
|
}
|
|
2042
|
-
certManager;
|
|
2043
|
-
gatewayCache = /* @__PURE__ */ new Map();
|
|
2044
|
-
tunnelManager = new TunnelManager();
|
|
2045
|
-
vpnManager = new VPNManager();
|
|
2046
|
-
tempConfigPath = null;
|
|
2047
|
-
vpnEstablishedByUs = false;
|
|
2048
2092
|
/**
|
|
2049
|
-
*
|
|
2050
|
-
* Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
|
|
2093
|
+
* Remove an environment variable
|
|
2051
2094
|
*/
|
|
2052
|
-
async
|
|
2053
|
-
|
|
2054
|
-
if (!app) {
|
|
2055
|
-
throw new Error(`App '${appName}' not found`);
|
|
2056
|
-
}
|
|
2057
|
-
const primaryNode = app.nodes.find((n) => n.isPrimary);
|
|
2058
|
-
if (!primaryNode?.ipv6) {
|
|
2059
|
-
throw new Error(`App '${appName}' has no primary node with IPv6 address`);
|
|
2060
|
-
}
|
|
2061
|
-
const vmIp = primaryNode.ipv6;
|
|
2062
|
-
await this.certManager.getSession(appName, vmIp);
|
|
2063
|
-
const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
|
|
2064
|
-
if (canConnectDirect) {
|
|
2065
|
-
const viaVPN = isRoutedViaVPN(vmIp);
|
|
2066
|
-
return {
|
|
2067
|
-
url: `ssh://root@${vmIp}`,
|
|
2068
|
-
vmIp,
|
|
2069
|
-
viaGateway: false,
|
|
2070
|
-
viaVPN
|
|
2071
|
-
};
|
|
2072
|
-
}
|
|
2073
|
-
const vpnAvailable = isWireGuardInstalled();
|
|
2074
|
-
if (vpnAvailable) {
|
|
2075
|
-
console.log(chalk7.yellow("Direct IPv6 not available, trying VPN..."));
|
|
2076
|
-
try {
|
|
2077
|
-
if (isVPNUp() && testIPv6Connectivity2(vmIp, 3)) {
|
|
2078
|
-
console.log(chalk7.green("\u2713 VPN already connected"));
|
|
2079
|
-
return {
|
|
2080
|
-
url: `ssh://root@${vmIp}`,
|
|
2081
|
-
vmIp,
|
|
2082
|
-
viaGateway: false,
|
|
2083
|
-
viaVPN: true
|
|
2084
|
-
};
|
|
2085
|
-
}
|
|
2086
|
-
this.vpnEstablishedByUs = await this.vpnManager.ensureConnected(
|
|
2087
|
-
vmIp,
|
|
2088
|
-
async () => this.getVPNConfig(app.location)
|
|
2089
|
-
);
|
|
2090
|
-
if (testIPv6Connectivity2(vmIp, 5)) {
|
|
2091
|
-
console.log(chalk7.green("\u2713 Connected via VPN"));
|
|
2092
|
-
return {
|
|
2093
|
-
url: `ssh://root@${vmIp}`,
|
|
2094
|
-
vmIp,
|
|
2095
|
-
viaGateway: false,
|
|
2096
|
-
viaVPN: true
|
|
2097
|
-
};
|
|
2098
|
-
}
|
|
2099
|
-
} catch (error) {
|
|
2100
|
-
console.log(chalk7.yellow(`VPN setup failed: ${error.message}`));
|
|
2101
|
-
console.log(chalk7.dim("Falling back to SSH tunnel..."));
|
|
2102
|
-
}
|
|
2103
|
-
} else {
|
|
2104
|
-
console.log(chalk7.yellow("Direct IPv6 not available on your network."));
|
|
2105
|
-
console.log(chalk7.dim("For faster deploys, install WireGuard:"));
|
|
2106
|
-
console.log(chalk7.dim(getWireGuardInstallInstructions()));
|
|
2107
|
-
console.log();
|
|
2108
|
-
}
|
|
2109
|
-
console.log(chalk7.yellow("Using SSH tunnel (may be slower for large transfers)..."));
|
|
2110
|
-
const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
|
|
2111
|
-
return {
|
|
2112
|
-
url: `ssh://root@localhost:${tunnelInfo.localPort}`,
|
|
2113
|
-
vmIp,
|
|
2114
|
-
viaGateway: true,
|
|
2115
|
-
viaVPN: false,
|
|
2116
|
-
localPort: tunnelInfo.localPort
|
|
2117
|
-
};
|
|
2095
|
+
async unsetEnvVar(appName, key) {
|
|
2096
|
+
await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
|
|
2118
2097
|
}
|
|
2098
|
+
// ==================== GitHub Connection ====================
|
|
2119
2099
|
/**
|
|
2120
|
-
*
|
|
2100
|
+
* Create app metadata without creating VM (for connect-before-deploy flow)
|
|
2121
2101
|
*/
|
|
2122
|
-
async
|
|
2123
|
-
const {
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
|
|
2131
|
-
allowedIPs: vpnConfigResponse.allowedIPs
|
|
2132
|
-
};
|
|
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;
|
|
2133
2110
|
}
|
|
2134
2111
|
/**
|
|
2135
|
-
*
|
|
2136
|
-
* This prevents "Host key verification failed" errors from uncloud
|
|
2112
|
+
* Initiate GitHub App installation flow
|
|
2137
2113
|
*/
|
|
2138
|
-
|
|
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() {
|
|
2139
2127
|
try {
|
|
2140
|
-
const
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
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;
|
|
2146
2135
|
}
|
|
2147
2136
|
}
|
|
2148
2137
|
/**
|
|
2149
|
-
*
|
|
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
|
|
2150
2146
|
*/
|
|
2151
|
-
async
|
|
2152
|
-
const
|
|
2153
|
-
|
|
2154
|
-
throw new Error(`No gateway found for location ${location}`);
|
|
2155
|
-
}
|
|
2156
|
-
return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
|
|
2147
|
+
async listAccessibleRepos() {
|
|
2148
|
+
const { repos } = await this.request("GET", "/api/github/repos");
|
|
2149
|
+
return repos;
|
|
2157
2150
|
}
|
|
2158
2151
|
/**
|
|
2159
|
-
*
|
|
2152
|
+
* Connect a repository to an app
|
|
2160
2153
|
*/
|
|
2161
|
-
async
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
}
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
});
|
|
2173
|
-
|
|
2174
|
-
|
|
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;
|
|
2175
2170
|
}
|
|
2176
|
-
|
|
2177
|
-
const result = execSync5(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
|
|
2178
|
-
cwd: options.cwd,
|
|
2179
|
-
encoding: "utf-8",
|
|
2180
|
-
timeout: options.timeout
|
|
2181
|
-
});
|
|
2182
|
-
return result;
|
|
2171
|
+
throw error;
|
|
2183
2172
|
}
|
|
2184
2173
|
}
|
|
2185
2174
|
/**
|
|
2186
|
-
*
|
|
2175
|
+
* Disconnect repository from an app
|
|
2187
2176
|
*/
|
|
2188
|
-
async
|
|
2189
|
-
await this.
|
|
2177
|
+
async disconnectRepo(appName) {
|
|
2178
|
+
await this.request("DELETE", `/api/apps/${appName}/repo`);
|
|
2190
2179
|
}
|
|
2180
|
+
// ==================== Builds ====================
|
|
2191
2181
|
/**
|
|
2192
|
-
*
|
|
2182
|
+
* List builds for an app
|
|
2193
2183
|
*/
|
|
2194
|
-
async
|
|
2195
|
-
const
|
|
2196
|
-
|
|
2197
|
-
if (options.tail) args.push("--tail", String(options.tail));
|
|
2198
|
-
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;
|
|
2199
2187
|
}
|
|
2200
2188
|
/**
|
|
2201
|
-
*
|
|
2189
|
+
* Get build details
|
|
2202
2190
|
*/
|
|
2203
|
-
async
|
|
2204
|
-
const
|
|
2205
|
-
return
|
|
2191
|
+
async getBuild(appName, buildId) {
|
|
2192
|
+
const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
|
|
2193
|
+
return build;
|
|
2206
2194
|
}
|
|
2207
2195
|
/**
|
|
2208
|
-
*
|
|
2209
|
-
* This is different - it runs on the VM directly, not via uncloud connector
|
|
2196
|
+
* Get build events (for live streaming)
|
|
2210
2197
|
*/
|
|
2211
|
-
async
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
|
|
2216
|
-
} else {
|
|
2217
|
-
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())}`;
|
|
2218
2202
|
}
|
|
2219
|
-
const
|
|
2220
|
-
return
|
|
2203
|
+
const { events } = await this.request("GET", url);
|
|
2204
|
+
return events;
|
|
2221
2205
|
}
|
|
2206
|
+
// ==================== VPN Management ====================
|
|
2222
2207
|
/**
|
|
2223
|
-
*
|
|
2208
|
+
* Register VPN peer with the platform
|
|
2209
|
+
* This is idempotent - if already registered, returns existing config
|
|
2224
2210
|
*/
|
|
2225
|
-
async
|
|
2226
|
-
const
|
|
2227
|
-
|
|
2228
|
-
if (connInfo.viaGateway && connInfo.localPort) {
|
|
2229
|
-
sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
|
|
2230
|
-
} else {
|
|
2231
|
-
sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
|
|
2232
|
-
}
|
|
2233
|
-
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;
|
|
2234
2214
|
}
|
|
2235
2215
|
/**
|
|
2236
|
-
*
|
|
2237
|
-
* Uses 'uc machine rm' to drain containers and remove from cluster
|
|
2216
|
+
* Get existing VPN config if registered
|
|
2238
2217
|
*/
|
|
2239
|
-
async
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
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;
|
|
2248
2227
|
}
|
|
2249
2228
|
}
|
|
2250
2229
|
/**
|
|
2251
|
-
*
|
|
2230
|
+
* Unregister VPN peer
|
|
2252
2231
|
*/
|
|
2253
|
-
async
|
|
2254
|
-
|
|
2255
|
-
return result;
|
|
2232
|
+
async unregisterVPNPeer() {
|
|
2233
|
+
await this.request("DELETE", "/api/vpn/unregister");
|
|
2256
2234
|
}
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
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)) {
|
|
2270
2251
|
return null;
|
|
2271
2252
|
}
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
this.certManager.cleanupAll();
|
|
2278
|
-
if (this.vpnEstablishedByUs) {
|
|
2279
|
-
this.vpnManager.disconnect();
|
|
2280
|
-
this.vpnEstablishedByUs = false;
|
|
2253
|
+
try {
|
|
2254
|
+
const content = readFileSync4(configPath, "utf-8");
|
|
2255
|
+
const config = parse(content);
|
|
2256
|
+
if (!config.appName) {
|
|
2257
|
+
return null;
|
|
2281
2258
|
}
|
|
2259
|
+
return config;
|
|
2260
|
+
} catch (error) {
|
|
2261
|
+
console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
|
|
2262
|
+
return null;
|
|
2282
2263
|
}
|
|
2283
|
-
}
|
|
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
|
+
}
|
|
2284
2281
|
|
|
2285
2282
|
// src/commands/deploy.ts
|
|
2286
2283
|
var DEFAULT_LOCATION = "eu-central-h1";
|
|
@@ -3730,19 +3727,33 @@ Error: ${error.message}
|
|
|
3730
3727
|
import { Command as Command10 } from "commander";
|
|
3731
3728
|
import chalk16 from "chalk";
|
|
3732
3729
|
import ora10 from "ora";
|
|
3733
|
-
import { confirm } from "@inquirer/prompts";
|
|
3734
|
-
var DEFAULT_VM_SIZE = "
|
|
3730
|
+
import { select as select2, confirm } from "@inquirer/prompts";
|
|
3731
|
+
var DEFAULT_VM_SIZE = "burstable-1";
|
|
3735
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
|
+
}
|
|
3736
3748
|
function createScaleCommand() {
|
|
3737
3749
|
const cmd = new Command10("scale");
|
|
3738
|
-
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) => {
|
|
3739
3751
|
let uncloudRunner = null;
|
|
3740
3752
|
try {
|
|
3741
|
-
const appName = options.app || getAppName();
|
|
3742
3753
|
const platformToken = getPlatformToken();
|
|
3743
3754
|
const platformClient = new PlatformClient(platformToken);
|
|
3744
|
-
const clusterManager = new ClusterManager(platformClient);
|
|
3745
3755
|
uncloudRunner = new UncloudRunner(platformClient);
|
|
3756
|
+
const clusterManager = new ClusterManager(platformClient, uncloudRunner);
|
|
3746
3757
|
const app = await platformClient.getApp(appName);
|
|
3747
3758
|
if (!app) {
|
|
3748
3759
|
console.log(chalk16.red(`
|
|
@@ -3752,146 +3763,122 @@ App '${appName}' not found
|
|
|
3752
3763
|
`);
|
|
3753
3764
|
process.exit(1);
|
|
3754
3765
|
}
|
|
3755
|
-
|
|
3756
|
-
|
|
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) {
|
|
3757
3787
|
console.log(chalk16.cyan(`
|
|
3758
|
-
|
|
3788
|
+
Services in '${appName}':
|
|
3759
3789
|
`));
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
const primaryLabel = node.isPrimary ? chalk16.yellow(" (primary)") : "";
|
|
3764
|
-
console.log(` ${index + 1}. ${chalk16.bold(node.name)}${primaryLabel}`);
|
|
3765
|
-
console.log(` IPv6: ${node.ipv6 || "pending"}`);
|
|
3766
|
-
console.log();
|
|
3790
|
+
serviceName = await select2({
|
|
3791
|
+
message: "Which service would you like to scale?",
|
|
3792
|
+
choices: services.map((s) => ({ name: s, value: s }))
|
|
3767
3793
|
});
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
console.log(chalk16.dim(` hackerrun scale -1 # Remove 1 node`));
|
|
3772
|
-
console.log();
|
|
3773
|
-
return;
|
|
3774
|
-
}
|
|
3775
|
-
let targetCount;
|
|
3776
|
-
if (countArg.startsWith("+")) {
|
|
3777
|
-
const delta = parseInt(countArg.slice(1), 10);
|
|
3778
|
-
if (isNaN(delta) || delta <= 0) {
|
|
3779
|
-
console.log(chalk16.red(`
|
|
3780
|
-
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>
|
|
3781
3797
|
`));
|
|
3782
3798
|
process.exit(1);
|
|
3783
3799
|
}
|
|
3784
|
-
|
|
3785
|
-
} else if (
|
|
3786
|
-
const
|
|
3787
|
-
if (isNaN(
|
|
3788
|
-
|
|
3789
|
-
|
|
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>
|
|
3790
3815
|
`));
|
|
3791
3816
|
process.exit(1);
|
|
3792
3817
|
}
|
|
3793
|
-
targetCount = currentNodeCount - delta;
|
|
3794
3818
|
} else {
|
|
3795
|
-
|
|
3796
|
-
|
|
3819
|
+
serviceName = serviceArg;
|
|
3820
|
+
replicas = parseInt(replicasArg, 10);
|
|
3821
|
+
if (!services.includes(serviceName)) {
|
|
3797
3822
|
console.log(chalk16.red(`
|
|
3798
|
-
|
|
3823
|
+
Service '${serviceName}' not found in app '${appName}'.
|
|
3799
3824
|
`));
|
|
3825
|
+
console.log(chalk16.cyan("Available services:"));
|
|
3826
|
+
services.forEach((s) => console.log(` - ${s}`));
|
|
3827
|
+
console.log();
|
|
3800
3828
|
process.exit(1);
|
|
3801
3829
|
}
|
|
3802
3830
|
}
|
|
3803
|
-
if (
|
|
3804
|
-
console.log(chalk16.red(
|
|
3805
|
-
Cannot scale below 1 node. Use 'hackerrun destroy' to remove the app.
|
|
3806
|
-
`));
|
|
3831
|
+
if (isNaN(replicas) || replicas < 1) {
|
|
3832
|
+
console.log(chalk16.red("\nReplicas must be a positive number.\n"));
|
|
3807
3833
|
process.exit(1);
|
|
3808
3834
|
}
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
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}
|
|
3812
3839
|
`));
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
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).
|
|
3820
3847
|
`));
|
|
3821
|
-
|
|
3822
|
-
console.log(chalk16.
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
console.log(chalk16.
|
|
3826
|
-
}
|
|
3827
|
-
const updatedApp = await platformClient.getApp(appName);
|
|
3828
|
-
const syncSpinner = ora10("Syncing gateway routes...").start();
|
|
3829
|
-
try {
|
|
3830
|
-
await platformClient.syncGatewayRoutes(app.location);
|
|
3831
|
-
syncSpinner.succeed("Gateway routes synced");
|
|
3832
|
-
} catch (error) {
|
|
3833
|
-
syncSpinner.warn(`Gateway sync failed: ${error.message}`);
|
|
3834
|
-
}
|
|
3835
|
-
console.log(chalk16.green(`
|
|
3836
|
-
\u2713 Scaled to ${updatedApp?.nodes.length} nodes
|
|
3837
|
-
`));
|
|
3838
|
-
console.log(chalk16.dim("To deploy to specific nodes, use x-machines in your compose file:"));
|
|
3839
|
-
console.log(chalk16.dim(" services:"));
|
|
3840
|
-
console.log(chalk16.dim(" web:"));
|
|
3841
|
-
console.log(chalk16.dim(" x-machines:"));
|
|
3842
|
-
updatedApp?.nodes.forEach((node) => {
|
|
3843
|
-
console.log(chalk16.dim(` - ${node.name}`));
|
|
3844
|
-
});
|
|
3845
|
-
console.log();
|
|
3846
|
-
} else {
|
|
3847
|
-
const nodesToRemove = currentNodeCount - targetCount;
|
|
3848
|
-
const sortedNodes = [...app.nodes].sort((a, b) => {
|
|
3849
|
-
const numA = parseInt(a.name.split("-").pop() || "0", 10);
|
|
3850
|
-
const numB = parseInt(b.name.split("-").pop() || "0", 10);
|
|
3851
|
-
return numB - numA;
|
|
3852
|
-
});
|
|
3853
|
-
const nodesToRemoveList = sortedNodes.slice(0, nodesToRemove);
|
|
3854
|
-
console.log(chalk16.yellow(`
|
|
3855
|
-
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)
|
|
3856
3853
|
`));
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
console.log(` - ${node.name} (${node.ipv6})`);
|
|
3860
|
-
});
|
|
3861
|
-
console.log();
|
|
3862
|
-
if (!options.force) {
|
|
3863
|
-
console.log(chalk16.yellow("Warning: All containers on these nodes will be stopped and the VMs deleted."));
|
|
3864
|
-
console.log(chalk16.yellow("Data on these nodes will be lost.\n"));
|
|
3865
|
-
const confirmed = await confirm({
|
|
3866
|
-
message: "Are you sure you want to remove these nodes?",
|
|
3854
|
+
shouldAddMachines = await confirm({
|
|
3855
|
+
message: `Add ${replicas - currentMachineCount} new machine(s)?`,
|
|
3867
3856
|
default: false
|
|
3868
3857
|
});
|
|
3869
|
-
if (!
|
|
3870
|
-
console.log(chalk16.dim(
|
|
3871
|
-
|
|
3858
|
+
if (!shouldAddMachines) {
|
|
3859
|
+
console.log(chalk16.dim(`
|
|
3860
|
+
Scaling ${replicas} replicas across ${currentMachineCount} existing machine(s).
|
|
3861
|
+
`));
|
|
3872
3862
|
}
|
|
3873
3863
|
}
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
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}...`));
|
|
3877
3873
|
try {
|
|
3878
|
-
const
|
|
3879
|
-
|
|
3880
|
-
spinner.succeed("Removed from cluster");
|
|
3881
|
-
spinner.start("Deleting VM...");
|
|
3882
|
-
await platformClient.deleteVM(app.location, node.name);
|
|
3883
|
-
spinner.succeed("VM deleted");
|
|
3884
|
-
spinner.start("Updating app state...");
|
|
3885
|
-
const updatedNodes = app.nodes.filter((n) => n.name !== node.name);
|
|
3886
|
-
app.nodes = updatedNodes;
|
|
3887
|
-
await platformClient.saveApp(app);
|
|
3888
|
-
spinner.succeed("App state updated");
|
|
3889
|
-
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})`));
|
|
3890
3876
|
} catch (error) {
|
|
3891
|
-
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;
|
|
3892
3880
|
}
|
|
3893
3881
|
}
|
|
3894
|
-
const updatedApp = await platformClient.getApp(appName);
|
|
3895
3882
|
const syncSpinner = ora10("Syncing gateway routes...").start();
|
|
3896
3883
|
try {
|
|
3897
3884
|
await platformClient.syncGatewayRoutes(app.location);
|
|
@@ -3899,10 +3886,22 @@ Removing node '${node.name}'...`));
|
|
|
3899
3886
|
} catch (error) {
|
|
3900
3887
|
syncSpinner.warn(`Gateway sync failed: ${error.message}`);
|
|
3901
3888
|
}
|
|
3902
|
-
console.log(chalk16.green(`
|
|
3903
|
-
\u2713 Scaled to ${updatedApp?.nodes.length} nodes
|
|
3904
|
-
`));
|
|
3905
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();
|
|
3906
3905
|
} catch (error) {
|
|
3907
3906
|
console.error(chalk16.red(`
|
|
3908
3907
|
Error: ${error.message}
|