diff --git a/.docker/Fedora.dockerfile b/.docker/Fedora.dockerfile new file mode 100644 index 00000000..1891af84 --- /dev/null +++ b/.docker/Fedora.dockerfile @@ -0,0 +1,43 @@ +FROM golang:1.23-bookworm as BUILDER + +WORKDIR /app + +ADD . . + +RUN CGO_ENABLED=0 go build -cover -o ./bin/algorun *.go + + +FROM fedora:39 as legacy + +ADD playbook.yaml /root/playbook.yaml +COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun +RUN dnf install systemd ansible-core -y && \ + mkdir -p /app/coverage/int/fedora/39 && \ + echo GOCOVERDIR=/app/coverage/int/fedora/39 >> /etc/environment + +STOPSIGNAL SIGRTMIN+3 +CMD ["/usr/lib/systemd/systemd"] + +FROM fedora:40 as previous + +ADD playbook.yaml /root/playbook.yaml +COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun + +RUN dnf install systemd ansible-core -y && \ + mkdir -p /app/coverage/int/fedora/40 && \ + echo GOCOVERDIR=/app/coverage/int/fedora/40 >> /etc/environment + +STOPSIGNAL SIGRTMIN+3 +CMD ["/usr/lib/systemd/systemd"] + +FROM fedora:41 as latest + +ADD playbook.yaml /root/playbook.yaml +COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun + +RUN dnf install systemd ansible-core -y && \ + mkdir -p /app/coverage/int/fedora/41 && \ + echo GOCOVERDIR=/app/coverage/int/fedora/41 >> /etc/environment + +STOPSIGNAL SIGRTMIN+3 +CMD ["/usr/lib/systemd/systemd"] diff --git a/.docker/Ubuntu.dockerfile b/.docker/Ubuntu.dockerfile new file mode 100644 index 00000000..f5cca2f7 --- /dev/null +++ b/.docker/Ubuntu.dockerfile @@ -0,0 +1,47 @@ +FROM golang:1.23-bookworm as BUILDER + +WORKDIR /app + +ADD . . + +RUN CGO_ENABLED=0 go build -cover -o ./bin/algorun *.go + +FROM ubuntu:18.04 as bionic + +RUN apt-get update && apt-get install systemd software-properties-common -y && add-apt-repository --yes --update ppa:ansible/ansible + +ADD playbook.yaml /root/playbook.yaml +COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun +RUN mkdir -p /app/coverage/int/ubuntu/18.04 && \ + echo GOCOVERDIR=/app/coverage/int/ubuntu/18.04 >> /etc/environment && \ + apt-get install ansible -y && \ + chmod 0 /usr/bin/apt # Liam Neeson + +STOPSIGNAL SIGRTMIN+3 +CMD ["/bin/systemd"] + +FROM ubuntu:22.04 as jammy + +RUN apt-get update && apt-get install systemd software-properties-common -y && add-apt-repository --yes --update ppa:ansible/ansible + +ADD playbook.yaml /root/playbook.yaml +COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun +RUN mkdir -p /app/coverage/int/ubuntu/22.04 && \ + echo GOCOVERDIR=/app/coverage/int/ubuntu/22.04 >> /etc/environment && \ + apt-get install ansible -y + +STOPSIGNAL SIGRTMIN+3 +CMD ["/usr/lib/systemd/systemd"] + +FROM ubuntu:24.04 as noble + +RUN apt-get update && apt-get install systemd software-properties-common -y && add-apt-repository --yes --update ppa:ansible/ansible + +ADD playbook.yaml /root/playbook.yaml +COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun +RUN mkdir -p /app/coverage/int/ubuntu/24.04 && \ + echo GOCOVERDIR=/app/coverage/int/ubuntu/24.04 >> /etc/environment && \ + apt-get install ansible -y + +STOPSIGNAL SIGRTMIN+3 +CMD ["/usr/lib/systemd/systemd"] \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/code_test.yaml similarity index 75% rename from .github/workflows/test.yaml rename to .github/workflows/code_test.yaml index 3cad3ebb..df125f2e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/code_test.yaml @@ -1,4 +1,4 @@ -name: Tests +name: Code Tests on: pull_request: @@ -55,8 +55,23 @@ jobs: - name: Build run: go build -o bin/algorun *.go - - name: Test with the Go CLI - run: go test ./... -coverprofile=./coverage.txt -covermode=atomic -coverpkg=./... + - name: Unit Tests + run: make unit + + - name: Kill docker + run: docker compose down + + - name: Start Integration + run: docker compose -f docker-compose.integration.yaml up -d + + - name: Wait for mount + run: npx wait-on ./coverage/int/fedora/40 ./coverage/int/ubuntu/24.04/ + + - name: Integration tests + run: make integration + + - name: Combine coverage + run: make combine-coverage - name: Upload results to Codecov uses: codecov/codecov-action@v4 diff --git a/.github/workflows/node_test.yaml b/.github/workflows/node_test.yaml new file mode 100644 index 00000000..cacf280c --- /dev/null +++ b/.github/workflows/node_test.yaml @@ -0,0 +1,56 @@ +## This is a temporary flow, until we have our custom docker images that work with systemd for linux. +## Once we have that, we can remove this and use docker containers in parallel, covering the various OS:es. + +name: Node Command OS-Matrix Test + +on: + workflow_dispatch: + pull_request: + paths: + - "cmd/**" + +jobs: + ubuntu: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: 1.22 + - name: Run Ubuntu commands + run: | + go build . + ./algorun-tui node install + systemctl status algorand.service + export TOKEN=$(cat /var/lib/algorand/algod.admin.token) + curl http://localhost:8080/v2/participation -H "X-Algo-API-Token: $TOKEN" | grep "null" + ./algorun-tui node stop + ./algorun-tui node upgrade + ./algorun-tui node stop + ./algorun-tui node uninstall + + macos: + runs-on: macos-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Go + run: brew install go + + - name: Run MacOs commands + run: | + go build . + ./algorun-tui node install + sudo launchctl print system/com.algorand.algod + sleep 5 + export TOKEN=$(cat ~/.algorand/algod.admin.token) + curl http://localhost:8080/v2/participation -H "X-Algo-API-Token: $TOKEN" | grep "null" + ./algorun-tui node stop + ./algorun-tui node upgrade + ./algorun-tui node stop + ./algorun-tui node uninstall diff --git a/.gitignore b/.gitignore index 3b4a04bd..c7a3058c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ algorun-tui +coverage bin .data @@ -15,6 +16,9 @@ bin # Test binary, built with `go test -c` *.test +# coverage.txt, in case you run tests locally +coverage.txt + # Output of the go coverage tool, specifically when used with LiteIDE *.out diff --git a/Makefile b/Makefile index fc3c4d38..195d9e3a 100644 --- a/Makefile +++ b/Makefile @@ -4,3 +4,11 @@ test: go test -coverpkg=./... -covermode=atomic ./... generate: oapi-codegen -config generate.yaml https://raw.githubusercontent.com/algorand/go-algorand/v3.26.0-stable/daemon/algod/api/algod.oas3.yml +unit: + mkdir -p $(CURDIR)/coverage/unit && go test -cover ./... -args -test.gocoverdir=$(CURDIR)/coverage/unit +integration: + for service in $(shell docker compose -f docker-compose.integration.yaml ps --services) ; do \ + docker compose exec -it "$$service" ansible-playbook --connection=local /root/playbook.yaml ; \ + done +combine-coverage: + go tool covdata textfmt -i=./coverage/unit,./coverage/int/ubuntu/24.04,./coverage/int/fedora/40 -o coverage.txt && sed -i 2,3d coverage.txt \ No newline at end of file diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go new file mode 100644 index 00000000..3669e7b6 --- /dev/null +++ b/cmd/configure/configure.go @@ -0,0 +1,332 @@ +package configure + +import ( + "bytes" + "fmt" + "github.com/algorandfoundation/algorun-tui/cmd/node" + "github.com/algorandfoundation/algorun-tui/internal/algod" + "github.com/algorandfoundation/algorun-tui/internal/algod/utils" + "os" + "os/exec" + "runtime" + "strings" + "text/template" + + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "configure", + Short: "Configure Algod", + Long: "Configure Algod settings", + SilenceUsage: true, + PersistentPreRun: node.NeedsToBeStopped, + //RunE: func(cmd *cobra.Command, args []string) error { + // return configureNode() + //}, +} + +func init() { + Cmd.AddCommand(serviceCmd) +} + +const ConfigureRunningErrorMsg = "algorand is currently running. Please stop the node with *node stop* before configuring" + +// TODO: configure not just data directory but algod path +func configureNode() error { + var systemServiceConfigure bool + + if algod.IsRunning() { + return fmt.Errorf(ConfigureRunningErrorMsg) + } + + // Check systemctl first + if algod.IsService() { + if promptWrapperYes("Algorand is installed as a service. Do you wish to edit the service file to change the data directory? (y/n)") { + // Edit the service file with the user's new data directory + systemServiceConfigure = 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 !systemServiceConfigure { + 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 systemServiceConfigure { + 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 systemServiceConfigure { + // 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 := utils.GetKnownDataPaths() + + 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 systemServiceConfigure { + // 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 systemServiceConfigure { + // 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 systemServiceConfigure { + editAlgorandServiceFile(selectedPath) + } else { + affectALGORAND_DATA(selectedPath) + } + return nil +} + +func editAlgorandServiceFile(dataDirectoryPath string) { + switch runtime.GOOS { + case "linux": + editSystemdAlgorandServiceFile(dataDirectoryPath) + case "darwin": + editLaunchdAlgorandServiceFile(dataDirectoryPath) + default: + fmt.Println("Unsupported operating system.") + } +} + +func editLaunchdAlgorandServiceFile(dataDirectoryPath string) { + + algodPath, err := exec.LookPath("algod") + if err != nil { + fmt.Printf("Failed to find algod binary: %v\n", err) + os.Exit(1) + } + + overwriteFilePath := "/Library/LaunchDaemons/com.algorand.algod.plist" + + overwriteTemplate := ` + + + + Label + com.algorand.algod + ProgramArguments + + {{.AlgodPath}} + -d + {{.DataDirectoryPath}} + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/algod.out + StandardErrorPath + /tmp/algod.err + + ` + + // Data to fill the template + data := map[string]string{ + "AlgodPath": algodPath, + "DataDirectoryPath": dataDirectoryPath, + } + + // Parse and execute the template + tmpl, err := template.New("override").Parse(overwriteTemplate) + if err != nil { + fmt.Printf("Failed to parse template: %v\n", err) + os.Exit(1) + } + + var overwriteContent bytes.Buffer + err = tmpl.Execute(&overwriteContent, 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(overwriteFilePath, overwriteContent.Bytes(), 0644) + if err != nil { + fmt.Printf("Failed to write override file: %v\n", err) + os.Exit(1) + } + + // Boot out the launchd service (just in case - it should be off) + cmd := exec.Command("launchctl", "bootout", "system", overwriteFilePath) + err = cmd.Run() + if err != nil { + if !strings.Contains(err.Error(), "No such process") { + fmt.Printf("Failed to bootout launchd service: %v\n", err) + os.Exit(1) + } + } + + // Load the launchd service + cmd = exec.Command("launchctl", "load", overwriteFilePath) + err = cmd.Run() + if err != nil { + fmt.Printf("Failed to load launchd service: %v\n", err) + os.Exit(1) + } + + fmt.Println("Launchd service updated and reloaded successfully.") +} + +// Update the algorand.service file +func editSystemdAlgorandServiceFile(dataDirectoryPath string) { + + 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.") +} diff --git a/cmd/configure/service.go b/cmd/configure/service.go new file mode 100644 index 00000000..8a84ae75 --- /dev/null +++ b/cmd/configure/service.go @@ -0,0 +1,26 @@ +package configure + +import ( + "errors" + "github.com/algorandfoundation/algorun-tui/internal/algod" + "github.com/algorandfoundation/algorun-tui/internal/system" + "github.com/algorandfoundation/algorun-tui/ui/style" + "github.com/spf13/cobra" +) + +var serviceCmd = &cobra.Command{ + Use: "service", + Short: "Configure the node service", + Long: style.Purple(style.BANNER) + "\n" + style.LightBlue("Configure the service that runs the node."), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if !system.IsSudo() { + return errors.New( + "you need to be root to run this command. Please run this command with sudo") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + // TODO: Combine this with algod.UpdateService and algod.SetNetwork + return algod.EnsureService() + }, +} diff --git a/cmd/configure/utils.go b/cmd/configure/utils.go new file mode 100644 index 00000000..16926921 --- /dev/null +++ b/cmd/configure/utils.go @@ -0,0 +1,97 @@ +package configure + +import ( + "fmt" + "github.com/algorandfoundation/algorun-tui/internal/system" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "os" +) + +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 +} + +// 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("") +} + +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 := system.FindPathToFile(path, "algod.token") + if len(paths) == 1 { + return true + } + return false +} + +// 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 := system.FindPathToFile(home, "algod.token") + + return paths +} diff --git a/cmd/node/debug.go b/cmd/node/debug.go new file mode 100644 index 00000000..eeb77754 --- /dev/null +++ b/cmd/node/debug.go @@ -0,0 +1,54 @@ +package node + +import ( + "encoding/json" + "fmt" + "github.com/algorandfoundation/algorun-tui/internal/algod" + "github.com/algorandfoundation/algorun-tui/internal/algod/utils" + "github.com/algorandfoundation/algorun-tui/internal/system" + "github.com/algorandfoundation/algorun-tui/ui/style" + "github.com/charmbracelet/log" + "github.com/spf13/cobra" + "os/exec" +) + +type DebugInfo struct { + InPath bool `json:"inPath"` + IsRunning bool `json:"isRunning"` + IsService bool `json:"isService"` + IsInstalled bool `json:"isInstalled"` + Algod string `json:"algod"` + Data []string `json:"data"` +} + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "Display debug information for developers", + Long: "Prints debug data to be copy and pasted to a bug report.", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + log.Info("Collecting debug information...") + + // Warn user for prompt + log.Warn(style.Yellow.Render(SudoWarningMsg)) + + paths := utils.GetKnownDataPaths() + path, _ := exec.LookPath("algod") + info := DebugInfo{ + InPath: system.CmdExists("algod"), + IsRunning: algod.IsRunning(), + IsService: algod.IsService(), + IsInstalled: algod.IsInstalled(), + Algod: path, + Data: paths, + } + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return err + } + + log.Info(style.Blue.Render("Copy and paste the following to a bug report:")) + fmt.Println(style.Bold(string(data))) + return nil + }, +} diff --git a/cmd/node/install.go b/cmd/node/install.go new file mode 100644 index 00000000..e2a6d516 --- /dev/null +++ b/cmd/node/install.go @@ -0,0 +1,58 @@ +package node + +import ( + "github.com/algorandfoundation/algorun-tui/internal/algod" + "github.com/algorandfoundation/algorun-tui/ui/style" + "github.com/charmbracelet/log" + "github.com/spf13/cobra" + "os" + "time" +) + +const InstallMsg = "Installing Algorand" +const InstallExistsMsg = "algod is already installed" + +var installCmd = &cobra.Command{ + Use: "install", + Short: "Install the algorand daemon", + Long: style.Purple(style.BANNER) + "\n" + style.LightBlue("Install the algorand daemon on your local machine"), + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + // TODO: yes flag + + // TODO: get expected version + log.Info(style.Green.Render(InstallMsg + " vX.X.X")) + // Warn user for prompt + log.Warn(style.Yellow.Render(SudoWarningMsg)) + + // TODO: compare expected version to existing version + if algod.IsInstalled() && !force { + log.Error(InstallExistsMsg) + os.Exit(1) + } + + // Run the installation + err := algod.Install() + if err != nil { + log.Error(err) + os.Exit(1) + } + + time.Sleep(5 * time.Second) + + // If it's not running, start the daemon (can happen) + if !algod.IsRunning() { + err = algod.Start() + if err != nil { + log.Error(err) + os.Exit(1) + } + } + + log.Info(style.Green.Render("Algorand installed successfully 🎉")) + }, +} + +func init() { + installCmd.Flags().BoolVarP(&force, "force", "f", false, style.Yellow.Render("forcefully install the node")) +} diff --git a/cmd/node/node.go b/cmd/node/node.go new file mode 100644 index 00000000..1fca2ecd --- /dev/null +++ b/cmd/node/node.go @@ -0,0 +1,56 @@ +package node + +import ( + "github.com/algorandfoundation/algorun-tui/internal/algod" + "github.com/algorandfoundation/algorun-tui/ui/style" + "github.com/charmbracelet/log" + "github.com/spf13/cobra" +) + +const SudoWarningMsg = "(You may be prompted for your password)" +const PermissionErrorMsg = "this command must be run with super-user privileges (sudo)" +const NotInstalledErrorMsg = "algod is not installed. please run the *node install* command" +const RunningErrorMsg = "algod is running, please run the *node stop* command" +const NotRunningErrorMsg = "algod is not running" + +var ( + force bool = false +) +var Cmd = &cobra.Command{ + Use: "node", + Short: "Node Management", + Long: style.Purple(style.BANNER) + "\n" + style.LightBlue("Manage your Algorand node"), +} + +func NeedsToBeRunning(cmd *cobra.Command, args []string) { + if force { + return + } + if !algod.IsInstalled() { + log.Fatal(NotInstalledErrorMsg) + } + if !algod.IsRunning() { + log.Fatal(NotRunningErrorMsg) + } +} + +func NeedsToBeStopped(cmd *cobra.Command, args []string) { + if force { + return + } + if !algod.IsInstalled() { + log.Fatal(NotInstalledErrorMsg) + } + if algod.IsRunning() { + log.Fatal(RunningErrorMsg) + } +} + +func init() { + Cmd.AddCommand(installCmd) + Cmd.AddCommand(startCmd) + Cmd.AddCommand(stopCmd) + Cmd.AddCommand(uninstallCmd) + Cmd.AddCommand(upgradeCmd) + Cmd.AddCommand(debugCmd) +} diff --git a/cmd/node/start.go b/cmd/node/start.go new file mode 100644 index 00000000..43cfb444 --- /dev/null +++ b/cmd/node/start.go @@ -0,0 +1,30 @@ +package node + +import ( + "github.com/algorandfoundation/algorun-tui/internal/algod" + "github.com/algorandfoundation/algorun-tui/ui/style" + "github.com/charmbracelet/log" + "github.com/spf13/cobra" +) + +var startCmd = &cobra.Command{ + Use: "start", + Short: "Start Algod", + Long: "Start Algod on your system (the one on your PATH).", + SilenceUsage: true, + PersistentPreRun: NeedsToBeStopped, + Run: func(cmd *cobra.Command, args []string) { + log.Info(style.Green.Render("Starting Algod 🚀")) + // Warn user for prompt + log.Warn(style.Yellow.Render(SudoWarningMsg)) + err := algod.Start() + if err != nil { + log.Fatal(err) + } + log.Info(style.Green.Render("Algorand started successfully 🎉")) + }, +} + +func init() { + startCmd.Flags().BoolVarP(&force, "force", "f", false, style.Yellow.Render("forcefully start the node")) +} diff --git a/cmd/node/stop.go b/cmd/node/stop.go new file mode 100644 index 00000000..878c3c37 --- /dev/null +++ b/cmd/node/stop.go @@ -0,0 +1,45 @@ +package node + +import ( + "fmt" + "github.com/algorandfoundation/algorun-tui/internal/algod" + "github.com/algorandfoundation/algorun-tui/ui/style" + "github.com/charmbracelet/log" + "time" + + "github.com/spf13/cobra" +) + +const StopTimeout = 5 * time.Second +const StopSuccessMsg = "Algod stopped successfully" +const StopFailureMsg = "failed to stop Algod" + +var stopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop Algod", + Long: "Stop the Algod process on your system.", + SilenceUsage: true, + PersistentPreRun: NeedsToBeRunning, + RunE: func(cmd *cobra.Command, args []string) error { + log.Info(style.Green.Render("Stopping Algod 😢")) + // Warn user for prompt + log.Warn(style.Yellow.Render(SudoWarningMsg)) + + err := algod.Stop() + if err != nil { + return fmt.Errorf(StopFailureMsg) + } + time.Sleep(StopTimeout) + + if algod.IsRunning() { + return fmt.Errorf(StopFailureMsg) + } + + log.Info(style.Green.Render("Algorand stopped successfully 🎉")) + return nil + }, +} + +func init() { + stopCmd.Flags().BoolVarP(&force, "force", "f", false, style.Yellow.Render("forcefully stop the node")) +} diff --git a/cmd/node/uninstall.go b/cmd/node/uninstall.go new file mode 100644 index 00000000..350c5ee3 --- /dev/null +++ b/cmd/node/uninstall.go @@ -0,0 +1,34 @@ +package node + +import ( + "github.com/algorandfoundation/algorun-tui/internal/algod" + "github.com/algorandfoundation/algorun-tui/ui/style" + "github.com/charmbracelet/log" + "github.com/spf13/cobra" +) + +const UninstallWarningMsg = "(You may be prompted for your password to uninstall)" + +var uninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "Uninstall Algorand node (Algod)", + Long: "Uninstall Algorand node (Algod) and other binaries on your system installed by this tool.", + SilenceUsage: true, + PersistentPreRun: NeedsToBeStopped, + Run: func(cmd *cobra.Command, args []string) { + if force { + log.Warn(style.Red.Render("Uninstalling Algorand (forcefully)")) + } + // Warn user for prompt + log.Warn(style.Yellow.Render(UninstallWarningMsg)) + + err := algod.Uninstall(force) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + uninstallCmd.Flags().BoolVarP(&force, "force", "f", false, style.Yellow.Render("forcefully uninstall the node")) +} diff --git a/cmd/node/upgrade.go b/cmd/node/upgrade.go new file mode 100644 index 00000000..b464f899 --- /dev/null +++ b/cmd/node/upgrade.go @@ -0,0 +1,42 @@ +package node + +import ( + "github.com/algorandfoundation/algorun-tui/internal/algod" + "github.com/algorandfoundation/algorun-tui/ui/style" + "github.com/charmbracelet/log" + "github.com/spf13/cobra" + "os" + "time" +) + +const UpgradeMsg = "Upgrading Algod" + +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade Algod", + Long: "Upgrade Algod (if installed with package manager).", + SilenceUsage: true, + PersistentPreRun: NeedsToBeStopped, + Run: func(cmd *cobra.Command, args []string) { + // TODO: get expected version and check if update is required + log.Info(style.Green.Render(UpgradeMsg + " vX.X.X")) + // Warn user for prompt + log.Warn(style.Yellow.Render(SudoWarningMsg)) + // TODO: Check Version from S3 against the local binary + err := algod.Update() + if err != nil { + log.Error(err) + } + + time.Sleep(5 * time.Second) + + // If it's not running, start the daemon (can happen) + if !algod.IsRunning() { + err = algod.Start() + if err != nil { + log.Error(err) + os.Exit(1) + } + } + }, +} diff --git a/cmd/root.go b/cmd/root.go index 3e84283d..2e03a8d9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "github.com/algorandfoundation/algorun-tui/api" + "github.com/algorandfoundation/algorun-tui/cmd/configure" + "github.com/algorandfoundation/algorun-tui/cmd/node" "github.com/algorandfoundation/algorun-tui/internal" "github.com/algorandfoundation/algorun-tui/ui" "github.com/algorandfoundation/algorun-tui/ui/explanations" @@ -17,18 +19,10 @@ import ( "github.com/spf13/viper" "io" "os" + "runtime" "strings" ) -const BANNER = ` - _____ .__ __________ - / _ \ | | ____ ____\______ \__ __ ____ - / /_\ \| | / ___\ / _ \| _/ | \/ \ -/ | \ |__/ /_/ > <_> ) | \ | / | \ -\____|__ /____/\___ / \____/|____|_ /____/|___| / - \/ /_____/ \/ \/ -` - var ( algod string token = strings.Repeat("a", 64) @@ -36,7 +30,7 @@ var ( rootCmd = &cobra.Command{ Use: "algorun", Short: "Manage Algorand nodes", - Long: style.Purple(BANNER) + "\n", + Long: style.Purple(style.BANNER) + "\n", CompletionOptions: cobra.CompletionOptions{ DisableDefaultCmd: true, }, @@ -145,15 +139,15 @@ func init() { rootCmd.Version = Version // Bindings - rootCmd.PersistentFlags().StringVarP(&algod, "algod-endpoint", "a", "", style.LightBlue("algod endpoint address URI, including http[s]")) - rootCmd.PersistentFlags().StringVarP(&token, "algod-token", "t", "", lipgloss.JoinHorizontal( + rootCmd.Flags().StringVarP(&algod, "algod-endpoint", "a", "", style.LightBlue("algod endpoint address URI, including http[s]")) + rootCmd.Flags().StringVarP(&token, "algod-token", "t", "", lipgloss.JoinHorizontal( lipgloss.Left, style.LightBlue("algod "), style.BoldUnderline("admin"), style.LightBlue(" token"), )) - _ = viper.BindPFlag("algod-endpoint", rootCmd.PersistentFlags().Lookup("algod-endpoint")) - _ = viper.BindPFlag("algod-token", rootCmd.PersistentFlags().Lookup("algod-token")) + _ = viper.BindPFlag("algod-endpoint", rootCmd.Flags().Lookup("algod-endpoint")) + _ = viper.BindPFlag("algod-token", rootCmd.Flags().Lookup("algod-token")) // Update Long Text rootCmd.Long += @@ -167,6 +161,10 @@ func init() { // Add Commands rootCmd.AddCommand(statusCmd) + if runtime.GOOS != "windows" { + rootCmd.AddCommand(node.Cmd) + rootCmd.AddCommand(configure.Cmd) + } } // Execute executes the root command. diff --git a/cmd/status.go b/cmd/status.go index 5ab65081..62bd010f 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -17,7 +17,7 @@ import ( var statusCmd = &cobra.Command{ Use: "status", Short: "Get the node status", - Long: style.Purple(BANNER) + "\n" + style.LightBlue("View the node status"), + Long: style.Purple(style.BANNER) + "\n" + style.LightBlue("View the node status"), RunE: func(cmd *cobra.Command, args []string) error { initConfig() if viper.GetString("algod-endpoint") == "" { diff --git a/cmd/status_test.go b/cmd/status_test.go index d6ff576d..87a36873 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -2,8 +2,9 @@ package cmd import ( "context" - "github.com/spf13/viper" "testing" + + "github.com/spf13/viper" ) func Test_ExecuteInvalidStatusCommand(t *testing.T) { diff --git a/codecov.yaml b/codecov.yaml index 2c7de746..eaf149d2 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -6,4 +6,4 @@ coverage: threshold: 10% patch: default: - target: 60% \ No newline at end of file + target: 10% \ No newline at end of file diff --git a/docker-compose.integration.yaml b/docker-compose.integration.yaml new file mode 100644 index 00000000..9a89539d --- /dev/null +++ b/docker-compose.integration.yaml @@ -0,0 +1,58 @@ +services: + # Legacy with apt disabled + ubuntu.18.04: + deploy: + replicas: 0 + privileged: true + environment: + - GOCOVERDIR=/app/coverage/int/ubuntu/18.04 + build: + context: . + target: bionic + dockerfile: .docker/Ubuntu.dockerfile + volumes: + - "./coverage/int/ubuntu/18.04:/app/coverage/int/ubuntu/18.04" + ubuntu.22.04: + deploy: + replicas: 0 + privileged: true + environment: + - GOCOVERDIR=/app/coverage/int/ubuntu/22.04 + build: + context: . + target: jammy + dockerfile: .docker/Ubuntu.dockerfile + volumes: + - "./coverage/int/ubuntu/22.04:/app/coverage/int/ubuntu/22.04" + ubuntu.24.04: + privileged: true + environment: + - GOCOVERDIR=/app/coverage/int/ubuntu/24.04 + build: + context: . + target: noble + dockerfile: .docker/Ubuntu.dockerfile + volumes: + - "./coverage/int/ubuntu/24.04:/app/coverage/int/ubuntu/24.04" + fedora.39: + deploy: + replicas: 0 + privileged: true + environment: + - GOCOVERDIR=/app/coverage/int/fedora/39 + build: + context: . + target: legacy + dockerfile: .docker/Fedora.dockerfile + volumes: + - "./coverage/int/fedora/39:/app/coverage/int/fedora/39" + fedora.40: + privileged: true + environment: + - GOCOVERDIR=/app/coverage/int/fedora/40 + build: + context: . + target: previous + dockerfile: .docker/Fedora.dockerfile + volumes: + - "./coverage/int/fedora/40:/app/coverage/int/fedora/40" \ No newline at end of file diff --git a/go.mod b/go.mod index a04b4a7c..7134e0ea 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/algod/algod.go b/internal/algod/algod.go new file mode 100644 index 00000000..34ad1537 --- /dev/null +++ b/internal/algod/algod.go @@ -0,0 +1,139 @@ +package algod + +import ( + "fmt" + "github.com/algorandfoundation/algorun-tui/internal/algod/linux" + "github.com/algorandfoundation/algorun-tui/internal/algod/mac" + "github.com/algorandfoundation/algorun-tui/internal/system" + "runtime" +) + +const UnsupportedOSError = "unsupported operating system" + +// IsInstalled checks if the Algod software is installed on the system +// by verifying its presence and service setup. +func IsInstalled() bool { + return system.CmdExists("algod") +} + +// IsRunning checks if the algod is currently running on the host operating system. +// It returns true if the application is running, or false if it is not or if an error occurs. +// This function supports Linux and macOS platforms. It returns an error for unsupported operating systems. +func IsRunning() bool { + switch runtime.GOOS { + case "linux", "darwin": + return system.IsCmdRunning("algod") + + default: + return false + } +} + +// IsService determines if the Algorand service is configured as +// a system service on the current operating system. +func IsService() bool { + switch runtime.GOOS { + case "linux": + return linux.IsService() + case "darwin": + return mac.IsService() + default: + return false + } +} + +// SetNetwork configures the network to the specified setting +// or returns an error on unsupported operating systems. +func SetNetwork(network string) error { + return fmt.Errorf(UnsupportedOSError) +} + +// Install installs Algorand software based on the host OS +// and returns an error if the installation fails or is unsupported. +func Install() error { + switch runtime.GOOS { + case "linux": + return linux.Install() + case "darwin": + return mac.Install() + default: + return fmt.Errorf(UnsupportedOSError) + } +} + +// Update checks the operating system and performs an +// upgrade using OS-specific package managers, if supported. +func Update() error { + switch runtime.GOOS { + case "linux": + return linux.Upgrade() + case "darwin": + return mac.Upgrade(false) + default: + return fmt.Errorf(UnsupportedOSError) + } +} + +// Uninstall removes the Algorand software from the system based +// on the host operating system using appropriate methods. +func Uninstall(force bool) error { + switch runtime.GOOS { + case "linux": + return linux.Uninstall() + case "darwin": + return mac.Uninstall(force) + default: + return fmt.Errorf(UnsupportedOSError) + } +} + +// UpdateService updates the service configuration for the +// Algorand daemon based on the OS and reloads the service. +func UpdateService(dataDirectoryPath string) error { + switch runtime.GOOS { + case "linux": + return linux.UpdateService(dataDirectoryPath) + case "darwin": + return mac.UpdateService(dataDirectoryPath) + default: + return fmt.Errorf(UnsupportedOSError) + } +} + +// EnsureService ensures the `algod` service is configured and running +// as a service based on the OS; +// Returns an error for unsupported systems. +func EnsureService() error { + switch runtime.GOOS { + case "darwin": + return mac.EnsureService() + default: + return fmt.Errorf(UnsupportedOSError) + } +} + +// Start attempts to initiate the Algod service based on the +// host operating system. Returns an error for unsupported OS. +func Start() error { + switch runtime.GOOS { + case "linux": + return linux.Start() + case "darwin": + return mac.Start(false) + default: // Unsupported OS + return fmt.Errorf(UnsupportedOSError) + } +} + +// Stop shuts down the Algorand algod system process based on the current operating system. +// Returns an error if the operation fails or the operating system is unsupported. +func Stop() error { + switch runtime.GOOS { + case "linux": + return linux.Stop() + case "darwin": + return mac.Stop(false) + default: + return fmt.Errorf(UnsupportedOSError) + } +} diff --git a/internal/algod/fallback/algod.go b/internal/algod/fallback/algod.go new file mode 100644 index 00000000..c83c4454 --- /dev/null +++ b/internal/algod/fallback/algod.go @@ -0,0 +1,90 @@ +package fallback + +import ( + "errors" + "fmt" + "github.com/algorandfoundation/algorun-tui/internal/algod/msgs" + "github.com/algorandfoundation/algorun-tui/internal/algod/utils" + "github.com/algorandfoundation/algorun-tui/internal/system" + "github.com/charmbracelet/log" + "os" + "os/exec" + "syscall" +) + +// Install executes a series of commands to set up the Algorand node and development tools on a Unix environment. +// TODO: Allow for changing of the paths +func Install() error { + return system.RunAll(system.CmdsList{ + {"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"}, + }) + +} + +func Start() error { + path, err := exec.LookPath("algod") + log.Debug("Starting algod", "path", path) + + // Check if ALGORAND_DATA environment variable is set + log.Info("Checking if ALGORAND_DATA env var is set...") + algorandData := os.Getenv("ALGORAND_DATA") + + if !utils.IsDataDir(algorandData) { + return errors.New(msgs.InvalidDataDirectory) + } + + log.Info("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 { + return fmt.Errorf("Failed to start algod: %v\n", err) + } + return nil +} + +func Stop() error { + log.Debug("Manually shutting down algod") + // Find the process ID of algod + pid, err := findAlgodPID() + if err != nil { + return err + } + + // Send SIGTERM to the process + process, err := os.FindProcess(pid) + if err != nil { + return err + } + + err = process.Signal(syscall.SIGTERM) + if err != nil { + return err + } + + return nil +} + +func findAlgodPID() (int, error) { + log.Debug("Scanning for algod process") + 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, err + } + + return pid, nil +} diff --git a/internal/algod/linux/linux.go b/internal/algod/linux/linux.go new file mode 100644 index 00000000..4820a42b --- /dev/null +++ b/internal/algod/linux/linux.go @@ -0,0 +1,220 @@ +package linux + +import ( + "bytes" + "fmt" + "github.com/algorandfoundation/algorun-tui/internal/algod/fallback" + "github.com/algorandfoundation/algorun-tui/internal/system" + "github.com/charmbracelet/log" + "os" + "os/exec" + "runtime" + "strings" + "text/template" +) + +const PackageManagerNotFoundMsg = "could not find a package manager to uninstall Algorand" + +type Algod struct { + system.Interface + Path string + DataDirectoryPath string +} + +// InstallRequirements generates installation commands for "sudo" based on the detected package manager and system state. +func InstallRequirements() system.CmdsList { + var cmds system.CmdsList + if (system.CmdExists("sudo") && system.CmdExists("prep")) || os.Geteuid() != 0 { + return cmds + } + if system.CmdExists("apt-get") { + return system.CmdsList{ + {"apt-get", "update"}, + {"apt-get", "install", "-y", "sudo", "procps"}, + } + } + + if system.CmdExists("dnf") { + return system.CmdsList{ + {"dnf", "install", "-y", "sudo", "procps-ng"}, + } + } + return cmds +} + +// Install installs Algorand development tools or node software depending on the package manager. +func Install() error { + log.Info("Installing Algod on Linux") + // Based off of https://developer.algorand.org/docs/run-a-node/setup/install/#installation-with-a-package-manager + if system.CmdExists("apt-get") { // On some Debian systems we use apt-get + log.Info("Installing with apt-get") + return system.RunAll(append(InstallRequirements(), system.CmdsList{ + {"sudo", "apt-get", "update"}, + {"sudo", "apt-get", "install", "-y", "gnupg2", "curl", "software-properties-common"}, + {"sh", "-c", "curl -o - https://releases.algorand.com/key.pub | sudo tee /etc/apt/trusted.gpg.d/algorand.asc"}, + {"sudo", "add-apt-repository", "-y", fmt.Sprintf("deb [arch=%s] https://releases.algorand.com/deb/ stable main", runtime.GOARCH)}, + {"sudo", "apt-get", "update"}, + {"sudo", "apt-get", "install", "-y", "algorand-devtools"}, + }...)) + } + + if system.CmdExists("dnf") { // On Fedora and CentOs8 there's the dnf package manager + log.Printf("Installing with dnf") + return system.RunAll(append(InstallRequirements(), system.CmdsList{ + {"curl", "-O", "https://releases.algorand.com/rpm/rpm_algorand.pub"}, + {"sudo", "rpmkeys", "--import", "rpm_algorand.pub"}, + {"sudo", "dnf", "install", "-y", "dnf-command(config-manager)"}, + {"sudo", "dnf", "config-manager", "--add-repo=https://releases.algorand.com/rpm/stable/algorand.repo"}, + {"sudo", "dnf", "install", "-y", "algorand-devtools"}, + {"sudo", "systemctl", "enable", "algorand.service"}, + {"sudo", "systemctl", "start", "algorand.service"}, + {"rm", "-f", "rpm_algorand.pub"}, + }...)) + + } + + // TODO: watch this method to see if it is ever used + return fallback.Install() +} + +// Uninstall removes the Algorand software using a supported package manager or clears related system files if necessary. +// Returns an error if a supported package manager is not found or if any command fails during execution. +func Uninstall() error { + log.Info("Uninstalling Algorand") + var unInstallCmds system.CmdsList + // On Ubuntu and Debian there's the apt package manager + if system.CmdExists("apt-get") { + log.Info("Using apt-get package manager") + unInstallCmds = [][]string{ + {"sudo", "apt-get", "autoremove", "algorand-devtools", "algorand", "-y"}, + } + } + // On Fedora and CentOs8 there's the dnf package manager + if system.CmdExists("dnf") { + log.Info("Using dnf package manager") + unInstallCmds = [][]string{ + {"sudo", "dnf", "remove", "algorand-devtools", "algorand", "-y"}, + } + } + // Error on unsupported package managers + if len(unInstallCmds) == 0 { + return fmt.Errorf(PackageManagerNotFoundMsg) + } + + // Commands to clear systemd algorand.service and any other files, like the configuration override + unInstallCmds = append(unInstallCmds, []string{"sudo", "bash", "-c", "rm -rf /etc/systemd/system/algorand*"}) + unInstallCmds = append(unInstallCmds, []string{"sudo", "systemctl", "daemon-reload"}) + + return system.RunAll(unInstallCmds) +} + +// Upgrade updates Algorand and its dev tools using an approved package +// manager if available, otherwise returns an error. +func Upgrade() error { + if system.CmdExists("apt-get") { + return system.RunAll(system.CmdsList{ + {"sudo", "apt-get", "update"}, + {"sudo", "apt-get", "install", "--only-upgrade", "-y", "algorand-devtools", "algorand"}, + }) + } + if system.CmdExists("dnf") { + return system.RunAll(system.CmdsList{ + {"sudo", "dnf", "update", "-y", "--refresh", "algorand-devtools", "algorand"}, + }) + } + return fmt.Errorf("the *node upgrade* command is currently only available for installations done with an approved package manager. Please use a different method to upgrade") +} + +// Start attempts to start the Algorand service using the system's service manager. +// It executes the appropriate command for systemd on Linux-based systems. +// Returns an error if the command fails. +// TODO: Replace with D-Bus integration +func Start() error { + return exec.Command("sudo", "systemctl", "start", "algorand").Run() +} + +// Stop shuts down the Algorand algod system process on Linux using the systemctl stop command. +// Returns an error if the operation fails. +// TODO: Replace with D-Bus integration +func Stop() error { + return exec.Command("sudo", "systemctl", "stop", "algorand").Run() +} + +// IsService checks if the "algorand.service" is listed as a systemd unit file on Linux. +// Returns true if it exists. +// TODO: Replace with D-Bus integration +func IsService() bool { + out, err := system.Run([]string{"sudo", "systemctl", "list-unit-files", "algorand.service"}) + if err != nil { + return false + } + return strings.Contains(out, "algorand.service") +} + +// UpdateService updates the systemd service file for the Algorand daemon +// with a new data directory path and reloads the daemon. +func UpdateService(dataDirectoryPath string) error { + + 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) + } + + log.Info("Algorand service file updated successfully.") + + return nil +} diff --git a/internal/algod/mac/mac.go b/internal/algod/mac/mac.go new file mode 100644 index 00000000..8afeeeb7 --- /dev/null +++ b/internal/algod/mac/mac.go @@ -0,0 +1,316 @@ +package mac + +import ( + "bytes" + "errors" + "fmt" + "github.com/algorandfoundation/algorun-tui/internal/algod/utils" + "github.com/algorandfoundation/algorun-tui/internal/system" + "github.com/charmbracelet/log" + "github.com/spf13/cobra" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" +) + +const MustBeServiceMsg = "service must be installed to be able to manage it" +const HomeBrewNotFoundMsg = "homebrew is not installed. please install Homebrew and try again" + +// IsService check if Algorand service has been created with launchd (macOS) +// Note that it needs to be run in super-user privilege mode to +// be able to view the root level services. +func IsService() bool { + _, err := system.Run([]string{"sudo", "launchctl", "list", "com.algorand.algod"}) + return err == nil +} + +// Install sets up Algod on macOS using Homebrew, +// configures necessary directories, and ensures it +// runs as a background service. +func Install() error { + log.Info("Installing Algod on macOS...") + + // Homebrew is our package manager of choice + if !system.CmdExists("brew") { + return errors.New(HomeBrewNotFoundMsg) + } + + err := system.RunAll(system.CmdsList{ + {"brew", "tap", "algorandfoundation/homebrew-node"}, + {"brew", "install", "algorand"}, + {"brew", "--prefix", "algorand", "--installed"}, + }) + if err != nil { + return err + } + + // Handle data directory and genesis.json file + err = handleDataDirMac() + if err != nil { + return err + } + + path, err := os.Executable() + if err != nil { + return err + } + + // Create and load the launchd service + // TODO: find a clever way to avoid this or make sudo persist for the second call + err = system.RunAll(system.CmdsList{{"sudo", path, "configure", "service"}}) + if err != nil { + return err + } + + if !IsService() { + return fmt.Errorf("algod is not a service") + } + + log.Info("Installed Algorand (Algod) with Homebrew ") + + return nil +} + +// Uninstall removes the Algorand application from the system using Homebrew if it is installed. +func Uninstall(force bool) error { + if force { + if system.IsCmdRunning("algod") { + err := Stop(force) + if err != nil { + return err + } + } + } + + cmds := system.CmdsList{} + if IsService() { + cmds = append(cmds, []string{"sudo", "launchctl", "unload", "/Library/LaunchDaemons/com.algorand.algod.plist"}) + } + + if !system.CmdExists("brew") && !force { + return errors.New("homebrew is not installed") + } else { + cmds = append(cmds, []string{"brew", "uninstall", "algorand"}) + } + + if force { + cmds = append(cmds, []string{"sudo", "rm", "-rf", strings.Join(utils.GetKnownDataPaths(), " ")}) + cmds = append(cmds, []string{"sudo", "rm", "-rf", "/Library/LaunchDaemons/com.algorand.algod.plist"}) + } + + return system.RunAll(cmds) +} + +// Upgrade updates the installed Algorand package using Homebrew if it's available and properly configured. +func Upgrade(force bool) error { + if !system.CmdExists("brew") { + return errors.New("homebrew is not installed") + } + + return system.RunAll(system.CmdsList{ + {"brew", "--prefix", "algorand", "--installed"}, + {"brew", "upgrade", "algorand", "--formula"}, + }) +} + +// Start algorand with launchd +func Start(force bool) error { + log.Debug("Attempting to start algorand with launchd") + //if !IsService() && !force { + // return fmt.Errorf(MustBeServiceMsg) + //} + return system.RunAll(system.CmdsList{ + {"sudo", "launchctl", "start", "com.algorand.algod"}, + }) +} + +// Stop shuts down the Algorand algod system process using the launchctl bootout command. +// Returns an error if the operation fails. +func Stop(force bool) error { + if !IsService() && !force { + return fmt.Errorf(MustBeServiceMsg) + } + + return system.RunAll(system.CmdsList{ + {"sudo", "launchctl", "stop", "com.algorand.algod"}, + }) +} + +// UpdateService updates the Algorand launchd service with +// a new data directory path and reloads the service configuration. +// TODO: Deduplicate this method, redundant version of EnsureService. +func UpdateService(dataDirectoryPath string) error { + + algodPath, err := exec.LookPath("algod") + if err != nil { + log.Info("Failed to find algod binary: %v\n", err) + os.Exit(1) + } + + overwriteFilePath := "/Library/LaunchDaemons/com.algorand.algod.plist" + + overwriteTemplate := ` + + + + Label + com.algorand.algod + ProgramArguments + + {{.AlgodPath}} + -d + {{.DataDirectoryPath}} + + RunAtLoad + + StandardOutPath + /tmp/algod.out + StandardErrorPath + /tmp/algod.err + + ` + + // Data to fill the template + data := map[string]string{ + "AlgodPath": algodPath, + "DataDirectoryPath": dataDirectoryPath, + } + + // Parse and execute the template + tmpl, err := template.New("override").Parse(overwriteTemplate) + if err != nil { + log.Info("Failed to parse template: %v\n", err) + os.Exit(1) + } + + var overwriteContent bytes.Buffer + err = tmpl.Execute(&overwriteContent, data) + if err != nil { + log.Info("Failed to execute template: %v\n", err) + os.Exit(1) + } + + // Write the override content to the file + err = os.WriteFile(overwriteFilePath, overwriteContent.Bytes(), 0644) + if err != nil { + log.Info("Failed to write override file: %v\n", err) + os.Exit(1) + } + + // Boot out the launchd service (just in case - it should be off) + cmd := exec.Command("launchctl", "bootout", "system", overwriteFilePath) + err = cmd.Run() + if err != nil { + if !strings.Contains(err.Error(), "No such process") { + log.Info("Failed to bootout launchd service: %v\n", err) + os.Exit(1) + } + } + + // Load the launchd service + cmd = exec.Command("launchctl", "load", overwriteFilePath) + err = cmd.Run() + if err != nil { + log.Info("Failed to load launchd service: %v\n", err) + os.Exit(1) + } + + fmt.Println("Launchd service updated and reloaded successfully.") + return nil +} + +// TODO move to configure as a generic +func handleDataDirMac() error { + // Ensure the ~/.algorand directory exists + algorandDir := filepath.Join(os.Getenv("HOME"), ".algorand") + if err := os.MkdirAll(algorandDir, 0755); err != nil { + return err + } + + // Check if genesis.json file exists in ~/.algorand + // TODO: replace with algocfg or goal templates + genesisFilePath := filepath.Join(os.Getenv("HOME"), ".algorand", "genesis.json") + _, err := os.Stat(genesisFilePath) + if !os.IsNotExist(err) { + return nil + } + + log.Info("Downloading mainnet genesis.json file to ~/.algorand/genesis.json") + + // Download the genesis.json file + resp, err := http.Get("https://raw.githubusercontent.com/algorand/go-algorand/db7f1627e4919b05aef5392504e48b93a90a0146/installer/genesis/mainnet/genesis.json") + if err != nil { + return err + } + defer resp.Body.Close() + + // Create the file + out, err := os.Create(genesisFilePath) + if err != nil { + return err + } + defer out.Close() + + // Write the content to the file + _, err = io.Copy(out, resp.Body) + if err != nil { + return err + } + + log.Info("mainnet genesis.json file downloaded successfully.") + return nil +} + +func EnsureService() error { + log.Debug("Ensuring Algorand service is running") + path, err := exec.LookPath("algod") + if err != nil { + log.Error("algod does not exist in path") + return err + } + // Define the launchd plist content + plistContent := fmt.Sprintf(` + + + + Label + com.algorand.algod + ProgramArguments + + %s + -d + %s/.algorand + + RunAtLoad + + Debug + + StandardOutPath + /tmp/algod.out + StandardErrorPath + /tmp/algod.err + +`, path, os.Getenv("HOME")) + + // Write the plist content to a file + plistPath := "/Library/LaunchDaemons/com.algorand.algod.plist" + err = os.MkdirAll(filepath.Dir(plistPath), 0755) + if err != nil { + log.Info("Failed to create LaunchDaemons directory: %v\n", err) + cobra.CheckErr(err) + } + + err = os.WriteFile(plistPath, []byte(plistContent), 0644) + if err != nil { + log.Info("Failed to write plist file: %v\n", err) + cobra.CheckErr(err) + } + return system.RunAll(system.CmdsList{ + {"launchctl", "load", plistPath}, + {"launchctl", "list", "com.algorand.algod"}, + }) +} diff --git a/internal/algod/msgs/errors.go b/internal/algod/msgs/errors.go new file mode 100644 index 00000000..d68b0c52 --- /dev/null +++ b/internal/algod/msgs/errors.go @@ -0,0 +1,5 @@ +package msgs + +const ( + InvalidDataDirectory = "algorand data directory is invalid" +) diff --git a/internal/algod/utils/utils.go b/internal/algod/utils/utils.go new file mode 100644 index 00000000..a32de05d --- /dev/null +++ b/internal/algod/utils/utils.go @@ -0,0 +1,69 @@ +package utils + +import ( + "github.com/algorandfoundation/algorun-tui/internal/system" + "github.com/spf13/cobra" + "os" + "path/filepath" +) + +func IsDataDir(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 := system.FindPathToFile(path, "algod.token") + if len(paths) == 1 { + return true + } + return false +} + +func GetKnownPaths() []string { + // Hardcoded paths known to be common Algorand data directories + binPaths := []string{ + "/opt/homebrew/bin/algod", + "/opt/homebrew/bin/algod", + } + + var paths []string + + for _, path := range binPaths { + if IsDataDir(path) { + paths = append(paths, path) + } + } + + return paths +} + +// GetKnownDataPaths Does a lazy check for Algorand data directories, based off of known common paths +func GetKnownDataPaths() []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 IsDataDir(path) { + paths = append(paths, path) + } + } + + return paths +} diff --git a/internal/system/cmds.go b/internal/system/cmds.go new file mode 100644 index 00000000..45b9ab48 --- /dev/null +++ b/internal/system/cmds.go @@ -0,0 +1,115 @@ +package system + +import ( + "fmt" + "github.com/algorandfoundation/algorun-tui/ui/style" + "github.com/charmbracelet/log" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" +) + +const CmdFailedErrorMsg = "command failed: %s output: %s error: %v" + +func IsSudo() bool { + return os.Geteuid() == 0 +} + +func IsCmdRunning(name string) bool { + err := exec.Command("pgrep", name).Run() + return err == nil +} + +// CmdExists checks that a bash cli/cmd tool exists +func CmdExists(tool string) bool { + _, err := exec.LookPath(tool) + return err == nil +} + +type CmdsList [][]string + +func (l CmdsList) Su(user string) CmdsList { + for i, args := range l { + if !strings.HasPrefix(args[0], "sudo") { + l[i] = append([]string{"sudo", "-u", user}, args...) + } + } + return l +} + +func Run(args []string) (string, error) { + cmd := exec.Command(args[0], args[1:]...) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func RunAll(list CmdsList) error { + // Run each installation command + for _, args := range list { + cmd := exec.Command(args[0], args[1:]...) + output, err := cmd.CombinedOutput() + if err != nil { + log.Error(fmt.Sprintf("%s: %s", style.Red.Render("Failed"), strings.Join(args, " "))) + return fmt.Errorf(CmdFailedErrorMsg, strings.Join(args, " "), output, err) + } + log.Debug(fmt.Sprintf("%s: %s", style.Green.Render("Running"), strings.Join(args, " "))) + } + return nil +} + +// FindPathToFile 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 msgs + if os.IsPermission(err) { + return nil + } + return err + } + fileChan <- path + return nil + }) + + close(fileChan) + wg.Wait() + + if err != nil { + panic(err) + } + + return dirPaths +} diff --git a/internal/system/service.go b/internal/system/service.go new file mode 100644 index 00000000..7a573f02 --- /dev/null +++ b/internal/system/service.go @@ -0,0 +1,16 @@ +package system + +type Interface interface { + IsInstalled() bool + IsRunning() bool + IsService() bool + SetNetwork(network string) error + Install() error + Update() error + Uninstall() error + Start() error + Stop() error + Restart() error + UpdateService(dataDirectoryPath string) error + EnsureService() error +} diff --git a/main.go b/main.go index f282015a..5dc55870 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,21 @@ package main import ( "github.com/algorandfoundation/algorun-tui/cmd" + "github.com/charmbracelet/log" + "os" ) +func init() { + // Log as JSON instead of the default ASCII formatter. + //log.SetFormatter(log.JSONFormatter) + + // Output to stdout instead of the default stderr + // Can be any io.Writer, see below for File example + log.SetOutput(os.Stdout) + + // Only log the warning severity or above. + log.SetLevel(log.DebugLevel) +} func main() { err := cmd.Execute() if err != nil { diff --git a/main_test.go b/main_test.go index 207c8325..ce07c8fb 100644 --- a/main_test.go +++ b/main_test.go @@ -1,8 +1,9 @@ package main import ( - "github.com/spf13/viper" "testing" + + "github.com/spf13/viper" ) func Test_Main(t *testing.T) { diff --git a/playbook.yaml b/playbook.yaml new file mode 100644 index 00000000..097634f7 --- /dev/null +++ b/playbook.yaml @@ -0,0 +1,22 @@ +- name: Test Instance + hosts: localhost + tasks: + - name: Ensure algorun exists + stat: + path: /usr/bin/algorun + register: binpath + - name: Fail missing binary + fail: + msg: "Must have algorun installed!" + when: not binpath.stat.exists + - name: Run installer + command: algorun node install + - name: Run stop + command: algorun node stop + - name: Run upgrade + command: algorun node upgrade + - name: Run stop + command: algorun node stop + - name: Run Start + command: algorun node start + # TODO: start a private network, fund TUI account and run TUI integration \ No newline at end of file diff --git a/ui/README.md b/ui/README.md index a50a9d1e..4d528a8f 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,13 +1,13 @@ # Overview -The ui package contains bubbletea interfaces. +The ui package contains bubbletea interfaces. ## Common practices A `style.go` file holds lipgloss predefined styles for the package. All components are instances of a `tea.Model` which is composed of models -from the `internal` package. +from the `internal` package. Components can either be single file or independent packages. Example for `status.go` single file component: @@ -26,12 +26,13 @@ func (m StatusViewModel) Int(){} //other tea.Model interfaces ... ``` -Once the component is sufficiently complex or needs to be reused, it can be moved +Once the component is sufficiently complex or needs to be reused, it can be moved to its own package Example refactor for `status.go` to a package: #### ui/status/model.go + ```go package status import "github.com/algorandfoundation/algorun-tui/internal" @@ -43,6 +44,7 @@ type ViewModel struct { ``` #### ui/status/controller.go + ```go package status @@ -84,4 +86,4 @@ package status import "github.com/charmbracelet/lipgloss" var someStyle = lipgloss.NewStyle() -``` \ No newline at end of file +``` diff --git a/ui/app/app_test.go b/ui/app/app_test.go index 311ffdb4..90ce2deb 100644 --- a/ui/app/app_test.go +++ b/ui/app/app_test.go @@ -46,7 +46,7 @@ func Test_EmitDeleteKey(t *testing.T) { t.Error("Expected ABC") } if evt.Err != nil { - t.Error("Expected no errors") + t.Error("Expected no msgs") } client = test.GetClient(true) @@ -60,7 +60,7 @@ func Test_EmitDeleteKey(t *testing.T) { t.Error("Expected no response") } if evt.Err == nil { - t.Error("Expected errors") + t.Error("Expected msgs") } } diff --git a/ui/style/style.go b/ui/style/style.go index 94c1089c..ef192ff4 100644 --- a/ui/style/style.go +++ b/ui/style/style.go @@ -178,3 +178,12 @@ func TruncateLeft(line string, padding int) string { return ansiStyle + strings.Join(wrapped[1:], "") } + +const BANNER = ` + _____ .__ __________ + / _ \ | | ____ ____\______ \__ __ ____ + / /_\ \| | / ___\ / _ \| _/ | \/ \ +/ | \ |__/ /_/ > <_> ) | \ | / | \ +\____|__ /____/\___ / \____/|____|_ /____/|___| / + \/ /_____/ \/ \/ +`