Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions rocketpool-cli/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,12 @@ func printPatchNotes() {
fmt.Println()
}

// Install the Rocket Pool update tracker for the metrics dashboard
// Install the OS update tracker for the metrics dashboard
func installUpdateTracker(yes, verbose bool) error {

// Prompt for confirmation
if prompt.Declined(yes,
"This will add the ability to display any available Operating System updates or new Rocket Pool versions on the metrics dashboard. "+
"This will add the ability to display any available Operating System updates on the metrics dashboard. "+
"Are you sure you want to install the update tracker?") {
fmt.Println("Cancelled.")
return nil
Expand Down
170 changes: 170 additions & 0 deletions rocketpool/node/collectors/version-update-collector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package collectors

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"

semver "github.com/blang/semver/v4"
"github.com/prometheus/client_golang/prometheus"

"github.com/rocket-pool/smartnode/shared"
)

const (
githubLatestReleaseURL = "https://api.github.com/repos/rocket-pool/smartnode/releases/latest"
versionCheckInterval = time.Hour
versionCheckTimeout = 15 * time.Second
)

// VersionUpdateCollector exposes whether a newer Smart Node release is available.
type VersionUpdateCollector struct {
versionUpdate *prometheus.Desc
versionUpdateInfo *prometheus.Desc
current string
latestURL string
client *http.Client
logf func(string, ...interface{})

mu sync.Mutex
updateAvailable float64
latestVersion string
lastChecked time.Time
}

type githubReleaseResponse struct {
TagName string `json:"tag_name"`
}

// NewVersionUpdateCollector creates a collector backed by an hourly GitHub release check.
func NewVersionUpdateCollector(logf func(string, ...interface{})) *VersionUpdateCollector {
return &VersionUpdateCollector{
versionUpdate: prometheus.NewDesc(prometheus.BuildFQName(namespace, "", "version_update"),
"New Rocket Pool version available",
nil, nil,
),
versionUpdateInfo: prometheus.NewDesc(prometheus.BuildFQName(namespace, "", "version_update_info"),
"The latest available Rocket Pool version",
[]string{"version"}, nil,
),
current: shared.RocketPoolVersion(),
latestURL: githubLatestReleaseURL,
client: &http.Client{
Timeout: versionCheckTimeout,
},
logf: logf,
}
}

// Describe writes metric descriptions to the Prometheus channel.
func (collector *VersionUpdateCollector) Describe(channel chan<- *prometheus.Desc) {
channel <- collector.versionUpdate
channel <- collector.versionUpdateInfo
}

// Collect emits the latest cached version update status.
func (collector *VersionUpdateCollector) Collect(channel chan<- prometheus.Metric) {
collector.checkIfDue(context.Background())

collector.mu.Lock()
updateAvailable := collector.updateAvailable
latestVersion := collector.latestVersion
collector.mu.Unlock()

channel <- prometheus.MustNewConstMetric(
collector.versionUpdate, prometheus.GaugeValue, updateAvailable)
if latestVersion != "" {
channel <- prometheus.MustNewConstMetric(
collector.versionUpdateInfo, prometheus.GaugeValue, 1, latestVersion)
}
}

func (collector *VersionUpdateCollector) checkIfDue(ctx context.Context) {
collector.mu.Lock()
defer collector.mu.Unlock()

if time.Since(collector.lastChecked) < versionCheckInterval {
return
}
collector.lastChecked = time.Now()

updateAvailable, latestVersion, err := collector.checkForUpdate(ctx)
if err != nil {
if collector.logf != nil {
collector.logf("Error checking latest Rocket Pool release: %v", err)
}
return
}

if updateAvailable {
collector.updateAvailable = 1
} else {
collector.updateAvailable = 0
}
collector.latestVersion = latestVersion
}

func (collector *VersionUpdateCollector) checkForUpdate(ctx context.Context) (bool, string, error) {
latest, err := collector.getLatestVersion(ctx)
if err != nil {
return false, "", err
}

updateAvailable, err := isNewerVersion(collector.current, latest)
if err != nil {
return false, "", err
}

return updateAvailable, latest, nil
}

func (collector *VersionUpdateCollector) getLatestVersion(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, collector.latestURL, nil)
if err != nil {
return "", fmt.Errorf("error creating GitHub release request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "rocketpool-smartnode")

resp, err := collector.client.Do(req)
if err != nil {
return "", fmt.Errorf("error fetching latest GitHub release: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
collector.logf("Error closing GitHub release response body: %v", err)
}
}()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub release request returned status %s", resp.Status)
}

var release githubReleaseResponse
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("error decoding latest GitHub release: %w", err)
}
if strings.TrimSpace(release.TagName) == "" {
return "", fmt.Errorf("latest GitHub release did not include a tag_name")
}

return release.TagName, nil
}

