Skip to content

Commit b66b71f

Browse files
committed
Add versionUpdateCollector
1 parent a6d9a9c commit b66b71f

11 files changed

Lines changed: 259 additions & 38 deletions

File tree

rocketpool-cli/service/service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,12 @@ func printPatchNotes() {
126126
fmt.Println()
127127
}
128128

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

132132
// Prompt for confirmation
133133
if prompt.Declined(yes,
134-
"This will add the ability to display any available Operating System updates or new Rocket Pool versions on the metrics dashboard. "+
134+
"This will add the ability to display any available Operating System updates on the metrics dashboard. "+
135135
"Are you sure you want to install the update tracker?") {
136136
fmt.Println("Cancelled.")
137137
return nil
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package collectors
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
semver "github.com/blang/semver/v4"
13+
"github.com/prometheus/client_golang/prometheus"
14+
15+
"github.com/rocket-pool/smartnode/shared"
16+
)
17+
18+
const (
19+
githubLatestReleaseURL = "https://api.github.com/repos/rocket-pool/smartnode/releases/latest"
20+
versionCheckInterval = time.Hour
21+
versionCheckTimeout = 15 * time.Second
22+
)
23+
24+
// VersionUpdateCollector exposes whether a newer Smart Node release is available.
25+
type VersionUpdateCollector struct {
26+
versionUpdate *prometheus.Desc
27+
versionUpdateInfo *prometheus.Desc
28+
current string
29+
latestURL string
30+
client *http.Client
31+
logf func(string, ...interface{})
32+
33+
mu sync.Mutex
34+
updateAvailable float64
35+
latestVersion string
36+
lastChecked time.Time
37+
}
38+
39+
type githubReleaseResponse struct {
40+
TagName string `json:"tag_name"`
41+
}
42+
43+
// NewVersionUpdateCollector creates a collector backed by an hourly GitHub release check.
44+
func NewVersionUpdateCollector(logf func(string, ...interface{})) *VersionUpdateCollector {
45+
return &VersionUpdateCollector{
46+
versionUpdate: prometheus.NewDesc(prometheus.BuildFQName(namespace, "", "version_update"),
47+
"New Rocket Pool version available",
48+
nil, nil,
49+
),
50+
versionUpdateInfo: prometheus.NewDesc(prometheus.BuildFQName(namespace, "", "version_update_info"),
51+
"The latest available Rocket Pool version",
52+
[]string{"version"}, nil,
53+
),
54+
current: shared.RocketPoolVersion(),
55+
latestURL: githubLatestReleaseURL,
56+
client: &http.Client{
57+
Timeout: versionCheckTimeout,
58+
},
59+
logf: logf,
60+
}
61+
}
62+
63+
// Describe writes metric descriptions to the Prometheus channel.
64+
func (collector *VersionUpdateCollector) Describe(channel chan<- *prometheus.Desc) {
65+
channel <- collector.versionUpdate
66+
channel <- collector.versionUpdateInfo
67+
}
68+
69+
// Collect emits the latest cached version update status.
70+
func (collector *VersionUpdateCollector) Collect(channel chan<- prometheus.Metric) {
71+
collector.checkIfDue(context.Background())
72+
73+
collector.mu.Lock()
74+
updateAvailable := collector.updateAvailable
75+
latestVersion := collector.latestVersion
76+
collector.mu.Unlock()
77+
78+
channel <- prometheus.MustNewConstMetric(
79+
collector.versionUpdate, prometheus.GaugeValue, updateAvailable)
80+
if latestVersion != "" {
81+
channel <- prometheus.MustNewConstMetric(
82+
collector.versionUpdateInfo, prometheus.GaugeValue, 1, latestVersion)
83+
}
84+
}
85+
86+
func (collector *VersionUpdateCollector) checkIfDue(ctx context.Context) {
87+
collector.mu.Lock()
88+
defer collector.mu.Unlock()
89+
90+
if time.Since(collector.lastChecked) < versionCheckInterval {
91+
return
92+
}
93+
collector.lastChecked = time.Now()
94+
95+
updateAvailable, latestVersion, err := collector.checkForUpdate(ctx)
96+
if err != nil {
97+
if collector.logf != nil {
98+
collector.logf("Error checking latest Rocket Pool release: %v", err)
99+
}
100+
return
101+
}
102+
103+
if updateAvailable {
104+
collector.updateAvailable = 1
105+
} else {
106+
collector.updateAvailable = 0
107+
}
108+
collector.latestVersion = latestVersion
109+
}
110+
111+
func (collector *VersionUpdateCollector) checkForUpdate(ctx context.Context) (bool, string, error) {
112+
latest, err := collector.getLatestVersion(ctx)
113+
if err != nil {
114+
return false, "", err
115+
}
116+
117+
updateAvailable, err := isNewerVersion(collector.current, latest)
118+
if err != nil {
119+
return false, "", err
120+
}
121+
122+
return updateAvailable, latest, nil
123+
}
124+
125+
func (collector *VersionUpdateCollector) getLatestVersion(ctx context.Context) (string, error) {
126+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, collector.latestURL, nil)
127+
if err != nil {
128+
return "", fmt.Errorf("error creating GitHub release request: %w", err)
129+
}
130+
req.Header.Set("Accept", "application/vnd.github+json")
131+
req.Header.Set("User-Agent", "rocketpool-smartnode")
132+
133+
resp, err := collector.client.Do(req)
134+
if err != nil {
135+
return "", fmt.Errorf("error fetching latest GitHub release: %w", err)
136+
}
137+
defer resp.Body.Close()
138+
139+
if resp.StatusCode != http.StatusOK {
140+
return "", fmt.Errorf("GitHub release request returned status %s", resp.Status)
141+
}
142+
143+
var release githubReleaseResponse
144+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
145+
return "", fmt.Errorf("error decoding latest GitHub release: %w", err)
146+
}
147+
if strings.TrimSpace(release.TagName) == "" {
148+
return "", fmt.Errorf("latest GitHub release did not include a tag_name")
149+
}
150+
151+
return release.TagName, nil
152+
}
153+
154+
func isNewerVersion(currentVersion string, latestVersion string) (bool, error) {
155+
current, err := semver.ParseTolerant(strings.TrimSpace(currentVersion))
156+
if err != nil {
157+
return false, fmt.Errorf("error parsing current version %q: %w", currentVersion, err)
158+
}
159+
160+
latest, err := semver.ParseTolerant(strings.TrimSpace(latestVersion))
161+
if err != nil {
162+
return false, fmt.Errorf("error parsing latest version %q: %w", latestVersion, err)
163+
}
164+
165+
return latest.Compare(current) > 0, nil
166+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package collectors
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
)
9+
10+
func TestIsNewerVersion(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
current string
14+
latest string
15+
want bool
16+
}{
17+
{
18+
name: "latest version is newer",
19+
current: "1.20.2",
20+
latest: "v1.20.3",
21+
want: true,
22+
},
23+
{
24+
name: "same version is not newer",
25+
current: "1.20.2",
26+
latest: "v1.20.2",
27+
want: false,
28+
},
29+
{
30+
name: "older latest version is not newer",
31+
current: "1.20.2",
32+
latest: "v1.20.1",
33+
want: false,
34+
},
35+
{
36+
name: "dev version is newer than latest",
37+
current: "v1.20.3-dev",
38+
latest: "v1.20.2",
39+
want: false,
40+
},
41+
}
42+
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
got, err := isNewerVersion(tt.current, tt.latest)
46+
if err != nil {
47+
t.Fatalf("isNewerVersion returned error: %v", err)
48+
}
49+
if got != tt.want {
50+
t.Fatalf("isNewerVersion(%q, %q) = %t, want %t", tt.current, tt.latest, got, tt.want)
51+
}
52+
})
53+
}
54+
}
55+
56+
func TestCheckIfDueCachesLatestVersion(t *testing.T) {
57+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+
_, err := w.Write([]byte(`{"tag_name":"v1.20.3"}`))
59+
if err != nil {
60+
t.Fatalf("error writing response: %v", err)
61+
}
62+
}))
63+
defer server.Close()
64+
65+
collector := NewVersionUpdateCollector(nil)
66+
collector.current = "1.20.2"
67+
collector.latestURL = server.URL
68+
collector.client = server.Client()
69+
70+
collector.checkIfDue(context.Background())
71+
72+
if collector.updateAvailable != 1 {
73+
t.Fatalf("updateAvailable = %f, want 1", collector.updateAvailable)
74+
}
75+
if collector.latestVersion != "v1.20.3" {
76+
t.Fatalf("latestVersion = %q, want %q", collector.latestVersion, "v1.20.3")
77+
}
78+
}

