diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index d73f9d53..0198d070 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -19,6 +19,11 @@ import ( "github.com/spf13/cobra" ) +const CheckAlgodInterval = 10 * time.Second +const CheckAlgodTimeout = 2 * time.Minute + +var CatchpointLagThreshold int = 30_000 + // bootstrapCmdShort provides a brief description of the "bootstrap" command to initialize a fresh Algorand node. var bootstrapCmdShort = "Initialize a fresh node" @@ -42,6 +47,8 @@ This is the beginning of your adventure into running an Algorand node! ` +var FailedToAutoStartMessage = "Failed to start Algorand automatically." + // bootstrapCmd defines the "debug" command used to display diagnostic information for developers, including debug data. var bootstrapCmd = &cobra.Command{ Use: "bootstrap", @@ -49,6 +56,47 @@ var bootstrapCmd = &cobra.Command{ Long: bootstrapCmdLong, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + var client *api.ClientWithResponses + // Create the Bootstrap TUI + model := bootstrap.NewModel() + log.Warn(style.Yellow.Render(explanations.SudoWarningMsg)) + // Try to launch the TUI if it's already running and configured + if algod.IsInitialized() { + // Parse the data directory + dir, err := algod.GetDataDir("") + if err != nil { + log.Fatal(err) + } + + // Wait for the client to respond + log.Warn(style.Yellow.Render("Waiting for the node to start...")) + client, err = algod.WaitForClient(context.Background(), dir, CheckAlgodInterval, CheckAlgodTimeout) + if err != nil { + log.Fatal(err) + } + + // Fetch the latest status + var resp *api.GetStatusResponse + resp, err = client.GetStatusWithResponse(context.Background()) + // This should not happen, we waited for a status already + if err != nil { + log.Fatal(err) + } + if resp.StatusCode() != 200 { + log.Fatal(fmt.Sprintf("Failed to connect to the node at %s", dir)) + } + + // Execute the TUI if we are caught up. + // TODO: check the delta to see if it is necessary, + if resp.JSON200.CatchupTime == 0 { + err = runTUI(RootCmd, dir, false) + if err != nil { + log.Fatal(err) + } + return nil + } + } + // Exit the application in an invalid state if algod.IsInstalled() && !algod.IsService() { dataDir, _ := algod.GetDataDir("") @@ -60,19 +108,6 @@ var bootstrapCmd = &cobra.Command{ log.Fatal("invalid state, exiting") } - // Just launch the TUI if it's already running - if algod.IsInstalled() && algod.IsService() && algod.IsRunning() { - dir, err := algod.GetDataDir("") - if err != nil { - log.Fatal(err) - } - err = runTUI(RootCmd, dir, false) - if err != nil { - log.Fatal(err) - } - return nil - } - // Render the welcome text r, _ := glamour.NewTermRenderer( glamour.WithAutoStyle(), @@ -84,12 +119,25 @@ var bootstrapCmd = &cobra.Command{ } fmt.Println(out) - // Create the Bootstrap TUI - model := bootstrap.NewModel() + // Ensure it the service is started, + // in this case we won't be able to query state without the node running + if algod.IsInstalled() && algod.IsService() && !algod.IsRunning() { + log.Debug("Algorand is installed, but not running. Attempting to start it automatically.") + log.Warn(style.Yellow.Render(explanations.SudoWarningMsg)) + err := algod.Start() + if err != nil { + log.Error(FailedToAutoStartMessage) + log.Fatal(err) + } + + } + + // Prefill questions if algod.IsInstalled() { model.BootstrapMsg.Install = false model.Question = bootstrap.CatchupQuestion } + // Run the Bootstrap TUI p := tea.NewProgram(model) var msg *app.BootstrapMsg go func() { @@ -115,21 +163,31 @@ var bootstrapCmd = &cobra.Command{ if msg.Install { log.Warn(style.Yellow.Render(explanations.SudoWarningMsg)) + // Run the installer err := algod.Install() if err != nil { return err } - // Wait for algod - time.Sleep(10 * time.Second) + // Parse the data directory + dir, err := algod.GetDataDir("") + if err != nil { + log.Fatal(err) + } + + // Wait for the client to respond + client, err = algod.WaitForClient(context.Background(), dir, CheckAlgodInterval, CheckAlgodTimeout) + if err != nil { + log.Fatal(err) + } if !algod.IsRunning() { log.Fatal("algod is not running. Something went wrong with installation") } } else { + // This should not happen but just in case, ensure it is running if !algod.IsRunning() { log.Info(style.Green.Render("Starting Algod 🚀")) - log.Warn(style.Yellow.Render(explanations.SudoWarningMsg)) err := algod.Start() if err != nil { log.Fatal(err) @@ -141,20 +199,16 @@ var bootstrapCmd = &cobra.Command{ // Find the data directory automatically dataDir, err := algod.GetDataDir("") + // Wait for the client to respond + client, err = algod.WaitForClient(context.Background(), dataDir, CheckAlgodInterval, CheckAlgodTimeout) + if err != nil { + log.Fatal(err) + } // User answer for catchup question if msg.Catchup { ctx := context.Background() httpPkg := new(api.HttpPkg) - - if err != nil { - return err - } - // Create the client - client, err := algod.GetClient(dataDir) - if err != nil { - return err - } network, err := utils.GetNetworkFromDataDir(dataDir) if err != nil { return err @@ -167,8 +221,8 @@ var bootstrapCmd = &cobra.Command{ log.Info(style.Green.Render("Latest Catchpoint: " + catchpoint)) } - // Start catchup - res, _, err := algod.StartCatchup(ctx, client, catchpoint, nil) + // Start catchup with round threshold + res, _, err := algod.StartCatchup(ctx, client, catchpoint, &api.StartCatchupParams{Min: &CatchpointLagThreshold}) if err != nil { log.Fatal(err) } diff --git a/internal/algod/algod.go b/internal/algod/algod.go index 0acf7b39..0e6aef67 100644 --- a/internal/algod/algod.go +++ b/internal/algod/algod.go @@ -145,3 +145,8 @@ func Stop() error { return fmt.Errorf(UnsupportedOSError) } } + +// IsInitialized determines if the Algod software is installed, configured as a service, and currently running. +func IsInitialized() bool { + return IsInstalled() && IsService() && IsRunning() +} diff --git a/internal/algod/client.go b/internal/algod/client.go index aea44b78..4f2ffd63 100644 --- a/internal/algod/client.go +++ b/internal/algod/client.go @@ -1,6 +1,7 @@ package algod import ( + "context" "errors" "github.com/algorandfoundation/nodekit/api" "github.com/algorandfoundation/nodekit/internal/algod/utils" @@ -8,9 +9,11 @@ import ( "os" "path/filepath" "runtime" + "time" ) const InvalidDataDirMsg = "invalid data directory" +const ClientTimeoutMsg = "the client has timed out" func GetDataDir(dataDir string) (string, error) { envDataDir := os.Getenv("ALGORAND_DATA") @@ -57,3 +60,40 @@ func GetClient(dataDir string) (*api.ClientWithResponses, error) { } return api.NewClientWithResponses(config.Endpoint, api.WithRequestEditorFn(apiToken.Intercept)) } + +func WaitForClient(ctx context.Context, dataDir string, interval time.Duration, timeout time.Duration) (*api.ClientWithResponses, error) { + var client *api.ClientWithResponses + var err error + dataDir, err = GetDataDir(dataDir) + if err != nil { + return client, err + } + // Try to fetch the client before waiting + client, err = GetClient(dataDir) + if err == nil { + var resp api.ResponseInterface + resp, err = client.GetStatusWithResponse(ctx) + if err == nil && resp.StatusCode() == 200 { + return client, nil + } + } + // Wait for client to respond + for { + select { + case <-ctx.Done(): + return client, nil + case <-time.After(interval): + client, err = GetClient(dataDir) + if err == nil { + var resp api.ResponseInterface + resp, err = client.GetStatusWithResponse(ctx) + if err == nil && resp.StatusCode() == 200 { + return client, nil + } + } + case <-time.After(timeout): + return client, errors.New(ClientTimeoutMsg) + } + + } +} diff --git a/ui/bootstrap/model.go b/ui/bootstrap/model.go index c5577f94..fe2aa64b 100644 --- a/ui/bootstrap/model.go +++ b/ui/bootstrap/model.go @@ -2,9 +2,9 @@ package bootstrap import ( "github.com/algorandfoundation/nodekit/ui/app" - "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" + "github.com/charmbracelet/log" ) type Question string @@ -43,8 +43,17 @@ func NewModel() Model { }, } } + +var termMarkdown *glamour.TermRenderer + func (m Model) Init() tea.Cmd { - return textinput.Blink + var err error + termMarkdown, err = glamour.NewTermRenderer(glamour.WithAutoStyle()) + if err != nil { + log.Fatal(err) + } + + return nil } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd @@ -95,13 +104,6 @@ func (m Model) View() string { case CatchupQuestion: str = CatchupQuestionMsg } - var msg string - r, err := glamour.NewTermRenderer(glamour.WithAutoStyle()) - if err != nil { - // Fallback to dark mode - msg, _ = glamour.Render(str, "dark") - } else { - msg, _ = r.Render(str) - } - return msg + str, _ = termMarkdown.Render(str) + return str }