diff --git a/cmd/node.go b/cmd/node.go new file mode 100644 index 00000000..bb3bd6e5 --- /dev/null +++ b/cmd/node.go @@ -0,0 +1,609 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "syscall" + "time" + + "github.com/algorandfoundation/hack-tui/ui/style" + "github.com/spf13/cobra" +) + +var nodeCmd = &cobra.Command{ + Use: "node", + Short: "Algod installation", + Long: style.Purple(BANNER) + "\n" + style.LightBlue("View the node status"), +} + +var installCmd = &cobra.Command{ + Use: "install", + Short: "Install Algod", + Long: "Install Algod on your system", + Run: func(cmd *cobra.Command, args []string) { + installNode() + }, +} + +var configureCmd = &cobra.Command{ + Use: "configure", + Short: "Configure Algod", + Long: "Configure Algod settings", + Run: func(cmd *cobra.Command, args []string) { + configureNode() + }, +} + +var startCmd = &cobra.Command{ + Use: "start", + Short: "Start Algod", + Long: "Start Algod on your system (the one on your PATH).", + Run: func(cmd *cobra.Command, args []string) { + startNode() + }, +} + +var stopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop Algod", + Long: "Stop the Algod process on your system.", + Run: func(cmd *cobra.Command, args []string) { + stopNode() + }, +} + +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade Algod", + Long: "Upgrade Algod (if installed with package manager).", + Run: func(cmd *cobra.Command, args []string) { + upgradeAlgod() + }, +} + +func init() { + rootCmd.AddCommand(nodeCmd) + nodeCmd.AddCommand(installCmd) + nodeCmd.AddCommand(configureCmd) + nodeCmd.AddCommand(startCmd) + nodeCmd.AddCommand(stopCmd) + nodeCmd.AddCommand(upgradeCmd) +} + +func installNode() { + fmt.Println("Checking if Algod is installed...") + + // Check if Algod is installed + if !isAlgodInstalled() { + fmt.Println("Algod is not installed. Installing...") + + // Install Algod based on OS + switch runtime.GOOS { + case "linux": + installNodeLinux() + case "darwin": + installNodeMac() + default: + panic("Unsupported OS: " + runtime.GOOS) + } + } else { + fmt.Println("Algod is already installed.") + printAlgodInfo() + } + +} + +func installNodeLinux() { + fmt.Println("Installing Algod on Linux") + + // Check that we are calling with sudo + if !isRunningWithSudo() { + fmt.Println("This command must be run with super-user priviledges (sudo).") + os.Exit(1) + } + + var installCmds [][]string + var postInstallHint string + + // Based off of https://developer.algorand.org/docs/run-a-node/setup/install/#installation-with-a-package-manager + + if checkCmdToolExists("apt") { // On Ubuntu and Debian we use the apt package manager + fmt.Println("Using apt package manager") + installCmds = [][]string{ + {"apt", "update"}, + {"apt", "install", "-y", "gnupg2", "curl", "software-properties-common"}, + {"sh", "-c", "curl -o - https://releases.algorand.com/key.pub | tee /etc/apt/trusted.gpg.d/algorand.asc"}, + {"sh", "-c", `add-apt-repository -y "deb [arch=amd64] https://releases.algorand.com/deb/ stable main"`}, + {"apt", "update"}, + {"apt", "install", "-y", "algorand-devtools"}, + } + } else if checkCmdToolExists("apt-get") { // On some Debian systems we use apt-get + fmt.Println("Using apt-get package manager") + installCmds = [][]string{ + {"apt-get", "update"}, + {"apt-get", "install", "-y", "gnupg2", "curl", "software-properties-common"}, + {"sh", "-c", "curl -o - https://releases.algorand.com/key.pub | tee /etc/apt/trusted.gpg.d/algorand.asc"}, + {"sh", "-c", `add-apt-repository -y "deb [arch=amd64] https://releases.algorand.com/deb/ stable main"`}, + {"apt-get", "update"}, + {"apt-get", "install", "-y", "algorand-devtools"}, + } + } else if checkCmdToolExists("dnf") { // On Fedora and CentOs8 there's the dnf package manager + fmt.Println("Using dnf package manager") + installCmds = [][]string{ + {"curl", "-O", "https://releases.algorand.com/rpm/rpm_algorand.pub"}, + {"rpmkeys", "--import", "rpm_algorand.pub"}, + {"dnf", "install", "-y", "dnf-command(config-manager)"}, + {"dnf", "config-manager", "--add-repo=https://releases.algorand.com/rpm/stable/algorand.repo"}, + {"dnf", "install", "-y", "algorand-devtools"}, + {"systemctl", "start", "algorand"}, + } + } else if checkCmdToolExists("yum") { // On CentOs7 we use the yum package manager + fmt.Println("Using yum package manager") + installCmds = [][]string{ + {"curl", "-O", "https://releases.algorand.com/rpm/rpm_algorand.pub"}, + {"rpmkeys", "--import", "rpm_algorand.pub"}, + {"yum", "install", "yum-utils"}, + {"yum-config-manager", "--add-repo", "https://releases.algorand.com/rpm/stable/algorand.repo"}, + {"yum", "install", "-y", "algorand-devtools"}, + {"systemctl", "start", "algorand"}, + } + } else { + fmt.Println("Unsupported package manager, possibly due to non-Debian or non-Red Hat based Linux distribution. Will attempt to install using updater script.") + installCmds = [][]string{ + {"mkdir", "~/node"}, + {"sh", "-c", "cd ~/node"}, + {"wget", "https://raw.githubusercontent.com/algorand/go-algorand/rel/stable/cmd/updater/update.sh"}, + {"chmod", "744", "update.sh"}, + {"sh", "-c", "./update.sh -i -c stable -p ~/node -d ~/node/data -n"}, + } + + postInstallHint = `You may need to add the Algorand binaries to your PATH: + export ALGORAND_DATA="$HOME/node/data" + export PATH="$HOME/node:$PATH" + ` + } + + // Run each installation command + for _, cmdArgs := range installCmds { + fmt.Println("Running command:", strings.Join(cmdArgs, " ")) + cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Command failed: %s\nOutput: %s\nError: %v\n", strings.Join(cmdArgs, " "), output, err) + cobra.CheckErr(err) + } + } + + if postInstallHint != "" { + fmt.Println(postInstallHint) + } +} + +func installNodeMac() { + fmt.Println("Installing Algod on macOS...") + + // Based off of the macOS installation instructions + // https://developer.algorand.org/docs/run-a-node/setup/install/#installing-on-mac + + installCmd := `mkdir ~/node + cd ~/node + wget https://raw.githubusercontent.com/algorand/go-algorand/rel/stable/cmd/updater/update.sh + chmod 744 update.sh + ./update.sh -i -c stable -p ~/node -d ~/node/data -n` + + postInstallHint := `You may need to add the Algorand binaries to your PATH: + export ALGORAND_DATA="$HOME/node/data" + export PATH="$HOME/node:$PATH" + ` + + // Run the installation command + err := exec.Command(installCmd).Run() + cobra.CheckErr(err) + + if postInstallHint != "" { + fmt.Println(postInstallHint) + } +} + +// TODO: configure not just data directory but algod path +func configureNode() { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + panic("Unsupported OS: " + runtime.GOOS) + } + + var systemctlConfigure bool + + // Check systemctl first + if checkSystemctlAlgorandServiceCreated() { + if !isRunningWithSudo() { + fmt.Println("This command must be run with super-user priviledges (sudo).") + os.Exit(1) + } + + if promptWrapperYes("Algorand is installed as a service. Do you wish to edit the service file to change the data directory? (y/n)") { + if checkSystemctlAlgorandServiceActive() { + fmt.Println("Algorand service is currently running. Please stop the service with *node stop* before editing the service file.") + os.Exit(1) + } + // Edit the service file with the user's new data directory + systemctlConfigure = true + } else { + fmt.Println("Exiting...") + os.Exit(0) + } + } + + // At the end, instead of affectALGORAND_DATA, we'll edit the systemctl algorand.service file + // i.e., overwrite /etc/systemd/system/algorand.service.d/override.conf + // ExecStart and Description will be changed to reflect the new data directory + // + + if !systemctlConfigure { + fmt.Println("Configuring Data directory for algod started through Algorun...") + } + + algorandData := os.Getenv("ALGORAND_DATA") + + // Check if ALGORAND_DATA environment variable is set + if algorandData != "" { + fmt.Println("ALGORAND_DATA environment variable is set to: " + algorandData) + fmt.Println("Inspecting the set data directory...") + + if validateAlgorandDataDir(algorandData) { + fmt.Println("Found valid Algorand Data Directory: " + algorandData) + + if systemctlConfigure { + if promptWrapperYes("Would you like to set the ALGORAND_DATA env variable as the data directory for the systemd Algorand service? (y/n)") { + editAlgorandServiceFile(algorandData) + os.Exit(0) + } + } + + if promptWrapperNo("Do you want to set a completely new data directory? (y/n)") { + fmt.Println("User chose not to set a completely new data directory.") + os.Exit(0) + } + + if promptWrapperYes("Do you want to manually input the new data directory? (y/n)") { + newPath := promptWrapperInput("Enter the new data directory path") + + if !validateAlgorandDataDir(newPath) { + fmt.Println("Path at ALGORAND_DATA: " + newPath + " is not recognizable as an Algorand Data directory.") + os.Exit(1) + } + + if systemctlConfigure { + // Edit the service file + editAlgorandServiceFile(newPath) + } else { + // Affect the ALGORAND_DATA environment variable + affectALGORAND_DATA(newPath) + } + os.Exit(0) + } + } else { + fmt.Println("Path at ALGORAND_DATA: " + algorandData + " is not recognizable as an Algorand Data directory.") + } + } else { + fmt.Println("ALGORAND_DATA environment variable not set.") + } + + // Do quick "lazy" check for existing Algorand Data directories + paths := lazyCheckAlgorandDataDirs() + + if len(paths) != 0 { + + fmt.Println("Quick check found the following potential data directories:") + for _, path := range paths { + fmt.Println("✔ " + path) + } + + if len(paths) == 1 { + if promptWrapperYes("Do you want to set this directory as the new data directory? (y/n)") { + if systemctlConfigure { + // Edit the service file + editAlgorandServiceFile(paths[0]) + } else { + affectALGORAND_DATA(paths[0]) + } + os.Exit(0) + } + + } else { + + if promptWrapperYes("Do you want to set one of these directories as the new data directory? (y/n)") { + + selectedPath := promptWrapperSelection("Select an Algorand data directory", paths) + + if systemctlConfigure { + // Edit the service file + editAlgorandServiceFile(selectedPath) + } else { + affectALGORAND_DATA(selectedPath) + } + os.Exit(0) + } + } + } + + // Deep search + if promptWrapperNo("Do you want Algorun to do a deep search for pre-existing Algorand Data directories? (y/n)") { + fmt.Println("User chose not to search for more pre-existing Algorand Data directories. Exiting...") + os.Exit(0) + } + + fmt.Println("Searching for pre-existing Algorand Data directories in HOME directory...") + paths = deepSearchAlgorandDataDirs() + + if len(paths) == 0 { + fmt.Println("No Algorand data directories could be found in HOME directory. Are you sure Algorand node has been setup? Please run install command.") + os.Exit(1) + } + + fmt.Println("Found Algorand data directories:") + for _, path := range paths { + fmt.Println(path) + } + + // Prompt user to select a directory + selectedPath := promptWrapperSelection("Select an Algorand data directory", paths) + + if systemctlConfigure { + editAlgorandServiceFile(selectedPath) + } else { + affectALGORAND_DATA(selectedPath) + } + os.Exit(0) +} + +// Start Algod on your system (the one on your PATH). +func startNode() { + fmt.Println("Attempting to start Algod...") + + if !isAlgodInstalled() { + fmt.Println("Algod is not installed. Please run the node install command.") + os.Exit(1) + } + + // Check if Algod is already running + if isAlgodRunning() { + fmt.Println("Algod is already running.") + os.Exit(0) + } + + startAlgodProcess() +} + +func isAlgodRunning() bool { + // Check if Algod is already running + // This works for systemctl started algorand.service as well as directly started algod + err := exec.Command("pgrep", "algod").Run() + return err == nil +} + +func startAlgodProcess() { + // Check if algod is available as a systemctl service + if checkSystemctlAlgorandServiceCreated() { + // Algod is available as a systemd service, start it using systemctl + + if !isRunningWithSudo() { + fmt.Println("This command must be run with super-user priviledges (sudo).") + os.Exit(1) + } + + fmt.Println("Starting algod using systemctl...") + cmd := exec.Command("systemctl", "start", "algorand") + err := cmd.Run() + if err != nil { + fmt.Printf("Failed to start algod service: %v\n", err) + os.Exit(1) + } + } else { + // Algod is not available as a systemd service, start it directly + fmt.Println("Starting algod directly...") + + // Check if ALGORAND_DATA environment variable is set + fmt.Println("Checking if ALGORAND_DATA env var is set...") + algorandData := os.Getenv("ALGORAND_DATA") + + if !validateAlgorandDataDir(algorandData) { + fmt.Println("ALGORAND_DATA environment variable is not set or is invalid. Please run node configure and follow the instructions.") + os.Exit(1) + } + + fmt.Println("ALGORAND_DATA env var set to valid directory: " + algorandData) + + cmd := exec.Command("algod") + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + err := cmd.Start() + if err != nil { + fmt.Printf("Failed to start algod: %v\n", err) + os.Exit(1) + } + } + + // Wait for the process to start + time.Sleep(5 * time.Second) + + if isAlgodRunning() { + fmt.Println("Algod is running.") + } else { + fmt.Println("Algod failed to start.") + } +} + +// Stop the Algod process on your system. +func stopNode() { + fmt.Println("Attempting to stop Algod...") + + if !isAlgodRunning() { + fmt.Println("Algod was not running.") + os.Exit(0) + } + + stopAlgodProcess() + + time.Sleep(5 * time.Second) + + if !isAlgodRunning() { + fmt.Println("Algod is no longer running.") + os.Exit(0) + } + + fmt.Println("Failed to stop Algod.") + os.Exit(1) +} + +func stopAlgodProcess() { + // Check if algod is available as a systemd service + if checkSystemctlAlgorandServiceCreated() { + if !isRunningWithSudo() { + fmt.Println("This command must be run with super-user priviledges (sudo).") + os.Exit(1) + } + + // Algod is available as a systemd service, stop it using systemctl + fmt.Println("Stopping algod using systemctl...") + cmd := exec.Command("systemctl", "stop", "algorand") + err := cmd.Run() + if err != nil { + fmt.Printf("Failed to stop algod service: %v\n", err) + cobra.CheckErr(err) + } + fmt.Println("Algod service stopped.") + } else { + // Algod is not available as a systemd service, stop it directly + fmt.Println("Stopping algod directly...") + // Find the process ID of algod + pid, err := findAlgodPID() + if err != nil { + fmt.Printf("Failed to find algod process: %v\n", err) + cobra.CheckErr(err) + } + + // Send SIGTERM to the process + process, err := os.FindProcess(pid) + if err != nil { + fmt.Printf("Failed to find process with PID %d: %v\n", pid, err) + cobra.CheckErr(err) + } + + err = process.Signal(syscall.SIGTERM) + if err != nil { + fmt.Printf("Failed to send SIGTERM to process with PID %d: %v\n", pid, err) + cobra.CheckErr(err) + } + + fmt.Println("Sent SIGTERM to algod process.") + } +} + +// Upgrade ALGOD (if installed with package manager). +func upgradeAlgod() { + + if !isAlgodInstalled() { + fmt.Println("Algod is not installed. Please run the node install command.") + os.Exit(1) + } + + // Check if Algod was installed with apt/apt-get + if checkCmdToolExists("apt") { + upgradeDebianPackage("apt", "algorand-devtools") + } else if checkCmdToolExists("apt-get") { + upgradeDebianPackage("apt-get", "algorand-devtools") + } else if checkCmdToolExists("dnf") { + upgradeRpmPackage("dnf", "algorand-devtools") + } else if checkCmdToolExists("yum") { + upgradeRpmPackage("yum", "algorand-devtools") + } else { + fmt.Println("The *node upgrade* command is currently only available for installations done with an approved package manager. Please use a different method to upgrade.") + os.Exit(1) + } +} + +// Upgrade a package using the specified Debian package manager +func upgradeDebianPackage(packageManager, packageName string) { + // Check that we are calling with sudo + if !isRunningWithSudo() { + fmt.Println("This command must be run with super-user priviledges (sudo).") + os.Exit(1) + } + + // Check if the package is installed and if there are updates available using apt-cache policy + cmd := exec.Command("apt-cache", "policy", packageName) + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Failed to check package policy: %v\n", err) + os.Exit(1) + } + + outputStr := string(output) + if strings.Contains(outputStr, "Installed: (none)") { + fmt.Printf("Package %s is not installed.\n", packageName) + os.Exit(1) + } + + installedVersion := extractVersion(outputStr, "Installed:") + candidateVersion := extractVersion(outputStr, "Candidate:") + + if installedVersion == candidateVersion { + fmt.Printf("Package %s is installed (v%s) and up-to-date with latest (v%s).\n", packageName, installedVersion, candidateVersion) + os.Exit(0) + } + + fmt.Printf("Package %s is installed (v%s) and has updates available (v%s).\n", packageName, installedVersion, candidateVersion) + + // Update the package list + fmt.Println("Updating package list...") + cmd = exec.Command(packageManager, "update") + err = cmd.Run() + if err != nil { + fmt.Printf("Failed to update package list: %v\n", err) + os.Exit(1) + } + + // Upgrade the package + fmt.Printf("Upgrading package %s...\n", packageName) + cmd = exec.Command(packageManager, "install", "--only-upgrade", "-y", packageName) + err = cmd.Run() + if err != nil { + fmt.Printf("Failed to upgrade package %s: %v\n", packageName, err) + os.Exit(1) + } + + fmt.Printf("Package %s upgraded successfully.\n", packageName) + os.Exit(0) +} + +// Upgrade a package using the specified RPM package manager +func upgradeRpmPackage(packageManager, packageName string) { + // Check that we are calling with sudo + if !isRunningWithSudo() { + fmt.Println("This command must be run with sudo.") + os.Exit(1) + } + + // Attempt to upgrade the package directly + fmt.Printf("Upgrading package %s...\n", packageName) + cmd := exec.Command(packageManager, "update", "-y", packageName) + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Failed to upgrade package %s: %v\n", packageName, err) + os.Exit(1) + } + + outputStr := string(output) + if strings.Contains(outputStr, "Nothing to do") { + fmt.Printf("Package %s is already up-to-date.\n", packageName) + os.Exit(0) + } else { + fmt.Println(outputStr) + fmt.Printf("Package %s upgraded successfully.\n", packageName) + os.Exit(0) + } +} diff --git a/cmd/root.go b/cmd/root.go index 1f04e8e9..f50bc7a0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "fmt" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui" @@ -39,11 +40,24 @@ var ( }, RunE: func(cmd *cobra.Command, args []string) error { log.SetOutput(cmd.OutOrStdout()) + initConfig() + + if viper.GetString("server") == "" { + return fmt.Errorf(style.Red.Render("server is required")) + } + if viper.GetString("token") == "" { + return fmt.Errorf(style.Red.Render("token is required")) + } + client, err := getClient() cobra.CheckErr(err) partkeys, err := internal.GetPartKeys(context.Background(), client) - cobra.CheckErr(err) + if err != nil { + return fmt.Errorf( + style.Red.Render("failed to get participation keys: %s"), + err) + } state := internal.StateModel{ Status: internal.StatusModel{ @@ -106,7 +120,7 @@ func check(err interface{}) { // Handle global flags and set usage templates func init() { log.SetReportTimestamp(false) - initConfig() + // Configure Version if Version == "" { Version = "unknown (built from source)" @@ -142,6 +156,15 @@ type AlgodConfig struct { EndpointAddress string `json:"EndpointAddress"` } +func replaceEndpointUrl(s string) string { + s = strings.Replace(s, "\n", "", 1) + s = strings.Replace(s, "0.0.0.0", "127.0.0.1", 1) + s = strings.Replace(s, "[::]", "127.0.0.1", 1) + return s +} +func hasWildcardEndpointUrl(s string) bool { + return strings.Contains(s, "0.0.0.0") || strings.Contains(s, "::") +} func initConfig() { // Find home directory. home, err := os.UserHomeDir() @@ -159,12 +182,17 @@ func initConfig() { // Load Configurations viper.AutomaticEnv() - err = viper.ReadInConfig() + _ = viper.ReadInConfig() + + // Check for server + loadedServer := viper.GetString("server") + loadedToken := viper.GetString("token") + // Load ALGORAND_DATA/config.json algorandData, exists := os.LookupEnv("ALGORAND_DATA") // Load the Algorand Data Configuration - if exists && algorandData != "" { + if exists && algorandData != "" && loadedServer == "" { // Placeholder for Struct var algodConfig AlgodConfig @@ -183,23 +211,43 @@ func initConfig() { err = configFile.Close() check(err) - // Replace catchall address with localhost - if strings.Contains(algodConfig.EndpointAddress, "0.0.0.0") { - algodConfig.EndpointAddress = strings.Replace(algodConfig.EndpointAddress, "0.0.0.0", "127.0.0.1", 1) + // Check for endpoint address + if hasWildcardEndpointUrl(algodConfig.EndpointAddress) { + algodConfig.EndpointAddress = replaceEndpointUrl(algodConfig.EndpointAddress) + } else if algodConfig.EndpointAddress == "" { + // Assume it is not set, try to discover the port from the network file + networkPath := algorandData + "/algod.net" + networkFile, err := os.Open(networkPath) + check(err) + + byteValue, err = io.ReadAll(networkFile) + check(err) + + if hasWildcardEndpointUrl(string(byteValue)) { + algodConfig.EndpointAddress = replaceEndpointUrl(string(byteValue)) + } else { + algodConfig.EndpointAddress = string(byteValue) + } + } + if strings.Contains(algodConfig.EndpointAddress, ":0") { + algodConfig.EndpointAddress = strings.Replace(algodConfig.EndpointAddress, ":0", ":8080", 1) + } + if loadedToken == "" { + // Handle Token Path + tokenPath := algorandData + "/algod.admin.token" - // Handle Token Path - tokenPath := algorandData + "/algod.admin.token" + tokenFile, err := os.Open(tokenPath) + check(err) - tokenFile, err := os.Open(tokenPath) - check(err) + byteValue, err = io.ReadAll(tokenFile) + check(err) - byteValue, err = io.ReadAll(tokenFile) - check(err) + viper.Set("token", strings.Replace(string(byteValue), "\n", "", 1)) + } // Set the server configuration - viper.Set("server", "http://"+algodConfig.EndpointAddress) - viper.Set("token", string(byteValue)) + viper.Set("server", "http://"+strings.Replace(algodConfig.EndpointAddress, "\n", "", 1)) viper.Set("data", dataConfigPath) } diff --git a/cmd/root_test.go b/cmd/root_test.go index 0b7b8b7e..7cae6f28 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -21,12 +21,64 @@ func Test_ExecuteRootCommand(t *testing.T) { func Test_InitConfig(t *testing.T) { cwd, _ := os.Getwd() - t.Setenv("ALGORAND_DATA", cwd+"/testdata") + viper.Set("token", "") + viper.Set("server", "") + t.Setenv("ALGORAND_DATA", cwd+"/testdata/Test_InitConfig") initConfig() server := viper.Get("server") if server == "" { t.Fatal("Invalid Server") } + if server != "http://127.0.0.1:8080" { + t.Fatal("Invalid Server") + } +} + +func Test_InitConfigWithoutEndpoint(t *testing.T) { + cwd, _ := os.Getwd() + viper.Set("token", "") + viper.Set("server", "") + t.Setenv("ALGORAND_DATA", cwd+"/testdata/Test_InitConfigWithoutEndpoint") + + initConfig() + server := viper.Get("server") + if server == "" { + t.Fatal("Invalid Server") + } + if server != "http://127.0.0.1:8080" { + t.Fatal("Invalid Server") + } +} + +func Test_InitConfigWithAddress(t *testing.T) { + cwd, _ := os.Getwd() + viper.Set("token", "") + viper.Set("server", "") + t.Setenv("ALGORAND_DATA", cwd+"/testdata/Test_InitConfigWithAddress") + + initConfig() + server := viper.Get("server") + if server == "" { + t.Fatal("Invalid Server") + } + if server != "http://255.255.255.255:8080" { + t.Fatal("Invalid Server") + } +} +func Test_InitConfigWithAddressAndDefaultPort(t *testing.T) { + cwd, _ := os.Getwd() + viper.Set("token", "") + viper.Set("server", "") + t.Setenv("ALGORAND_DATA", cwd+"/testdata/Test_InitConfigWithAddressAndDefaultPort") + + initConfig() + server := viper.Get("server") + if server == "" { + t.Fatal("Invalid Server") + } + if server != "http://255.255.255.255:8080" { + t.Fatal("Invalid Server") + } } diff --git a/cmd/status.go b/cmd/status.go index 3bff01e3..5af7f38b 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -19,6 +19,7 @@ var statusCmd = &cobra.Command{ Short: "Get the node status", Long: style.Purple(BANNER) + "\n" + style.LightBlue("View the node status"), RunE: func(cmd *cobra.Command, args []string) error { + initConfig() if viper.GetString("server") == "" { return errors.New(style.Magenta("server is required")) } diff --git a/cmd/testdata/algod.admin.token b/cmd/testdata/Test_InitConfig/algod.admin.token similarity index 100% rename from cmd/testdata/algod.admin.token rename to cmd/testdata/Test_InitConfig/algod.admin.token diff --git a/cmd/testdata/config.json b/cmd/testdata/Test_InitConfig/config.json similarity index 100% rename from cmd/testdata/config.json rename to cmd/testdata/Test_InitConfig/config.json diff --git a/cmd/testdata/Test_InitConfigWithAddress/algod.admin.token b/cmd/testdata/Test_InitConfigWithAddress/algod.admin.token new file mode 100644 index 00000000..71b7a719 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithAddress/algod.admin.token @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithAddress/config.json b/cmd/testdata/Test_InitConfigWithAddress/config.json new file mode 100644 index 00000000..f172cd52 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithAddress/config.json @@ -0,0 +1,4 @@ +{ + "EndpointAddress": "255.255.255.255:8080", + "OtherKey": "" +} \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/algod.admin.token b/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/algod.admin.token new file mode 100644 index 00000000..71b7a719 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/algod.admin.token @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/config.json b/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/config.json new file mode 100644 index 00000000..0e455f6b --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/config.json @@ -0,0 +1,4 @@ +{ + "EndpointAddress": "255.255.255.255:0", + "OtherKey": "" +} \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.admin.token b/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.admin.token new file mode 100644 index 00000000..71b7a719 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.admin.token @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.net b/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.net new file mode 100644 index 00000000..7985b074 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.net @@ -0,0 +1 @@ +[::]:8080 \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithoutEndpoint/config.json b/cmd/testdata/Test_InitConfigWithoutEndpoint/config.json new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithoutEndpoint/config.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 00000000..b3c56acf --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,340 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "text/template" + + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +type Release struct { + Name string `json:"name"` + ZipballURL string `json:"zipball_url"` + TarballURL string `json:"tarball_url"` + Commit struct { + Sha string `json:"sha"` + URL string `json:"url"` + } `json:"commit"` + NodeID string `json:"node_id"` +} + +// Queries user on the provided prompt and returns the user input +func promptWrapperInput(promptLabel string) string { + prompt := promptui.Prompt{ + Label: promptLabel, + } + + result, err := prompt.Run() + cobra.CheckErr(err) + + return result +} + +// Queries user on the provided prompt and returns true if user inputs "y" +func promptWrapperYes(promptLabel string) bool { + return promptWrapperInput(promptLabel) == "y" +} + +// Queries user on the provided prompt and returns true if user does not input "y" +// Included for improved readability of decision tree, despite being redundant. +func promptWrapperNo(promptLabel string) bool { + return promptWrapperInput(promptLabel) != "y" +} + +// Queries user on the provided prompt and returns the selected item +func promptWrapperSelection(promptLabel string, items []string) string { + prompt := promptui.Select{ + Label: promptLabel, + Items: items, + } + + _, result, err := prompt.Run() + cobra.CheckErr(err) + + fmt.Printf("You selected: %s\n", result) + + return result +} + +// Check if Algod is installed +func isAlgodInstalled() bool { + if runtime.GOOS == "windows" { + panic("Windows is not supported.") + } + + return checkCmdToolExists("algod") +} + +// Checks that a bash cli/cmd tool exists +func checkCmdToolExists(tool string) bool { + _, err := exec.LookPath(tool) + return err == nil +} + +// Find where algod is defined and print its version +func printAlgodInfo() { + algodPath, err := exec.LookPath("algod") + if err != nil { + fmt.Printf("Error finding algod: %v\n", err) + return + } + + // Get algod version + algodVersion, err := exec.Command("algod", "-v").Output() + if err != nil { + fmt.Printf("Error getting algod version: %v\n", err) + return + } + + fmt.Printf("Algod is located at: %s\n", algodPath) + fmt.Printf("algod -v\n") + fmt.Printf("%s\n", algodVersion) +} + +// TODO: consider replacing with a method that does more for the user +func affectALGORAND_DATA(path string) { + fmt.Println("Please execute the following in your terminal to set the environment variable:") + fmt.Println("") + fmt.Println("export ALGORAND_DATA=" + path) + fmt.Println("") +} + +// Update the algorand.service file +func editAlgorandServiceFile(dataDirectoryPath string) { + + // TODO: look into setting algod path as well as the data directory path + // Find the path to the algod binary + algodPath, err := exec.LookPath("algod") + if err != nil { + fmt.Printf("Failed to find algod binary: %v\n", err) + os.Exit(1) + } + + // Path to the systemd service override file + // Assuming that this is the same everywhere systemd is used + overrideFilePath := "/etc/systemd/system/algorand.service.d/override.conf" + + // Create the override directory if it doesn't exist + err = os.MkdirAll("/etc/systemd/system/algorand.service.d", 0755) + if err != nil { + fmt.Printf("Failed to create override directory: %v\n", err) + os.Exit(1) + } + + // Content of the override file + const overrideTemplate = `[Unit] +Description=Algorand daemon {{.AlgodPath}} in {{.DataDirectoryPath}} +[Service] +ExecStart= +ExecStart={{.AlgodPath}} -d {{.DataDirectoryPath}}` + + // Data to fill the template + data := map[string]string{ + "AlgodPath": algodPath, + "DataDirectoryPath": dataDirectoryPath, + } + + // Parse and execute the template + tmpl, err := template.New("override").Parse(overrideTemplate) + if err != nil { + fmt.Printf("Failed to parse template: %v\n", err) + os.Exit(1) + } + + var overrideContent bytes.Buffer + err = tmpl.Execute(&overrideContent, data) + if err != nil { + fmt.Printf("Failed to execute template: %v\n", err) + os.Exit(1) + } + + // Write the override content to the file + err = os.WriteFile(overrideFilePath, overrideContent.Bytes(), 0644) + if err != nil { + fmt.Printf("Failed to write override file: %v\n", err) + os.Exit(1) + } + + // Reload systemd manager configuration + cmd := exec.Command("systemctl", "daemon-reload") + err = cmd.Run() + if err != nil { + fmt.Printf("Failed to reload systemd daemon: %v\n", err) + os.Exit(1) + } + + fmt.Println("Algorand service file updated successfully.") +} + +// Check if the program is running with admin (super-user) priviledges +func isRunningWithSudo() bool { + return os.Geteuid() == 0 +} + +// Finds path(s) to a file in a directory and its subdirectories using parallel processing +func findPathToFile(startDir string, targetFileName string) []string { + var dirPaths []string + var mu sync.Mutex + var wg sync.WaitGroup + + fileChan := make(chan string) + + // Worker function to process files + worker := func() { + defer wg.Done() + for path := range fileChan { + info, err := os.Stat(path) + if err != nil { + continue + } + if !info.IsDir() && info.Name() == targetFileName { + dirPath := filepath.Dir(path) + mu.Lock() + dirPaths = append(dirPaths, dirPath) + mu.Unlock() + } + } + } + + // Start worker goroutines + numWorkers := 4 // Adjust the number of workers based on your system's capabilities + for i := 0; i < numWorkers; i++ { + wg.Add(1) + go worker() + } + + // Walk the directory tree and send file paths to the channel + err := filepath.Walk(startDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + // Ignore permission errors + if os.IsPermission(err) { + return nil + } + return err + } + fileChan <- path + return nil + }) + + close(fileChan) + wg.Wait() + + if err != nil { + panic(err) + } + + return dirPaths +} + +func validateAlgorandDataDir(path string) bool { + info, err := os.Stat(path) + + // Check if the path exists + if os.IsNotExist(err) { + return false + } + + // Check if the path is a directory + if !info.IsDir() { + return false + } + + paths := findPathToFile(path, "algod.token") + if len(paths) == 1 { + return true + } + return false +} + +// Does a lazy check for Algorand data directories, based off of known common paths +func lazyCheckAlgorandDataDirs() []string { + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + // Hardcoded paths known to be common Algorand data directories + commonAlgorandDataDirPaths := []string{ + "/var/lib/algorand", + filepath.Join(home, "node", "data"), + filepath.Join(home, ".algorand"), + } + + var paths []string + + for _, path := range commonAlgorandDataDirPaths { + if validateAlgorandDataDir(path) { + paths = append(paths, path) + } + } + + return paths +} + +// Checks if Algorand data directories exist, based off of existence of the "algod.token" file +func deepSearchAlgorandDataDirs() []string { + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + // TODO: consider a better way to identify an Algorand data directory + paths := findPathToFile(home, "algod.token") + + return paths +} + +func findAlgodPID() (int, error) { + cmd := exec.Command("pgrep", "algod") + output, err := cmd.Output() + if err != nil { + return 0, err + } + + var pid int + _, err = fmt.Sscanf(string(output), "%d", &pid) + if err != nil { + return 0, fmt.Errorf("failed to parse PID: %v", err) + } + + return pid, nil +} + +// Check systemctl has Algorand Service been created in the first place +func checkSystemctlAlgorandServiceCreated() bool { + cmd := exec.Command("systemctl", "list-unit-files", "algorand.service") + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + return false + } + return strings.Contains(out.String(), "algorand.service") +} + +func checkSystemctlAlgorandServiceActive() bool { + cmd := exec.Command("systemctl", "is-active", "algorand") + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + return false + } + return strings.TrimSpace(out.String()) == "active" +} + +// Extract version information from apt-cache policy output +func extractVersion(output, prefix string) string { + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(strings.TrimPrefix(line, prefix)) + } + } + return "" +} diff --git a/go.mod b/go.mod index ebabe667..7bac0b36 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/charmbracelet/lipgloss v0.13.1 github.com/charmbracelet/log v0.4.0 github.com/charmbracelet/x/exp/teatest v0.0.0-20241022174419-46d9bb99a691 + github.com/manifoldco/promptui v0.9.0 github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/cobra v1.8.1 @@ -19,6 +20,7 @@ require ( ) require ( + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index acaa1e37..0b57cc4a 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,12 @@ github.com/charmbracelet/x/exp/teatest v0.0.0-20241022174419-46d9bb99a691 h1:xiY github.com/charmbracelet/x/exp/teatest v0.0.0-20241022174419-46d9bb99a691/go.mod h1:ektxP4TiEONm1mTGILRfo8F0a4rZMwsT1fEkXslQKtU= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -68,6 +74,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -137,6 +145,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/metrics.go b/internal/metrics.go index d2403094..b10a9f40 100644 --- a/internal/metrics.go +++ b/internal/metrics.go @@ -17,6 +17,9 @@ type MetricsModel struct { TPS float64 RX int TX int + LastTS time.Time + LastRX int + LastTX int } type MetricsResponse map[string]int diff --git a/internal/state.go b/internal/state.go index c20508c0..e14c07aa 100644 --- a/internal/state.go +++ b/internal/state.go @@ -91,8 +91,15 @@ func (s *StateModel) UpdateMetricsFromRPC(ctx context.Context, client *api.Clien } if err == nil { s.Metrics.Enabled = true - s.Metrics.TX = res["algod_network_sent_bytes_total"] - s.Metrics.RX = res["algod_network_received_bytes_total"] + now := time.Now() + diff := now.Sub(s.Metrics.LastTS) + + s.Metrics.TX = max(0, int(float64(res["algod_network_sent_bytes_total"]-s.Metrics.LastTX)/diff.Seconds())) + s.Metrics.RX = max(0, int(float64(res["algod_network_received_bytes_total"]-s.Metrics.LastRX)/diff.Seconds())) + + s.Metrics.LastTS = now + s.Metrics.LastTX = res["algod_network_sent_bytes_total"] + s.Metrics.LastRX = res["algod_network_received_bytes_total"] } } func (s *StateModel) UpdateAccounts(client *api.ClientWithResponses) { diff --git a/ui/status.go b/ui/status.go index 281288bf..451a5758 100644 --- a/ui/status.go +++ b/ui/status.go @@ -6,6 +6,7 @@ import ( "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "math" "strconv" "strings" "time" @@ -44,6 +45,21 @@ func (m StatusViewModel) HandleMessage(msg tea.Msg) (StatusViewModel, tea.Cmd) { return m, nil } +func getBitRate(bytes int) string { + txString := fmt.Sprintf("%d B/s ", bytes) + if bytes >= 1024 { + txString = fmt.Sprintf("%d KB/s ", bytes/(1<<10)) + } + if bytes >= int(math.Pow(1024, 2)) { + txString = fmt.Sprintf("%d MB/s ", bytes/(1<<20)) + } + if bytes >= int(math.Pow(1024, 3)) { + txString = fmt.Sprintf("%d GB/s ", bytes/(1<<30)) + } + + return txString +} + // View handles the render cycle func (m StatusViewModel) View() string { if !m.IsVisible { @@ -70,13 +86,13 @@ func (m StatusViewModel) View() string { row1 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) beginning = style.Blue.Render(" Round time: ") + fmt.Sprintf("%.2fs", float64(m.Data.Metrics.RoundTime)/float64(time.Second)) - end = fmt.Sprintf("%d KB/s ", m.Data.Metrics.TX/1024) + style.Green.Render("TX ") + end = getBitRate(m.Data.Metrics.TX) + style.Green.Render("TX ") middle = strings.Repeat(" ", max(0, size-(lipgloss.Width(beginning)+lipgloss.Width(end)+2))) row2 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) beginning = style.Blue.Render(" TPS: ") + fmt.Sprintf("%.2f", m.Data.Metrics.TPS) - end = fmt.Sprintf("%d KB/s ", m.Data.Metrics.RX/1024) + style.Green.Render("RX ") + end = getBitRate(m.Data.Metrics.RX) + style.Green.Render("RX ") middle = strings.Repeat(" ", max(0, size-(lipgloss.Width(beginning)+lipgloss.Width(end)+2))) row3 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end)