Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for CLI update command #311

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ The following commands are available via the Smartnode client:
- `rocketpool service resync-eth1` - Deletes the main ETH1 client's chain data and resyncs it from scratch. Only use this as a last resort!
- `rocketpool service resync-eth2` - Deletes the ETH2 client's chain data and resyncs it from scratch. Only use this as a last resort!
- `rocketpool service terminate, t` - Deletes all of the Rocket Pool Docker containers and volumes, including your ETH1 and ETH2 chain data and your Prometheus database (if metrics are enabled). Only use this if you are cleaning up the Smartnode and want to start over!
- **update**, u - Update Rocket Pool
- `rocketpool update cli, c` - Update the Rocket Pool Client (CLI)
- **wallet**, w - Manage the node wallet
- `rocketpool wallet status, s` - Get the node wallet status
- `rocketpool wallet init, i` - Initialize the node wallet
Expand Down
2 changes: 2 additions & 0 deletions rocketpool-cli/rocketpool-cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/rocket-pool/smartnode/rocketpool-cli/odao"
"github.com/rocket-pool/smartnode/rocketpool-cli/queue"
"github.com/rocket-pool/smartnode/rocketpool-cli/service"
"github.com/rocket-pool/smartnode/rocketpool-cli/update"
"github.com/rocket-pool/smartnode/rocketpool-cli/wallet"
"github.com/rocket-pool/smartnode/shared"
"github.com/rocket-pool/smartnode/shared/services/rocketpool"
Expand Down Expand Up @@ -148,6 +149,7 @@ ______ _ _ ______ _
odao.RegisterCommands(app, "odao", []string{"o"})
queue.RegisterCommands(app, "queue", []string{"q"})
service.RegisterCommands(app, "service", []string{"s"})
update.RegisterCommands(app, "update", []string{"u"})
wallet.RegisterCommands(app, "wallet", []string{"w"})

app.Before = func(c *cli.Context) error {
Expand Down
222 changes: 222 additions & 0 deletions rocketpool-cli/update/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package update

import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"

"github.com/blang/semver/v4"
"github.com/urfave/cli"
"golang.org/x/crypto/openpgp"

"github.com/rocket-pool/smartnode/shared"
cliutils "github.com/rocket-pool/smartnode/shared/utils/cli"
)

// Settings
const (
GithubAPIGetLatest string = "https://api.github.com/repos/rocket-pool/smartnode-install/releases/latest"
SigningKeyURL string = "https://github.com/rocket-pool/smartnode-install/releases/download/v%s/smartnode-signing-key-v3.asc"
ReleaseBinaryURL string = "https://github.com/rocket-pool/smartnode-install/releases/download/v%s/rocketpool-cli-%s-%s"
)

func getHttpClientWithTimeout() *http.Client {
return &http.Client{
Timeout: time.Second * 5,
}
}

func checkSignature(signatureUrl string, pubkeyUrl string, verification_target *os.File) error {
pubkeyResponse, err := http.Get(pubkeyUrl)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some error context.

}
defer pubkeyResponse.Body.Close()
if pubkeyResponse.StatusCode != http.StatusOK {
return fmt.Errorf("public key request failed with code %d", pubkeyResponse.StatusCode)
}
keyring, err := openpgp.ReadArmoredKeyRing(pubkeyResponse.Body)
if err != nil {
return fmt.Errorf("error while reading public key: %w", err)
}

signatureResponse, err := http.Get(signatureUrl)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some error context.

}
defer signatureResponse.Body.Close()
if signatureResponse.StatusCode != http.StatusOK {
return fmt.Errorf("signature request failed with code %d", signatureResponse.StatusCode)
}

entity, err := openpgp.CheckDetachedSignature(keyring, verification_target, signatureResponse.Body)
if err != nil {
return fmt.Errorf("error while verifying signature: %w", err)
}

for _, v := range entity.Identities {
fmt.Printf("Signature verified. Signed by: %s\n", v.Name)
}
return nil
}

// Update the Rocket Pool CLI
func updateCLI(c *cli.Context) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see a few main sections to this function. I think it would be easier to see the main steps if they are split into their own functions. It would also help add context to the errors. I see the same error message repeated/similar to another one. What do you think?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that's a fair comment. I've split out the initial version check, and the download/verify sections into their own functions, and I think updateCLI is pretty readable now :)

