-
Notifications
You must be signed in to change notification settings - Fork 10
[HYPERSHELL-178] Standardize gateway health and readiness vocabulary #239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| // Package gatewayhealth is the single source of truth for the vocabulary the | ||
| // HyperShell platform uses to describe a Gateway's health: its lifecycle Phase | ||
| // and the canonical status reason recorded alongside a healthy gateway. | ||
| // | ||
| // Both the API server (phase validation on writes and the per-phase metric) and | ||
| // the control plane (which writes phase/status back and gates on phase) import | ||
| // this package, so the vocabulary cannot drift between components. See | ||
| // specs/platform/gateway-phase-vocabulary.spec.md. | ||
| package gatewayhealth | ||
|
|
||
| // Phase is the canonical lifecycle state of a Gateway. Values are TitleCase and | ||
| // compared case-sensitively. | ||
| type Phase string | ||
|
|
||
| const ( | ||
| // PhasePending is accepted but not yet acted on by the reconciler. | ||
| PhasePending Phase = "Pending" | ||
| // PhaseProvisioning indicates manifests are being applied and the workload | ||
| // (and, for routed gateways, its external exposure) is not yet Ready. | ||
| PhaseProvisioning Phase = "Provisioning" | ||
| // PhaseRunning indicates the gateway is fully serving. | ||
| PhaseRunning Phase = "Running" | ||
| // PhaseDegraded indicates the gateway was provisioned but is currently | ||
| // unhealthy; it is recoverable without user action. | ||
| PhaseDegraded Phase = "Degraded" | ||
| // PhaseFailed indicates provisioning could not complete; recovery requires a | ||
| // change. | ||
| PhaseFailed Phase = "Failed" | ||
| ) | ||
|
|
||
| // StatusHealthy is the canonical human-readable status recorded alongside | ||
| // PhaseRunning when a gateway's workload - and, for routed gateways, its | ||
| // external exposure - is fully Ready. | ||
| const StatusHealthy = "Healthy" | ||
|
|
||
| // canonicalPhases lists every allowed phase in lifecycle order. It is the one | ||
| // place the allowed-phase set is defined; all other consumers derive from it. | ||
| var canonicalPhases = []Phase{ | ||
| PhasePending, | ||
| PhaseProvisioning, | ||
| PhaseRunning, | ||
| PhaseDegraded, | ||
| PhaseFailed, | ||
| } | ||
|
|
||
| // Phases returns the canonical phase set in lifecycle order. The returned slice | ||
| // is a copy, so callers cannot mutate the canonical set. | ||
| func Phases() []Phase { | ||
| out := make([]Phase, len(canonicalPhases)) | ||
| copy(out, canonicalPhases) | ||
| return out | ||
| } | ||
|
|
||
| // PhaseStrings returns the canonical phase set as strings, in lifecycle order. | ||
| func PhaseStrings() []string { | ||
| out := make([]string, len(canonicalPhases)) | ||
| for i, p := range canonicalPhases { | ||
| out[i] = string(p) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // IsValidPhase reports whether s is exactly one of the canonical phase values. | ||
| // Comparison is case-sensitive: the platform vocabulary is TitleCase. | ||
| func IsValidPhase(s string) bool { | ||
| for _, p := range canonicalPhases { | ||
| if string(p) == s { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package gatewayhealth | ||
|
|
||
| import "testing" | ||
|
|
||
| func TestIsValidPhase(t *testing.T) { | ||
| cases := []struct { | ||
| name string | ||
| phase string | ||
| want bool | ||
| }{ | ||
| {"pending", "Pending", true}, | ||
| {"provisioning", "Provisioning", true}, | ||
| {"running", "Running", true}, | ||
| {"degraded", "Degraded", true}, | ||
| {"failed", "Failed", true}, | ||
| {"empty is not valid", "", false}, | ||
| {"unknown value", "Booting", false}, | ||
| {"wrong case is rejected", "running", false}, | ||
| {"trailing space is rejected", "Running ", false}, | ||
| } | ||
| for _, tc := range cases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| if got := IsValidPhase(tc.phase); got != tc.want { | ||
| t.Fatalf("IsValidPhase(%q) = %v, want %v", tc.phase, got, tc.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestPhaseStringsCoversEveryConstant(t *testing.T) { | ||
| got := PhaseStrings() | ||
| want := []string{"Pending", "Provisioning", "Running", "Degraded", "Failed"} | ||
| if len(got) != len(want) { | ||
| t.Fatalf("PhaseStrings() = %v, want %v", got, want) | ||
| } | ||
| for i := range want { | ||
| if got[i] != want[i] { | ||
| t.Fatalf("PhaseStrings()[%d] = %q, want %q (order must be canonical)", i, got[i], want[i]) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestPhasesReturnsCopy(t *testing.T) { | ||
| first := Phases() | ||
| first[0] = "Mutated" | ||
| if second := Phases(); second[0] != PhasePending { | ||
| t.Fatalf("Phases() returned a mutable view: got %q after mutation, want %q", second[0], PhasePending) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import ( | |
| "context" | ||
| "sync" | ||
|
|
||
| "github.com/openshift-online/hypershell/components/api-server/pkg/gatewayhealth" | ||
| "github.com/prometheus/client_golang/prometheus" | ||
| ) | ||
|
|
||
|
|
@@ -17,25 +18,29 @@ var ( | |
| metricsOnce sync.Once | ||
| ) | ||
|
|
||
| // metricsHelp describes the per-phase gateway gauge. The canonical phase set is | ||
| // owned by the gatewayhealth package (single source of truth). | ||
| const metricsHelp = "Number of gateways by phase (Pending, Provisioning, Running, Degraded, Failed)." | ||
|
|
||
| // RegisterGatewayMetrics registers a Prometheus GaugeVec that reports the | ||
| // number of gateways broken down by phase (Running, Provisioning, Degraded, | ||
| // Failed). The gauge is refreshed on every scrape by querying the database. | ||
| // It is safe to call multiple times; subsequent calls are no-ops. | ||
| // number of gateways broken down by phase. The gauge is refreshed on every | ||
| // scrape by querying the database. It is safe to call multiple times; | ||
| // subsequent calls are no-ops. | ||
| func RegisterGatewayMetrics(dao GatewayDao) { | ||
| metricsOnce.Do(func() { | ||
| gatewayTotalVec = prometheus.NewGaugeVec( | ||
| prometheus.GaugeOpts{ | ||
| Namespace: metricsNamespace, | ||
| Subsystem: metricsSubsystem, | ||
| Name: "total", | ||
| Help: "Number of gateways by phase (Running, Provisioning, Degraded, Failed).", | ||
| Help: metricsHelp, | ||
| }, | ||
| []string{"phase"}, | ||
| ) | ||
|
|
||
| // Pre-seed the known phases so they always appear in the output even | ||
| // Pre-seed the canonical phases so they always appear in the output even | ||
| // when the count is zero, avoiding gaps in graphs. | ||
| for _, phase := range []string{"Running", "Provisioning", "Degraded", "Failed"} { | ||
| for _, phase := range gatewayhealth.PhaseStrings() { | ||
| gatewayTotalVec.WithLabelValues(phase).Set(0) | ||
| } | ||
|
|
||
|
|
@@ -55,7 +60,7 @@ func newGatewayCollector(dao GatewayDao) *gatewayCollector { | |
| dao: dao, | ||
| desc: prometheus.NewDesc( | ||
| prometheus.BuildFQName(metricsNamespace, metricsSubsystem, "total"), | ||
| "Number of gateways by phase (Running, Provisioning, Degraded, Failed).", | ||
| metricsHelp, | ||
| []string{"phase"}, | ||
| nil, | ||
| ), | ||
|
|
@@ -74,14 +79,8 @@ func (c *gatewayCollector) Collect(ch chan<- prometheus.Metric) { | |
| return | ||
| } | ||
|
|
||
| // Always emit the known phases so graphs never have gaps. | ||
| known := map[string]struct{}{ | ||
| "Running": {}, | ||
| "Provisioning": {}, | ||
| "Degraded": {}, | ||
| "Failed": {}, | ||
| } | ||
| for phase := range known { | ||
| // Always emit the canonical phases so graphs never have gaps. | ||
| for _, phase := range gatewayhealth.PhaseStrings() { | ||
| ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, float64(counts[phase]), phase) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Minor - Observability] |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Minor - Style] This runs inside the
if req.Phase != nilblock, immediately afterValidateStringField("phase", *req.Phase, false), so thephase == nilguard insidevalidateGatewayPhaseis redundant on this path. Harmless - just noting it; the create path (L68) legitimately needs the nil guard, so the shared helper is fine as-is.