-
Notifications
You must be signed in to change notification settings - Fork 112
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
base: master
Are you sure you want to change the base?
Changes from 3 commits
bc4412f
7b65b64
49a337c
2ec9a9c
21ba27b
f11c68c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
} | ||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
// Check the latest version published to the Github repository | ||
client := getHttpClientWithTimeout() | ||
resp, err := client.Get(GithubAPIGetLatest) | ||
if err != nil { | ||
return err | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
So the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
||
}, | ||
}, | ||
}, | ||
}) | ||
} |
There was a problem hiding this comment.
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.