[HYPERSHELL-178] Standardize gateway health and readiness vocabulary - #239
Conversation
Introduce a single source of truth for the Gateway phase/status vocabulary so the phase a client reads means the same thing in every component and cannot silently drift. No schema change. - Add shared pkg/gatewayhealth (Phase constants, Phases/PhaseStrings/ IsValidPhase, StatusHealthy), imported by both the API server and the control plane. - API server now rejects writes that set a gateway phase outside the canonical set (gRPC create/update -> InvalidArgument; REST create/patch -> 400); absent/empty phase stays accepted. - Derive the per-phase metric set from gatewayhealth (fixes the prior omission of Pending). - Replace magic-string phase literals (reconciler, health loop, and the watcher forceSeedRecovery gate) and the "Healthy" status with the shared constants. - Align the console phase vocabulary (gateway-data.ts) to a canonical set mirroring the Go package (behavior-preserving). - Add gateway-phase-vocabulary.spec.md and cross-link it from the health spec. HYPERSHELL-178 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Central YAML (inherited) Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Amber reviewStatus: Complete |
jsell-rh
left a comment
There was a problem hiding this comment.
Verdict
This is a clean, well-scoped refactor that collapses duplicated Gateway phase/status string literals into a single shared gatewayhealth package, adds optional-but-canonical phase validation on the API-server write paths, and derives the per-phase metric (now including the previously-missing Pending) from that source of truth. The change is behavior-preserving, thoroughly tested (including a legacy-record test that proves pre-existing non-canonical rows are not locked out), and the accompanying spec is precise; I have only minor observations.
Summary
The PR introduces components/api-server/pkg/gatewayhealth (phase constants, Phases()/PhaseStrings()/IsValidPhase(), StatusHealthy) and wires it into the API server (validation + metric), the control plane (reconciler, health loop, watcher gate), and the console (gatewayCanonicalPhases). Validation is correctly gated so an absent/empty phase stays accepted, keeping the field optional and existing rows patchable on unrelated fields.
Strengths
- Genuine single source of truth. Both Go modules import the same package via the existing
replace ../api-serverdirective, and the console mirrors it with a documented "keep in sync" note. The metric and validation now derive from one list, so the earlierPendingomission cannot recur. - Migration safety is explicitly handled.
validateGatewayPhaseValue/validateGatewayPhaseonly fire when a write setsphase, and empty is accepted.TestGatewayPatchNotTouchingPhaseAcceptsLegacyRecordproves a stored non-canonical phase does not lock a row out of unrelated patches. - Test diff scrutiny. The pre-existing assertions in
grpc_integration_test.goandintegration_test.gothat flip"TestPhase"/"UpdatedPhase"/"test-phase"to canonical values are a tightened-shared-precondition signal, but the PR calls this out and backs it with the legacy-record + rejection tests rather than silently rewriting them. No guarantee was removed.
Minor observations
- [Minor]
metrics.goCollect()emits only canonical phases, so any gateway persisting a legacy/non-canonical or blank phase (a state this PR intentionally still permits at the service layer) is not counted in any series and the gauge can under-report the true total. Pre-existing behavior, but now that the vocabulary is canonicalized anunknown/otherbucket would make the drift visible. Observability. - [Minor] In
grpc_handler.goUpdateGateway,validateGatewayPhase(req.Phase)runs immediately aftergrpcutil.ValidateStringField("phase", *req.Phase, false)inside the samereq.Phase != nilblock; the nil/empty guard insidevalidateGatewayPhaseis redundant on this path. Harmless, purely cosmetic. Style.
Cross-PR coordination
Two open pull requests require maintainer coordination with this change:
-
#211 introduces a console gateway-metrics dashboard that hardcodes its own phase set (
GatewayPhaseCounts/gatewayPhases=Running,Provisioning,Degraded,Failed) insidepackages/gateway-management-ui/src/metrics/, the same package this PR designates as the console-side source of truth. That set omitsPending, which this PR deliberately adds to the API-server metric and mandates as part of the canonical vocabulary. If both merge as-is, the dashboard will silently drop thePendinggauge series and reintroduce the duplicated, drifting phase list this PR is removing. A decision is needed on having #211 consume the canonical vocabulary (includingPending) rather than its own literal set, and on merge order so the later PR conforms. -
#151 and #200 propose re-keying the control-plane provisioning gate off desired-state convergence and updating the Gateway health spec so a healthy phase no longer suppresses drift repair - directly targeting the phase gate (
skip if Running/Provisioning/Degraded) and the health spec that this PR refactors into shared constants (and cross-links). The behavior this PR preserves is the exact behavior those PRs intend to replace. Maintainers should decide the gate model and the merge order; whichever lands second must rebase the gate/spec onto the agreed design rather than re-entrenching or re-removing it independently.
Findings Summary (ordered by severity, highest first)
- [Minor] Per-phase gauge omits gateways holding a legacy/non-canonical/blank phase - Observability (metrics.go L82-L84)
- [Minor] Redundant empty/nil phase guard on the gRPC update path - Style (grpc_handler.go L140-L143)
Convention Checklist
| Convention | Result |
|---|---|
No panic() in production code |
Pass |
Errors wrapped with fmt.Errorf/typed errors context |
Pass |
| Input validated (canonical phase vocabulary) | Pass |
| No secrets in logs or responses | Pass |
| Reconcile pattern preserved | Pass |
| Image references consistent | N/A |
| Single source of truth for shared vocabulary | Pass |
| Test Diff Scrutiny (modified pre-existing assertions justified) | Pass |
| Conventional commit message | Pass |
| Spec added and cross-linked | Pass |
| 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) |
There was a problem hiding this comment.
[Minor - Observability] Collect() iterates only the canonical PhaseStrings(), so any gateway whose stored phase is legacy/non-canonical or blank (a state this PR intentionally still allows at the service layer, and which the legacy-record test relies on) is not represented in any series. The gauge can therefore under-report the true gateway total. Consider emitting an unknown/other bucket for counts whose phase is not in the canonical set, so drift stays visible rather than silently disappearing. Pre-existing behavior, so not blocking.
| if err := grpcutil.ValidateStringField("phase", *req.Phase, false); err != nil { | ||
| return nil, err | ||
| } | ||
| if err := validateGatewayPhase(req.Phase); err != nil { |
There was a problem hiding this comment.
[Minor - Style] This runs inside the if req.Phase != nil block, immediately after ValidateStringField("phase", *req.Phase, false), so the phase == nil guard inside validateGatewayPhase is 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.

Summary
Standardizes the Gateway phase/status vocabulary into a single source of truth so the phase a client reads means the same thing in every component and cannot silently drift. No schema change — the health behavior (deployment readiness, route readiness, continuous health loop) already exists; this PR standardizes its representation.
Before this change the phase strings were duplicated as magic literals across the control plane, the API-server metric, and the console, with real drift (the metric omitted
Pending; the console had lowercase/extra variants).What changed
components/api-server/pkg/gatewayhealth—Phaseconstants (Pending/Provisioning/Running/Degraded/Failed),Phases()/PhaseStrings()/IsValidPhase(), and the canonicalStatusHealthyconstant. Imported by both the API server and the control plane (via the existingreplace ../api-serverdirective).InvalidArgument; REST create/patch →400). An absent/empty phase stays accepted, so the field remains optional and pre-existing rows are not locked out (a patch that doesn't touch phase skips validation).hypershell_gateways_totalnow derives its phase set fromgatewayhealth.PhaseStrings(), fixing the prior omission ofPending.forceSeedRecoveryphase-read gate now use the shared constants instead of magic strings; the healthy case usesStatusHealthy.gateway-data.tsgains an exportedgatewayCanonicalPhasesmirroring the Go vocabulary; the polling/failed sets derive from it (behavior-preserving).specs/platform/gateway-phase-vocabulary.spec.md, cross-linked fromopenshell-gateway-health.spec.md.Testing
pkg/gatewayhealthunit tests (valid/invalid/case-sensitivity, canonical order, defensive copy).phase_validation_test.gointegration tests: invalid phase rejected on gRPC create/update + REST create; canonical phase accepted; absent phase accepted; and a legacy-record test proving a non-canonical stored phase can still be patched on unrelated fields (no lockout).plugins/gatewaysintegration suite, control-planewatcher/reconcilertests,go build/go vet, and repomake checkall pass.HYPERSHELL-178
🤖 Generated with Claude Code