Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
72 changes: 72 additions & 0 deletions components/api-server/pkg/gatewayhealth/gatewayhealth.go
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
}
49 changes: 49 additions & 0 deletions components/api-server/pkg/gatewayhealth/gatewayhealth_test.go
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)
}
}
19 changes: 19 additions & 0 deletions components/api-server/plugins/gateways/grpc_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"google.golang.org/grpc/status"

pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1"
"github.com/openshift-online/hypershell/components/api-server/pkg/gatewayhealth"
"github.com/openshift-online/rh-trex-ai/pkg/api"
pkgserver "github.com/openshift-online/rh-trex-ai/pkg/server"
"github.com/openshift-online/rh-trex-ai/pkg/server/grpcutil"
Expand All @@ -27,6 +28,18 @@ func NewGatewayGRPCHandler(svc GatewayService, generic services.GenericService,
return &gatewayGRPCHandler{service: svc, generic: generic, brokerFunc: brokerFunc}
}

// validateGatewayPhase rejects a phase outside the canonical vocabulary. An
// absent or empty phase is accepted so the field stays optional.
func validateGatewayPhase(phase *string) error {
if phase == nil || *phase == "" {
return nil
}
if !gatewayhealth.IsValidPhase(*phase) {
return status.Errorf(codes.InvalidArgument, "phase %q is not a valid gateway phase; allowed: %v", *phase, gatewayhealth.PhaseStrings())
}
return nil
}

func (h *gatewayGRPCHandler) GetGateway(ctx context.Context, req *pb.GetGatewayRequest) (*pb.GetGatewayResponse, error) {
if err := grpcutil.ValidateRequiredID(req.Id); err != nil {
return nil, err
Expand All @@ -52,6 +65,9 @@ func (h *gatewayGRPCHandler) CreateGateway(ctx context.Context, req *pb.CreateGa
if err := grpcutil.ValidateStringField("database_id", req.DatabaseId, false); err != nil {
return nil, err
}
if err := validateGatewayPhase(req.Phase); err != nil {
return nil, err
}
var serverDnsNamesJSON *string
if len(req.ServerDnsNames) > 0 {
data, _ := json.Marshal(req.ServerDnsNames)
Expand Down Expand Up @@ -124,6 +140,9 @@ func (h *gatewayGRPCHandler) UpdateGateway(ctx context.Context, req *pb.UpdateGa
if err := grpcutil.ValidateStringField("phase", *req.Phase, false); err != nil {
return nil, err
}
if err := validateGatewayPhase(req.Phase); err != nil {

Copy link
Copy Markdown
Collaborator

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 != 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.

return nil, err
}
}

gateway, svcErr := h.service.Get(ctx, req.Id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func TestGRPCGatewayCRUD(t *testing.T) {
TlsMode: func() *string { s := "TestTlsMode"; return &s }(),
ServiceType: func() *string { s := "TestServiceType"; return &s }(),
Status: func() *string { s := "TestStatus"; return &s }(),
Phase: func() *string { s := "TestPhase"; return &s }(),
Phase: func() *string { s := "Provisioning"; return &s }(),
}
created, err := grpcClient.CreateGateway(ctx, createReq)
Expect(err).NotTo(HaveOccurred())
Expand Down Expand Up @@ -88,7 +88,7 @@ func TestGRPCGatewayCRUD(t *testing.T) {
TlsMode: func() *string { s := "UpdatedTlsMode"; return &s }(),
ServiceType: func() *string { s := "UpdatedServiceType"; return &s }(),
Status: func() *string { s := "UpdatedStatus"; return &s }(),
Phase: func() *string { s := "UpdatedPhase"; return &s }(),
Phase: func() *string { s := "Running"; return &s }(),
}
updated, err := grpcClient.UpdateGateway(ctx, updateReq)
Expect(err).NotTo(HaveOccurred())
Expand Down
19 changes: 19 additions & 0 deletions components/api-server/plugins/gateways/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/gorilla/mux"

"github.com/openshift-online/hypershell/components/api-server/pkg/api/openapi"
"github.com/openshift-online/hypershell/components/api-server/pkg/gatewayhealth"
"github.com/openshift-online/hypershell/components/api-server/pkg/rbac"
"github.com/openshift-online/rh-trex-ai/pkg/api/presenters"
"github.com/openshift-online/rh-trex-ai/pkg/auth"
Expand Down Expand Up @@ -40,6 +41,18 @@ type gatewayHandler struct {
ownerLookup GatewayOwnerLookup
}

// validateGatewayPhaseValue rejects a phase outside the canonical vocabulary. An
// absent or empty phase is accepted so the field stays optional.
func validateGatewayPhaseValue(phase *string) *errors.ServiceError {
if phase == nil || *phase == "" {
return nil
}
if !gatewayhealth.IsValidPhase(*phase) {
return errors.Validation("phase %q is not a valid gateway phase; allowed: %v", *phase, gatewayhealth.PhaseStrings())
}
return nil
}

func NewGatewayHandler(gateway GatewayService, generic services.GenericService, ownerBinding OwnerBindingCreator, visibilityFilter GatewayVisibilityFilter, ownerLookup GatewayOwnerLookup) *gatewayHandler {
return &gatewayHandler{
gateway: gateway,
Expand All @@ -58,6 +71,9 @@ func (h gatewayHandler) Create(w http.ResponseWriter, r *http.Request) {
Action: func() (interface{}, *errors.ServiceError) {
ctx := r.Context()
gatewayModel := ConvertGateway(gateway)
if phaseErr := validateGatewayPhaseValue(gatewayModel.Phase); phaseErr != nil {
return nil, phaseErr
}
gatewayModel, err := h.gateway.Create(ctx, gatewayModel)
if err != nil {
return nil, err
Expand Down Expand Up @@ -116,6 +132,9 @@ func (h gatewayHandler) Patch(w http.ResponseWriter, r *http.Request) {
found.Status = patch.Status
}
if patch.Phase != nil {
if phaseErr := validateGatewayPhaseValue(patch.Phase); phaseErr != nil {
return nil, phaseErr
}
found.Phase = patch.Phase
}
if patch.Image != nil {
Expand Down
2 changes: 1 addition & 1 deletion components/api-server/plugins/gateways/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func TestGatewayPost(t *testing.T) {
TlsMode: openapi.PtrString("test-tls_mode"),
ServiceType: openapi.PtrString("test-service_type"),
Status: openapi.PtrString("test-status"),
Phase: openapi.PtrString("test-phase"),
Phase: openapi.PtrString("Provisioning"),
}

gatewayOutput, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(gatewayInput).Execute()
Expand Down
29 changes: 14 additions & 15 deletions components/api-server/plugins/gateways/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"sync"

"github.com/openshift-online/hypershell/components/api-server/pkg/gatewayhealth"
"github.com/prometheus/client_golang/prometheus"
)

Expand All @@ -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)
}

Expand All @@ -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,
),
Expand All @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

}
}
Loading
Loading