Skip to content

Hub status: surface cluster leadership (IsLeader, LeaderAddr) in Status RPC + ctx hub status #96

@v0lkan

Description

@v0lkan

Summary

The hub stores a Raft Cluster reference on Server (internal/hub/server.go:54) and even uses it during GracefulStop (server.go:58-62). But the cluster's leadership state is never read by the Status RPC, so operators have no way to ask "which node is leader?" or "am I talking to the leader?" through the supported API surface. The data exists; the wire is missing.

This was tracked in TASKS.md as "Fix hub cluster: NewCluster result is discarded; Raft runs but leadership status is never queryable" (added 2026-04-08). The "discarded" half is no longer accurate — it was fixed at some point and the task entry didn't get updated. This issue covers only the remaining "leadership status never queryable" half.

What's already in place

Server has the cluster reference and shuts it down properly:

// internal/hub/server.go:48-62
func (s *Server) SetCluster(cluster *Cluster) {
    s.cluster = cluster
}

func (s *Server) GracefulStop() {
    if s.cluster != nil {
        _ = s.cluster.Shutdown()
    }
    s.grpc.GracefulStop()
}

The methods to query leadership exist on *Cluster:

// internal/hub/cluster.go:127-141
func (c *Cluster) IsLeader() bool { ... }
func (c *Cluster) LeaderAddr() string { ... }

What's missing

StatusResponse has no leadership fields:

// internal/hub/types.go:264-269
type StatusResponse struct {
    TotalEntries     uint64            `json:"total_entries"`
    ConnectedClients uint32            `json:"connected_clients"`
    EntriesByType    map[string]uint64 `json:"entries_by_type"`
    EntriesByProject map[string]uint64 `json:"entries_by_project"`
}

The Status handler (internal/hub/handler.go) populates these from Store but never touches s.cluster. The ctx hub status CLI renders whatever the RPC returns, so even if an operator runs the cluster in HA mode, the CLI is silent about it.

Proposed Shape

Three small changes, all in this order so each is reviewable:

1. Extend StatusResponse

// internal/hub/types.go
type StatusResponse struct {
    TotalEntries     uint64            `json:"total_entries"`
    ConnectedClients uint32            `json:"connected_clients"`
    EntriesByType    map[string]uint64 `json:"entries_by_type"`
    EntriesByProject map[string]uint64 `json:"entries_by_project"`
    // Cluster fields are zero values when the hub runs
    // standalone (no Raft peers configured).
    ClusterEnabled bool   `json:"cluster_enabled"`
    IsLeader       bool   `json:"is_leader,omitempty"`
    LeaderAddr     string `json:"leader_addr,omitempty"`
}

ClusterEnabled is the disambiguator: when false, the other two are meaningless zero values; when true, they reflect live Raft state. Without ClusterEnabled, a standalone hub would always report IsLeader=false, LeaderAddr="" which would mislead anyone reading the response.

2. Populate in the handler

// internal/hub/handler.go (Status handler)
resp := &StatusResponse{
    TotalEntries:     ...,
    ConnectedClients: ...,
    EntriesByType:    ...,
    EntriesByProject: ...,
}
if s.cluster != nil {
    resp.ClusterEnabled = true
    resp.IsLeader = s.cluster.IsLeader()
    resp.LeaderAddr = s.cluster.LeaderAddr()
}
return resp, nil

3. Render in the CLI

Locate the ctx hub status rendering layer (probably internal/cli/hub/core/server/render.go or similar; grep for the existing TotalEntries rendering to find it) and add a short cluster section:

Cluster:      enabled (leader: 10.0.0.5:7081)
              this node IS the leader

or, when not in cluster mode:

Cluster:      standalone

Use the existing rendering style for consistency.

Tests Required

  • TestStatus_StandaloneReportsClusterDisabled: start a Server without SetCluster, call Status, assert ClusterEnabled == false.
  • TestStatus_ClusterReportsLeadershipState: start a single-node Raft cluster, wait for it to elect itself leader, call Status, assert ClusterEnabled == true && IsLeader == true && LeaderAddr != "".
  • (Render layer) Snapshot/golden-file test for the two CLI rendering shapes (standalone vs cluster).

Out of Scope

  • Adding a Leadership streaming RPC (operator could subscribe to leadership changes). The pull-via-Status surface is the minimum useful; streaming can come later if a real use case appears.
  • A ctx hub leader shortcut command. ctx hub status returning the info is sufficient; a dedicated subcommand is sugar.
  • Tracking the Raft term / commit-index / log-position. Useful for debugging quorum issues but a separate concern from "who is leader right now".

Acceptance

  • StatusResponse gains ClusterEnabled, IsLeader, LeaderAddr fields.
  • Status handler populates them from s.cluster when present.
  • ctx hub status renders the cluster section.
  • Tests for standalone and cluster modes both pass.

Scope for "good first issue"

This is wiring an existing data source (Cluster.IsLeader(), Cluster.LeaderAddr()) through three layers (response struct → handler → CLI render) plus a small test. No new gRPC method, no auth concerns, no concurrency surprise (the Cluster has its own locking). A newcomer can pattern-match against how TotalEntries flows from Store → handler → CLI to land this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggood first issueGood for newcomers

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions