Skip to content

Commit 4bb8350

Browse files
authored
Merge pull request #183 from ethpandaops/refactor/enhance-c-status-cmd
feat(status): add beacon node health and sync status to CLI status cmd
2 parents 91e908e + c24e918 commit 4bb8350

File tree

5 files changed

+604
-1
lines changed

5 files changed

+604
-1
lines changed

.github/typos.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[files]
2+
extend-exclude = ["internal/service/beacon_test.go"]

.github/workflows/check-typos.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ jobs:
1414
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
1515

1616
- name: Check for typos
17-
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0
17+
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0
18+
with:
19+
config: .github/typos.toml

cmd/cli/commands/status/status.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package status
22

33
import (
4+
"context"
45
"fmt"
6+
"strings"
57

68
"github.com/ethpandaops/contributoor-installer/cmd/cli/options"
79
"github.com/ethpandaops/contributoor-installer/internal/service"
@@ -126,5 +128,106 @@ func showStatus(
126128

127129
fmt.Printf("%-20s: %s%s%s\n", "Status", statusColor, statusText, tui.TerminalColorReset)
128130

131+
// Print beacon node information if configured.
132+
if cfg.BeaconNodeAddress != "" {
133+
printBeaconNodeInfo(c.Context, log, cfg.BeaconNodeAddress)
134+
}
135+
129136
return nil
130137
}
138+
139+
func printBeaconNodeInfo(ctx context.Context, log *logrus.Logger, addresses string) {
140+
nodes := strings.Split(addresses, ",")
141+
142+
for i, address := range nodes {
143+
address = strings.TrimSpace(address)
144+
if address == "" {
145+
continue
146+
}
147+
148+
// Skip status check for non-localhost addresses (e.g., Docker network hostnames).
149+
// These are not reachable from the host machine where the CLI runs.
150+
if !isLocalhostAddress(address) {
151+
continue
152+
}
153+
154+
beaconSvc := service.NewBeaconService(log, address)
155+
info := beaconSvc.GetBeaconInfo(ctx)
156+
157+
fmt.Println()
158+
159+
// Show header with node number if multiple nodes.
160+
if len(nodes) > 1 {
161+
fmt.Printf("%sBeacon Node %d Status%s (%s)\n",
162+
tui.TerminalColorLightBlue, i+1, tui.TerminalColorReset, address)
163+
} else {
164+
fmt.Printf("%sBeacon Node Status%s\n", tui.TerminalColorLightBlue, tui.TerminalColorReset)
165+
}
166+
167+
// Show error if we couldn't connect.
168+
if info.Error != nil {
169+
fmt.Printf("%-20s: %s%s%s\n", "Status", tui.TerminalColorRed, "Unreachable", tui.TerminalColorReset)
170+
fmt.Printf("%-20s: %s\n", "Error", info.Error.Error())
171+
172+
continue
173+
}
174+
175+
// Network.
176+
if info.Network != "" {
177+
fmt.Printf("%-20s: %s\n", "Network", info.Network)
178+
}
179+
180+
// Health status.
181+
if info.Health != nil {
182+
healthColor := tui.TerminalColorGreen
183+
healthText := "Healthy"
184+
185+
if info.Health.IsSyncing {
186+
healthColor = tui.TerminalColorYellow
187+
healthText = "Syncing"
188+
} else if !info.Health.IsHealthy {
189+
healthColor = tui.TerminalColorRed
190+
healthText = "Unhealthy"
191+
}
192+
193+
fmt.Printf("%-20s: %s%s%s\n", "Health", healthColor, healthText, tui.TerminalColorReset)
194+
}
195+
196+
// Sync status.
197+
if info.Sync != nil {
198+
syncColor := tui.TerminalColorGreen
199+
syncText := "Synced"
200+
201+
if info.Sync.IsSyncing {
202+
syncColor = tui.TerminalColorYellow
203+
syncText = fmt.Sprintf("Syncing (head: %s, distance: %s)", info.Sync.HeadSlot, info.Sync.SyncDistance)
204+
}
205+
206+
if info.Sync.ELOffline {
207+
syncColor = tui.TerminalColorRed
208+
syncText = "Execution Layer Offline"
209+
}
210+
211+
fmt.Printf("%-20s: %s%s%s\n", "Sync Status", syncColor, syncText, tui.TerminalColorReset)
212+
}
213+
214+
// Peer ID (truncated for readability).
215+
if info.Identity != nil && info.Identity.PeerID != "" {
216+
peerID := info.Identity.PeerID
217+
if len(peerID) > 20 {
218+
peerID = peerID[:10] + "..." + peerID[len(peerID)-10:]
219+
}
220+
221+
fmt.Printf("%-20s: %s\n", "Peer ID", peerID)
222+
}
223+
}
224+
}
225+
226+
// isLocalhostAddress checks if the address points to localhost.
227+
// Non-localhost addresses (e.g., Docker network hostnames) are not reachable from the host.
228+
func isLocalhostAddress(address string) bool {
229+
host := strings.TrimPrefix(strings.TrimPrefix(address, "http://"), "https://")
230+
host = strings.Split(host, ":")[0]
231+
232+
return strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "localhost")
233+
}