rocketpool/node/metrics-exporter.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ func runMetricsServer(ctx context.Context, c *cli.Command, logger log.ColorLogge
7171
beaconCollector := collectors.NewBeaconCollector(rp, bc, ec, nodeAccount.Address, stateLocker)
7272
smoothingPoolCollector := collectors.NewSmoothingPoolCollector(rp, ec, stateLocker)
7373
governanceCollector := collectors.NewGovernanceCollector(rp)
74+
versionUpdateCollector := collectors.NewVersionUpdateCollector(logger.Printlnf)
7475

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

8890
// Set up snapshot checking if enabled
8991
if cfg.Smartnode.GetRocketSignerRegistryAddress() != "" {
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
APT::Update::Post-Invoke-Success {
22
"/usr/share/apt-metrics.sh | sponge /var/lib/node_exporter/textfile_collector/apt.prom || true";
3-
"/usr/share/rp-version-check.sh | sponge /var/lib/node_exporter/textfile_collector/rp.prom || true";
43
};
54

65
DPkg::Post-Invoke {
76
"/usr/share/apt-metrics.sh | sponge /var/lib/node_exporter/textfile_collector/apt.prom || true";
8-
"/usr/share/rp-version-check.sh | sponge /var/lib/node_exporter/textfile_collector/rp.prom || true";
97
};
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
#!/bin/sh
22

3-
/usr/share/dnf-metrics.sh | sponge /var/lib/node_exporter/textfile_collector/dnf.prom || true
4-
/usr/share/rp-version-check.sh | sponge /var/lib/node_exporter/textfile_collector/rp.prom || true
3+
/usr/share/dnf-metrics.sh | sponge /var/lib/node_exporter/textfile_collector/dnf.prom || true

shared/services/rocketpool/assets/rp-update-tracker/dnf/rp-update-tracker.service

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[Unit]
2-
Description=Checks for system and Rocket Pool updates periodically
2+
Description=Checks for system updates periodically
33
Wants=rp-update-tracker.timer
44

55
[Service]

shared/services/rocketpool/assets/rp-update-tracker/rp-version-check.sh

Lines changed: 0 additions & 17 deletions
This file was deleted.

shared/services/rocketpool/assets/rp-update-tracker/yum/rp-update-tracker.service

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[Unit]
2-
Description=Checks for system and Rocket Pool updates periodically
2+
Description=Checks for system updates periodically
33
Wants=rp-update-tracker.timer
44

55
[Service]
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
#!/bin/sh
22

3-
/usr/share/yum-metrics.sh | sponge /var/lib/node_exporter/textfile_collector/yum.prom || true
4-
/usr/share/rp-version-check.sh | sponge /var/lib/node_exporter/textfile_collector/rp.prom || true
3+
/usr/share/yum-metrics.sh | sponge /var/lib/node_exporter/textfile_collector/yum.prom || true

0 commit comments

Comments
 (0)