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
3 changes: 3 additions & 0 deletions cmd/hyperfleet-api/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/handlers"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/middleware"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/validators"
)

Expand Down Expand Up @@ -107,6 +108,8 @@ func (s *apiServer) routes(tracingEnabled bool) *mux.Router {
func registerAPIMiddleware(router *mux.Router) error {
router.Use(MetricsMiddleware)

registry.Validate()

schemaPath := env().Config.Server.OpenAPISchemaPath
ctx := context.Background()

Expand Down
1 change: 1 addition & 0 deletions pkg/registry/descriptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ type EntityDescriptor struct {
OnParentDelete OnParentDeletePolicy // only meaningful when ParentKind != ""
SearchDisallowedFields []string // fields blocked from TSL search
RequiredAdapters []string // adapters that must finalize before hard-delete
RequireSpecSchema bool // panic at startup if SpecSchemaName missing from spec
}
15 changes: 15 additions & 0 deletions pkg/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ func Validate() {
}
}

// ValidateSpecSchemas checks descriptors that set RequireSpecSchema and panics if
// their SpecSchemaName is absent from the OpenAPI spec. Entities without
// RequireSpecSchema are left to buildSchemasMap, which warns and skips them.
// See also Validate, which checks registry structural integrity.
func ValidateSpecSchemas(schemaExists func(string) bool) {
for _, d := range descriptors {
if d.SpecSchemaName != "" && d.RequireSpecSchema && !schemaExists(d.SpecSchemaName) {
panic(fmt.Sprintf(
"entity kind %q declares SpecSchemaName %q but it does not resolve to an existing component in the OpenAPI spec",
d.Kind, d.SpecSchemaName,
))
}
}
}

// Reset clears all registrations. Only for use in tests.
func Reset() {
descriptors = make(map[string]EntityDescriptor)
Expand Down
115 changes: 115 additions & 0 deletions pkg/registry/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,121 @@ func TestValidate_EmptyRegistry(t *testing.T) {
}).ToNot(Panic())
}

func TestValidateSpecSchemas_PanicsOnMissingSchema(t *testing.T) {
RegisterTestingT(t)
Reset()

Register(EntityDescriptor{
Kind: "Channel",
Plural: "channels",
SpecSchemaName: "ChannelSpec",
RequireSpecSchema: true,
})

Expect(func() {
ValidateSpecSchemas(func(name string) bool { return false })
}).To(PanicWith(ContainSubstring("ChannelSpec")))
}

func TestValidateSpecSchemas_PassesWhenAllSchemasResolve(t *testing.T) {
RegisterTestingT(t)
Reset()

Register(EntityDescriptor{
Kind: "Channel",
Plural: "channels",
SpecSchemaName: "ChannelSpec",
RequireSpecSchema: true,
})
Register(EntityDescriptor{
Kind: "Version",
Plural: "versions",
ParentKind: "Channel",
SpecSchemaName: "VersionSpec",
RequireSpecSchema: true,
})

Expect(func() {
ValidateSpecSchemas(func(name string) bool { return true })
}).ToNot(Panic())
}

func TestValidateSpecSchemas_SkipsDescriptorsWithoutSpecSchema(t *testing.T) {
RegisterTestingT(t)
Reset()

Register(EntityDescriptor{
Kind: "Channel",
Plural: "channels",
})

called := false
ValidateSpecSchemas(func(name string) bool {
called = true
return false
})
Expect(called).To(BeFalse())
}

func TestValidateSpecSchemas_EmptyRegistry(t *testing.T) {
RegisterTestingT(t)
Reset()

Expect(func() {
ValidateSpecSchemas(func(name string) bool {
t.Fatal("callback should not be called on empty registry")
return false
})
}).ToNot(Panic())
}

func TestValidateSpecSchemas_MixedDescriptors(t *testing.T) {
RegisterTestingT(t)
Reset()

Register(EntityDescriptor{
Kind: "Channel",
Plural: "channels",
})
Register(EntityDescriptor{
Kind: "Version",
Plural: "versions",
ParentKind: "Channel",
SpecSchemaName: "VersionSpec",
})
Register(EntityDescriptor{
Kind: "Cluster",
Plural: "clusters",
SpecSchemaName: "ClusterSpec",
RequireSpecSchema: true,
})

resolved := map[string]bool{"VersionSpec": true, "ClusterSpec": true}
Expect(func() {
ValidateSpecSchemas(func(name string) bool { return resolved[name] })
}).ToNot(Panic())

resolved["ClusterSpec"] = false
Expect(func() {
ValidateSpecSchemas(func(name string) bool { return resolved[name] })
}).To(PanicWith(ContainSubstring("ClusterSpec")))
}

func TestValidateSpecSchemas_SkipsNonRequiredMissingSchema(t *testing.T) {
RegisterTestingT(t)
Reset()

Register(EntityDescriptor{
Kind: "Channel",
Plural: "channels",
SpecSchemaName: "ChannelSpec",
})

Expect(func() {
ValidateSpecSchemas(func(name string) bool { return false })
}).ToNot(Panic())
}

