Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions changelog.d/fixed-hub-forge-unreachable-badge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- hub: hives whose spoke reports `forge_unreachable` are labelled "forge unreachable (github_host)" instead of generic stale badges, and the condition no longer counts against their health or advisory status
6 changes: 6 additions & 0 deletions src/docs/advisory-staleness.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ never re-derives it.

## The gates

0. **Forge unreachable** — a beat carrying `ForgeUnreachable` is never stale,
whatever the digest's age or error. The spoke cannot reach its GitHub host
from the pod, so an ageing digest is a symptom of the egress fault rather
than a wedge of its own; the row shows a `forge unreachable (github_host)`
pill instead (#17433).
1. **Participation** — no post time *and* no error means "not an advisory hive
(or a spoke too old to report)". Unknown, never stale.
2. **Reported error** — flagged with its cause, unless the App is still
Expand Down Expand Up @@ -49,6 +54,7 @@ never re-derives it.
| Pure PR/merge hive | Suppressed | Suppressed | unchanged (expected suppression) |
| Every agent paused or off-schedule, digest ageing | Flagged stale — the pause itself lit the pill | Suppressed — no agents, no findings, no digest | Gate 3 (`allAgentsQuietByDesign`, #4528); counted as `hidden_stale` in diagnostics |
| Spoke heartbeat collect timed out, beat carries cached/minimal stats (`statsStale`) | `advisory-stale` alert raised from the last-good `advisoryLastPostedAt` while fresh digests were posting | No fleet alert — the post time is not a fresh observation | The hub alert rule skips `StatsStale` beats (#12842); health verdict already reads "heartbeat stats stale" |
| Spoke cannot reach its GitHub host from the pod (`ForgeUnreachable`) | Generic stale pills lit the row while the real fault was cluster egress | No stale-advisory pill — the row shows `forge unreachable (github_host)` instead | `advisoryStale` returns early on `ForgeUnreachable` beats (#17433) |

## Diagnostics

Expand Down
58 changes: 29 additions & 29 deletions src/docs/api-reference/hub-server.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/pkg/hub/advisory_staleness.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ func carryAdvisoryPostTime(entry *RegistryEntry, prev RegistryEntry) {
// returns false — never a false alarm — matching the rule the codebase already
// applies to StartedAt and GitHubAPIURL.
func advisoryStale(e RegistryEntry, now time.Time) (stale bool, reason string) {
if e.ForgeUnreachable {
return false, ""
}
return advisoryStaleFromFreshness(e, advisoryFreshnessFor(e, now))
}

Expand Down
7 changes: 7 additions & 0 deletions src/pkg/hub/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,9 @@ type alertHive struct {
// key material.
InferenceAuthError string

// ForgeUnreachable exempts the github_auth check from the failing-check rule.
ForgeUnreachable bool

// GitHubAppRequired / GitHubAppState are the spoke's own report of whether
// its config demands App auth and, when App auth is failing, WHY (the
// github.AppAuthState wire token). Carried verbatim: the spoke owns the
Expand Down Expand Up @@ -692,6 +695,7 @@ func alertHiveFromEntry(h MyHiveEntry) alertHive {
InactiveAgentsReason: h.InactiveAgentsReason,

InferenceAuthError: h.InferenceAuthError,
ForgeUnreachable: h.ForgeUnreachable,

GitHubAppRequired: h.GitHubAppRequired,
GitHubAppState: h.GitHubAppState,
Expand Down Expand Up @@ -1141,6 +1145,9 @@ func evaluateAlertsInFleet(state *alertState, hives, fleet []alertHive, driftAle
// seeded with a placeholder org/repo, which the check flags by design
// until the slot is claimed (#12843).
failing := failingHealthChecks(h.Health)
if h.ForgeUnreachable {
failing = withoutForgeChecks(failing)
}
if h.IsPlaceholder {
failing = withoutAuthClassChecks(failing, h.ProvStatus == statusAvailable)
}
Expand Down
27 changes: 20 additions & 7 deletions src/pkg/hub/assets/dashboard.html

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions src/pkg/hub/forge_unreachable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package hub

import "strings"

// forgeUnreachableLabel is the row label for a hive whose latest beat said the
// forge host cannot be reached from the pod; "" when it did not.
func forgeUnreachableLabel(e RegistryEntry) string {
if !e.ForgeUnreachable {
return ""
}
if host := strings.TrimSpace(e.GitHubHost); host != "" {
return "forge unreachable (" + host + ")"
}
return "forge unreachable"
}

// withoutForgeChecks drops the github_auth check, whose failure is a symptom
// of forge egress being down rather than of the hive being unhealthy.
func withoutForgeChecks(names []string) []string {
kept := names[:0:0]
for _, n := range names {
if n == healthCheckGitHubAuth {
continue
}
kept = append(kept, n)
}
return kept
}
63 changes: 63 additions & 0 deletions src/pkg/hub/forge_unreachable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package hub

import (
"testing"
"time"
)

func TestForgeUnreachableLabel(t *testing.T) {
cases := []struct {
name string
e RegistryEntry
want string
}{
{"host", RegistryEntry{ForgeUnreachable: true, GitHubHost: "github.ibm.com"}, "forge unreachable (github.ibm.com)"},
{"no host", RegistryEntry{ForgeUnreachable: true}, "forge unreachable"},
{"old spoke", RegistryEntry{GitHubHost: "github.ibm.com"}, ""},
}
for _, tc := range cases {
if got := forgeUnreachableLabel(tc.e); got != tc.want {
t.Errorf("%s: label = %q, want %q", tc.name, got, tc.want)
}
}
}

func TestForgeUnreachable_NotCountedAgainstHealth(t *testing.T) {
e := RegistryEntry{
ID: "h1", Online: true, StatsStale: true, ACMMLevel: 3,
GitHubAppState: "unreachable", GitHubAppRequired: true,
}
rollup := agentFleetRollup{Known: 1, Running: 1, Expected: 1}
app := GitHubAppHealth{Bucket: ghAppBucketBroken}
now := time.Now()

before := hiveHealthFor(e, rollup, app, 0, now)
if before.State != HealthStateUnknown {
t.Fatalf("baseline stale beat should be unknown, got %q", before.State)
}

e.ForgeUnreachable = true
after := hiveHealthFor(e, rollup, app, 0, now)
if after.State == HealthStateUnknown || after.State == HealthStateRed {
t.Fatalf("forge-unreachable hive must not be penalised, got %q (%s)", after.State, after.Reason)
}
}

func TestForgeUnreachable_NotStaleAdvisory(t *testing.T) {
e := advisoryModeEntry()
e.AdvisoryLastPostedAt = rfc3339Ago(30 * 24 * time.Hour)
if stale, _ := advisoryStale(e, advNow); !stale {
t.Fatal("baseline: aged digest should be stale")
}
e.ForgeUnreachable = true
if stale, reason := advisoryStale(e, advNow); stale {
t.Fatalf("forge-unreachable hive must not be stale-advisory, got %q", reason)
}
}

func TestForgeUnreachable_WithoutForgeChecks(t *testing.T) {
got := withoutForgeChecks([]string{"github_auth", "disk"})
if len(got) != 1 || got[0] != "disk" {
t.Fatalf("got %v", got)
}
}
7 changes: 7 additions & 0 deletions src/pkg/hub/health_verdict.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ func hiveHealthFor(e RegistryEntry, rollup agentFleetRollup, app GitHubAppHealth
func hiveHealthBase(e RegistryEntry, rollup agentFleetRollup, app GitHubAppHealth, queuedWork int, now time.Time) HealthVerdict {
v := HealthVerdict{QueuedWork: queuedWork}

// Forge egress is broken, not the pod: the stale stats and github_auth
// failure it causes must not read as a hive fault.
if e.ForgeUnreachable {
e.StatsStale = false
app = GitHubAppHealth{Bucket: ghAppBucketUnknown}
}

// --- Unknown gates first: never claim health for a hive we can't see. ---
if !e.Online {
v.State = HealthStateUnknown
Expand Down
1 change: 1 addition & 0 deletions src/pkg/hub/heartbeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ type HeartbeatPayload struct {
GitBranch string `json:"git_branch,omitempty"`
ImageRef string `json:"image_ref,omitempty"`
GitHubHost string `json:"github_host,omitempty"`
ForgeUnreachable bool `json:"forge_unreachable,omitempty"`
Timestamp string `json:"timestamp"`
GitHubAppRequired bool `json:"github_app_required,omitempty"`
GitHubAppPermIssue string `json:"github_app_perm_issue,omitempty"`
Expand Down
10 changes: 8 additions & 2 deletions src/pkg/hub/saas_hives_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,12 @@ type MyHiveEntry struct {
// the tooltip cause. Both stay zero/empty for hives that are not in advisory
// mode, whose App cannot write, or that report an unknown timestamp — those
// must never show the pill.
AdvisoryStale bool `json:"advisoryStale,omitempty"`
AdvisoryStaleReason string `json:"advisoryStaleReason,omitempty"`
AdvisoryStale bool `json:"advisoryStale,omitempty"`
// ForgeUnreachableLabel is "forge unreachable (host)" when the latest beat
// reported ForgeUnreachable; the UI renders it in place of the generic
// failing-check / stale-advisory pills.
ForgeUnreachableLabel string `json:"forgeUnreachableLabel,omitempty"`
AdvisoryStaleReason string `json:"advisoryStaleReason,omitempty"`

// AutoUpgradeBlocked is true when this hive has auto-upgrade ON but the hub
// will REFUSE to arm it: upgradeCollectible() is false, so the spoke cannot
Expand Down Expand Up @@ -1031,6 +1035,8 @@ func (s *HubServer) buildMyHivesResponse(username string, queryValues url.Values
result[i].BudgetHealth = budgetHealthFor(result[i].RegistryEntry)
result[i].GitHubAppHealth = githubAppHealthFor(result[i].RegistryEntry, journeyNow)

result[i].ForgeUnreachableLabel = forgeUnreachableLabel(result[i].RegistryEntry)

// Advisory-staleness pill, computed on read (same as Journey) so the
// gating — advisory-mode participation, app-can-write, past-threshold —
// lives ONLY in Go and the browser just renders the flag.
Expand Down
9 changes: 8 additions & 1 deletion src/pkg/hub/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,13 @@ type RegistryEntry struct {
// from cache. The hive is online, but health/agent-derived truth must be
// rendered as stale/unknown unless the beat explicitly carried a fresh
// in-memory agent overlay.
StatsStale bool `json:"statsStale,omitempty"`
StatsStale bool `json:"statsStale,omitempty"`
// ForgeUnreachable is the spoke-reported flag that the GitHub host in
// GitHubHost cannot be reached from the pod (forge probe timed out or
// failed at the transport layer). The pod is healthy; the break is egress,
// so the hub labels the row by host and exempts it from health/advisory
// penalties. Old spokes omit it (false = today's behaviour).
ForgeUnreachable bool `json:"forgeUnreachable,omitempty"`
Upgrading bool `json:"upgrading,omitempty"`
UpgradeTarget string `json:"upgradeTarget,omitempty"`
UpgradeStartedAt time.Time `json:"upgradeStartedAt,omitempty"`
Expand Down Expand Up @@ -2123,6 +2129,7 @@ func (s *HubServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
}(),
Online: true,
StatsStale: payload.StatsStale,
ForgeUnreachable: payload.ForgeUnreachable,
GitHubAppRequired: payload.GitHubAppRequired,
// GitHubAppPermIssue is a full sentence the drift hover renders
// verbatim ("The GitHub App is configured but has no installation.
Expand Down
Loading