internal/service/beacon.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
"time"
10+
11+
"github.com/sirupsen/logrus"
12+
)
13+
14+
// BeaconService provides methods to interact with a beacon node's REST API.
15+
type BeaconService interface {
16+
// GetNodeIdentity returns the identity of the beacon node.
17+
GetNodeIdentity(ctx context.Context) (*NodeIdentity, error)
18+
// GetSyncStatus returns the sync status of the beacon node.
19+
GetSyncStatus(ctx context.Context) (*SyncStatus, error)
20+
// GetHealth returns the health status of the beacon node.
21+
GetHealth(ctx context.Context) (*HealthStatus, error)
22+
// GetBeaconInfo fetches all beacon node info in one call.
23+
// Errors are stored in BeaconInfo.Error rather than returned.
24+
GetBeaconInfo(ctx context.Context) *BeaconInfo
25+
}
26+
27+
// NodeIdentity represents the response from /eth/v1/node/identity.
28+
//
29+
//nolint:tagliatelle // Ethereum Beacon API uses snake_case.
30+
type NodeIdentity struct {
31+
PeerID string `json:"peer_id"`
32+
ENR string `json:"enr"`
33+
P2PAddresses []string `json:"p2p_addresses"`
34+
DiscoveryAddresses []string `json:"discovery_addresses"`
35+
Metadata struct {
36+
SeqNumber string `json:"seq_number"`
37+
Attnets string `json:"attnets"`
38+
Syncnets string `json:"syncnets"`
39+
} `json:"metadata"`
40+
}
41+
42+
// SyncStatus represents the response from /eth/v1/node/syncing.
43+
//
44+
//nolint:tagliatelle // Ethereum Beacon API uses snake_case.
45+
type SyncStatus struct {
46+
HeadSlot string `json:"head_slot"`
47+
SyncDistance string `json:"sync_distance"`
48+
IsSyncing bool `json:"is_syncing"`
49+
IsOptimistic bool `json:"is_optimistic"`
50+
ELOffline bool `json:"el_offline"`
51+
}
52+
53+
// HealthStatus represents the parsed health response.
54+
type HealthStatus struct {
55+
StatusCode int
56+
IsHealthy bool
57+
IsSyncing bool
58+
}
59+
60+
// BeaconInfo aggregates all beacon node information.
61+
type BeaconInfo struct {
62+
Identity *NodeIdentity
63+
Sync *SyncStatus
64+
Health *HealthStatus
65+
Network string
66+
Error error
67+
}
68+
69+
// beaconService implements BeaconService.
70+
type beaconService struct {
71+
log *logrus.Logger
72+
client *http.Client
73+
address string
74+
}
75+
76+
// NewBeaconService creates a new BeaconService instance.
77+
func NewBeaconService(log *logrus.Logger, address string) BeaconService {
78+
return &beaconService{
79+
log: log,
80+
client: &http.Client{Timeout: 5 * time.Second},
81+
address: strings.TrimSuffix(address, "/"),
82+
}
83+
}
84+
85+
// GetNodeIdentity fetches the node identity from the beacon node.
86+
func (s *beaconService) GetNodeIdentity(ctx context.Context) (*NodeIdentity, error) {
87+
var response struct {
88+
Data NodeIdentity `json:"data"`
89+
}
90+
91+
if err := s.doGet(ctx, "/eth/v1/node/identity", &response); err != nil {
92+
return nil, fmt.Errorf("failed to get node identity: %w", err)
93+
}
94+
95+
return &response.Data, nil
96+
}
97+
98+
// GetSyncStatus fetches the sync status from the beacon node.
99+
func (s *beaconService) GetSyncStatus(ctx context.Context) (*SyncStatus, error) {
100+
var response struct {
101+
Data SyncStatus `json:"data"`
102+
}
103+
104+
if err := s.doGet(ctx, "/eth/v1/node/syncing", &response); err != nil {
105+
return nil, fmt.Errorf("failed to get sync status: %w", err)
106+
}
107+
108+
return &response.Data, nil
109+
}
110+
111+
// GetHealth fetches the health status from the beacon node.
112+
func (s *beaconService) GetHealth(ctx context.Context) (*HealthStatus, error) {
113+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.address+"/eth/v1/node/health", nil)
114+
if err != nil {
115+
return nil, fmt.Errorf("failed to create request: %w", err)
116+
}
117+
118+
resp, err := s.client.Do(req)
119+
if err != nil {
120+
return nil, fmt.Errorf("failed to get health: %w", err)
121+
}
122+
123+
defer resp.Body.Close()
124+
125+
health := &HealthStatus{
126+
StatusCode: resp.StatusCode,
127+
IsHealthy: resp.StatusCode == http.StatusOK,
128+
IsSyncing: resp.StatusCode == http.StatusPartialContent,
129+
}
130+
131+
return health, nil
132+
}
133+
134+
// GetBeaconInfo fetches all beacon node info and returns aggregated results.
135+
func (s *beaconService) GetBeaconInfo(ctx context.Context) *BeaconInfo {
136+
info := &BeaconInfo{}
137+
138+
// If no address configured, return early.
139+
if s.address == "" {
140+
info.Error = fmt.Errorf("no beacon node address configured")
141+
142+
return info
143+
}
144+
145+
// Fetch health first as it's the quickest indicator of connectivity.
146+
health, err := s.GetHealth(ctx)
147+
if err != nil {
148+
info.Error = fmt.Errorf("beacon node unreachable: %w", err)
149+
150+
return info
151+
}
152+
153+
info.Health = health
154+
155+
// Fetch sync status.
156+
sync, err := s.GetSyncStatus(ctx)
157+
if err != nil {
158+
s.log.WithError(err).Debug("Failed to get sync status")
159+
} else {
160+
info.Sync = sync
161+
}
162+
163+
// Fetch identity.
164+
identity, err := s.GetNodeIdentity(ctx)
165+
if err != nil {
166+
s.log.WithError(err).Debug("Failed to get node identity")
167+
} else {
168+
info.Identity = identity
169+
}
170+
171+
// Try to determine network from spec.
172+
network, err := s.getNetwork(ctx)
173+
if err != nil {
174+
s.log.WithError(err).Debug("Failed to get network")
175+
} else {
176+
info.Network = network
177+
}
178+
179+
return info
180+
}
181+
182+
// getNetwork fetches the network name from the beacon node spec.
183+
func (s *beaconService) getNetwork(ctx context.Context) (string, error) {
184+
var response specResponse
185+
186+
if err := s.doGet(ctx, "/eth/v1/config/spec", &response); err != nil {
187+
return "", fmt.Errorf("failed to get spec: %w", err)
188+
}
189+
190+
return response.Data.ConfigName, nil
191+
}
192+
193+
// specResponse represents the response from /eth/v1/config/spec.
194+
//
195+
//nolint:tagliatelle // Ethereum Beacon API uses SCREAMING_SNAKE_CASE for config.
196+
type specResponse struct {
197+
Data struct {
198+
ConfigName string `json:"CONFIG_NAME"`
199+
} `json:"data"`
200+
}
201+
202+
// doGet performs a GET request and decodes the JSON response.
203+
func (s *beaconService) doGet(ctx context.Context, path string, result any) error {
204+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.address+path, nil)
205+
if err != nil {
206+
return fmt.Errorf("failed to create request: %w", err)
207+
}
208+
209+
req.Header.Set("Accept", "application/json")
210+
211+
resp, err := s.client.Do(req)
212+
if err != nil {
213+
return fmt.Errorf("request failed: %w", err)
214+
}
215+
216+
defer resp.Body.Close()
217+
218+
if resp.StatusCode != http.StatusOK {
219+
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
220+
}
221+
222+
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
223+
return fmt.Errorf("failed to decode response: %w", err)
224+
}
225+
226+
return nil
227+
}

0 commit comments

Comments
 (0)