func isNewerVersion(currentVersion string, latestVersion string) (bool, error) {
current, err := semver.ParseTolerant(strings.TrimSpace(currentVersion))
if err != nil {
return false, fmt.Errorf("error parsing current version %q: %w", currentVersion, err)
}

latest, err := semver.ParseTolerant(strings.TrimSpace(latestVersion))
if err != nil {
return false, fmt.Errorf("error parsing latest version %q: %w", latestVersion, err)
}

return latest.Compare(current) > 0, nil
}
78 changes: 78 additions & 0 deletions rocketpool/node/collectors/version-update-collector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package collectors

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestIsNewerVersion(t *testing.T) {
tests := []struct {
name string
current string
latest string
want bool
}{
{
name: "latest version is newer",
current: "1.20.2",
latest: "v1.20.3",
want: true,
},
{
name: "same version is not newer",
current: "1.20.2",
latest: "v1.20.2",
want: false,
},
{
name: "older latest version is not newer",
current: "1.20.2",
latest: "v1.20.1",
want: false,
},
{
name: "dev version is newer than latest",
current: "v1.20.3-dev",
latest: "v1.20.2",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := isNewerVersion(tt.current, tt.latest)
if err != nil {
t.Fatalf("isNewerVersion returned error: %v", err)
}
if got != tt.want {
t.Fatalf("isNewerVersion(%q, %q) = %t, want %t", tt.current, tt.latest, got, tt.want)
}
})
}
}

func TestCheckIfDueCachesLatestVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte(`{"tag_name":"v1.20.3"}`))
if err != nil {
t.Fatalf("error writing response: %v", err)
}
}))
defer server.Close()

collector := NewVersionUpdateCollector(nil)
collector.current = "1.20.2"
collector.latestURL = server.URL
collector.client = server.Client()

collector.checkIfDue(context.Background())

if collector.updateAvailable != 1 {
t.Fatalf("updateAvailable = %f, want 1", collector.updateAvailable)
}
if collector.latestVersion != "v1.20.3" {
t.Fatalf("latestVersion = %q, want %q", collector.latestVersion, "v1.20.3")
}
}
2 changes: 2 additions & 0 deletions rocketpool/node/metrics-exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func runMetricsServer(ctx context.Context, c *cli.Command, logger log.ColorLogge
beaconCollector := collectors.NewBeaconCollector(rp, bc, ec, nodeAccount.Address, stateLocker)
smoothingPoolCollector := collectors.NewSmoothingPoolCollector(rp, ec, stateLocker)
governanceCollector := collectors.NewGovernanceCollector(rp)
versionUpdateCollector := collectors.NewVersionUpdateCollector(logger.Printlnf)

// Set up Prometheus
registry := prometheus.NewRegistry()
Expand All @@ -84,6 +85,7 @@ func runMetricsServer(ctx context.Context, c *cli.Command, logger log.ColorLogge
registry.MustRegister(beaconCollector)
registry.MustRegister(smoothingPoolCollector)
registry.MustRegister(governanceCollector)
registry.MustRegister(versionUpdateCollector)

// Set up snapshot checking if enabled
if cfg.Smartnode.GetRocketSignerRegistryAddress() != "" {
Expand Down
Loading
Loading