// Check the latest version published to the Github repository
client := getHttpClientWithTimeout()
resp, err := client.Get(GithubAPIGetLatest)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some error context.

}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("request failed with code %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some error context.

}
var apiResponse map[string]interface{}
if err := json.Unmarshal(body, &apiResponse); err != nil {
return fmt.Errorf("could not decode Github API response: %w", err)
}
var latestVersion semver.Version
if x, found := apiResponse["url"]; found {
var name string
var ok bool
if name, ok = x.(string); !ok {
return fmt.Errorf("unexpected Github API response format")
}
latestVersion, err = semver.Make(strings.TrimLeft(name, "v"))
if err != nil {
return fmt.Errorf("could not parse version number from release name '%s': %w", name, err)
}
} else {
return fmt.Errorf("unexpected Github API response format")
}

// Check this version against the currently installed version
if !c.Bool("force") {
currentVersion, err := semver.Make(shared.RocketPoolVersion)
if err != nil {
return fmt.Errorf("could not parse local Rocket Pool version number '%s': %w", shared.RocketPoolVersion, err)
}
switch latestVersion.Compare(currentVersion) {
case 1:
fmt.Printf("Newer version avilable online (v%s). Downloading...\n", latestVersion.String())
case 0:
fmt.Printf("Already on latest version (v%s). Aborting update\n", latestVersion.String())
return nil
default:
fmt.Printf("Online version (v%s) is lower than running version (v%s). Aborting update\n", latestVersion.String(), currentVersion.String())
return nil
}
} else {
fmt.Printf("Forced update to v%s. Downloading...\n", latestVersion.String())
}

// Download the new binary to same folder as the running RP binary, as `rocketpool-vX.X.X`
var ClientURL = fmt.Sprintf(ReleaseBinaryURL, latestVersion.String(), runtime.GOOS, runtime.GOARCH)
resp, err = http.Get(ClientURL)
if err != nil {
return fmt.Errorf("error while downloading %s: %w", ClientURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("request failed with code %d", resp.StatusCode)
}

ex, err := os.Executable()
if err != nil {
return fmt.Errorf("error while determining running rocketpool location: %w", err)
}
var rpBinDir = filepath.Dir(ex)
var fileName = filepath.Join(rpBinDir, "rocketpool-v"+latestVersion.String())
output, err := os.OpenFile(fileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("error while creating %s: %w", fileName, err)
}
defer output.Close()

_, err = io.Copy(output, resp.Body)
if err != nil {
return fmt.Errorf("error while downloading %s: %w", ClientURL, err)
}

// Verify the signature of the downloaded binary
if !c.Bool("skip-signature-verification") {
var pubkeyUrl = fmt.Sprintf(SigningKeyURL, latestVersion.String())
output.Seek(0, io.SeekStart)
err = checkSignature(ClientURL+".sig", pubkeyUrl, output)
if err != nil {
return fmt.Errorf("error while verifying GPG signature: %w", err)
}
}

// Prompt for confirmation
if !(c.Bool("yes") || cliutils.Confirm("Are you sure you want to update? Current Rocketpool Client will be replaced.")) {
fmt.Println("Cancelled.")
return nil
}

// Do the switcheroo - move `rocketpool-vX.X.X` to the location of the current Rocketpool Client
err = os.Remove(ex)
if err != nil {
return fmt.Errorf("error while removing old rocketpool binary: %w", err)
}
err = os.Rename(fileName, ex)
if err != nil {
return fmt.Errorf("error while writing new rocketpool binary: %w", err)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.Rename should replace the original file according to the docs: https://pkg.go.dev/os#Rename

So the os.Remove isn't needed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @SN9NV . I based this behaviour on this Stackoverflow question, but I just tested and it does appear to work without the unlink first.

Copy link
Contributor

@angaz angaz Feb 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When replacing the binary, the key is the the inode is different to the original executable. We have a new file created during the download process. So this is pretty safe. If we were using something else like truncate/copy, then we might still have the original inode, which can be problematic for the OS to deal with.


fmt.Printf("Updated Rocketpool Client to v%s. Please run `rocketpool service install` to finish the installation and update your smartstack.\n", latestVersion.String())
return nil
}

// Register commands
func RegisterCommands(app *cli.App, name string, aliases []string) {

app.Commands = append(app.Commands, cli.Command{
Name: name,
Aliases: aliases,
Subcommands: []cli.Command{
{
Name: "cli",
Aliases: []string{"c"},
Usage: "Update the Rocket Pool CLI",
UsageText: "rocketpool update cli [options]",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",
Usage: "Force update, even if same version or lower",
},
cli.BoolFlag{
Name: "skip-signature-verification, s",
Usage: "Skip signature verification",
},
cli.BoolFlag{
Name: "yes, y",
Usage: "Automatically confirm update",
},
},
Action: func(c *cli.Context) error {

// Validate args
if err := cliutils.ValidateArgCount(c, 0); err != nil {
return err
}

// Run command
return updateCLI(c)

},
},
},
})
}