func TestDescriptorFields(t *testing.T) {
RegisterTestingT(t)
Reset()
Expand Down
73 changes: 18 additions & 55 deletions pkg/validators/schema_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ import (
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry"
)

// TODO : HYPERFLEET-1159 - Uncomment this once Cluster and NodePool are registered
// var requiredSpecValidationKinds = []string{"Cluster", "NodePool"}

// ResourceSchema represents a validation schema for a specific resource type
type ResourceSchema struct {
Schema *openapi3.SchemaRef
Expand All @@ -27,9 +24,8 @@ type SchemaValidator struct {
}

// NewSchemaValidator creates a new schema validator by loading an OpenAPI spec from the given path.
// Cluster and NodePool must be registered with SpecSchemaName and have matching OpenAPI components.
// Other registered entities with SpecSchemaName are validated only when their component exists;
// missing components are skipped with a warning at startup.
// Panics if any registered entity with RequireSpecSchema has a SpecSchemaName that does not resolve.
// Entities without RequireSpecSchema whose schema is absent are skipped with a warning.
func NewSchemaValidator(schemaPath string) (*SchemaValidator, error) {
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(schemaPath)
Expand All @@ -41,6 +37,10 @@ func NewSchemaValidator(schemaPath string) (*SchemaValidator, error) {
return nil, fmt.Errorf("invalid OpenAPI schema: %w", validateErr)
}

registry.ValidateSpecSchemas(func(name string) bool {
return doc.Components.Schemas[name] != nil
})

schemas, err := buildSchemasMap(doc)
if err != nil {
return nil, err
Expand All @@ -55,78 +55,41 @@ func NewSchemaValidator(schemaPath string) (*SchemaValidator, error) {
func buildSchemasMap(doc *openapi3.T) (map[string]*ResourceSchema, error) {
ctx := context.Background()
schemas := make(map[string]*ResourceSchema)
// registeredKinds := make(map[string]bool, len(requiredSpecValidationKinds))

for _, d := range registry.WithSpecSchema() {
schemaRef := doc.Components.Schemas[d.SpecSchemaName]
if schemaRef == nil {
// TODO : HYPERFLEET-1159 - Uncomment this once Cluster and NodePool are registered
// if isRequiredSpecValidationKind(d.Kind) {
// return nil, fmt.Errorf(
// "%s schema not found in OpenAPI spec (required for entity kind %q)",
// d.SpecSchemaName, d.Kind,
// )
// }

logger.With(ctx,
"schema_name", d.SpecSchemaName,
"kind", d.Kind,
"plural", d.Plural,
).Warn("OpenAPI spec schema not found, skipping validation for entity")
continue
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

schemas[d.Plural] = &ResourceSchema{
TypeName: d.SpecSchemaName,
Schema: schemaRef,
}
// registeredKinds[d.Kind] = true
}

// TODO : HYPERFLEET-1159 - Remove this once Cluster and NodePool are registered
// Extract ClusterSpec schema
clusterSpecSchema := doc.Components.Schemas["ClusterSpec"]
if clusterSpecSchema == nil {
return nil, fmt.Errorf("ClusterSpec schema not found in OpenAPI spec")
}

// Extract NodePoolSpec schema
nodePoolSpecSchema := doc.Components.Schemas["NodePoolSpec"]
if nodePoolSpecSchema == nil {
return nil, fmt.Errorf("NodePoolSpec schema not found in OpenAPI spec")
}

// Build schemas map
schemas["clusters"] = &ResourceSchema{
TypeName: "ClusterSpec",
Schema: clusterSpecSchema,
}
schemas["nodepools"] = &ResourceSchema{
TypeName: "NodePoolSpec",
Schema: nodePoolSpecSchema,
for _, hc := range []struct{ kind, plural, schema string }{
{"Cluster", "clusters", "ClusterSpec"},
{"NodePool", "nodepools", "NodePoolSpec"},
} {
schemaRef := doc.Components.Schemas[hc.schema]
if schemaRef == nil {
return nil, fmt.Errorf("%s schema not found in OpenAPI spec", hc.schema)
}
schemas[hc.plural] = &ResourceSchema{
TypeName: hc.schema,
Schema: schemaRef,
}
}
// for _, kind := range requiredSpecValidationKinds {
// if !registeredKinds[kind] {
// return nil, fmt.Errorf(
// "entity kind %q with SpecSchemaName must be registered for schema validation",
// kind,
// )
// }
// }

return schemas, nil
}

// TODO : HYPERFLEET-1159 - Uncomment this once Cluster and NodePool are registered
// func isRequiredSpecValidationKind(kind string) bool {
// for _, required := range requiredSpecValidationKinds {
// if kind == required {
// return true
// }
// }
// return false
// }

// HasSchema reports whether a validation schema was loaded for the given resource plural.
func (v *SchemaValidator) HasSchema(resourcePlural string) bool {
return v.schemas[resourcePlural] != nil
Expand Down
Loading