This repository was archived by the owner on Sep 9, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 81
OSAC-704: Add catalog_item support to Cluster and ComputeInstance create flow #549
Merged
tzvatot
merged 10 commits into
osac-project:main
from
tzvatot:OSAC-58/catalog-items-resource-integration
May 19, 2026
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
97b23be
OSAC-704: add catalog_item field to ClusterSpec/ComputeInstanceSpec +…
tzvatot 111d4ab
OSAC-704: fix review findings in catalog item validation
tzvatot 09488db
OSAC-704: add catalog item support to ComputeInstance create flow
tzvatot 92d577b
OSAC-704: add catalog_item immutability check on Update
tzvatot d04a235
OSAC-704: add catalog item unit tests and fix JSON Schema validation
tzvatot e44b6f3
OSAC-704: address review findings in catalog item tests
tzvatot 03fde8c
OSAC-704: fix gofmt alignment in PrivateClustersServer struct
tzvatot 1576050
OSAC-704: address test review findings
tzvatot 160bec1
OSAC-704: address CodeRabbit review findings
tzvatot 108db5e
OSAC-704: fix Tenants→Tenant after upstream metadata field rename
tzvatot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
240 changes: 129 additions & 111 deletions
240
internal/api/osac/private/v1/cluster_type_protoopaque.pb.go
Large diffs are not rendered by default.
Oops, something went wrong.
255 changes: 138 additions & 117 deletions
255
internal/api/osac/private/v1/compute_instance_type.pb.go
Large diffs are not rendered by default.
Oops, something went wrong.
273 changes: 146 additions & 127 deletions
273
internal/api/osac/private/v1/compute_instance_type_protoopaque.pb.go
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
242 changes: 130 additions & 112 deletions
242
internal/api/osac/public/v1/cluster_type_protoopaque.pb.go
Large diffs are not rendered by default.
Oops, something went wrong.
260 changes: 140 additions & 120 deletions
260
internal/api/osac/public/v1/compute_instance_type.pb.go
Large diffs are not rendered by default.
Oops, something went wrong.
274 changes: 146 additions & 128 deletions
274
internal/api/osac/public/v1/compute_instance_type_protoopaque.pb.go
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| /* | ||
| Copyright (c) 2025 Red Hat Inc. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the | ||
| License. You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an | ||
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific | ||
| language governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| package servers | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "strings" | ||
|
|
||
| "github.com/santhosh-tekuri/jsonschema/v6" | ||
| grpccodes "google.golang.org/grpc/codes" | ||
| grpcstatus "google.golang.org/grpc/status" | ||
| "google.golang.org/protobuf/encoding/protojson" | ||
| "google.golang.org/protobuf/proto" | ||
| "google.golang.org/protobuf/types/known/structpb" | ||
|
|
||
| privatev1 "github.com/osac-project/fulfillment-service/internal/api/osac/private/v1" | ||
| ) | ||
|
|
||
| // catalogItem is implemented by both ClusterCatalogItem and ComputeInstanceCatalogItem. | ||
| type catalogItem interface { | ||
| proto.Message | ||
| GetPublished() bool | ||
| GetTemplate() string | ||
| GetFieldDefinitions() []*privatev1.FieldDefinition | ||
| GetMetadata() *privatev1.Metadata | ||
| } | ||
|
|
||
| // applyFieldDefinitions processes field definitions from a catalog item against a resource spec. | ||
| // For non-editable fields: overrides user-provided values with the catalog item default. | ||
| // For editable fields with user values: validates against the JSON Schema. | ||
| // For editable fields without user values: applies the catalog item default. | ||
| func applyFieldDefinitions( | ||
| spec proto.Message, | ||
| fieldDefinitions []*privatev1.FieldDefinition, | ||
| ) error { | ||
| if len(fieldDefinitions) == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| marshaller := protojson.MarshalOptions{UseProtoNames: true} | ||
| specJSON, err := marshaller.Marshal(spec) | ||
| if err != nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, "failed to marshal spec: %v", err) | ||
| } | ||
|
|
||
| var specMap map[string]any | ||
| if err := json.Unmarshal(specJSON, &specMap); err != nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, "failed to parse spec: %v", err) | ||
| } | ||
|
|
||
| compiler := jsonschema.NewCompiler() | ||
|
|
||
| for _, fd := range fieldDefinitions { | ||
| path := fd.GetPath() | ||
| if path == "" { | ||
| continue | ||
| } | ||
|
|
||
| defaultVal := fd.GetDefault() | ||
| userVal, userHasValue := getNestedValue(specMap, path) | ||
|
|
||
| if !fd.GetEditable() { | ||
| if defaultVal == nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, | ||
| "catalog item misconfigured: non-editable field '%s' has no default value", path) | ||
| } | ||
| if err := applyDefault(specMap, path, defaultVal); err != nil { | ||
| return err | ||
| } | ||
| } else { | ||
| if userHasValue && userVal != nil { | ||
| schema := fd.GetValidationSchema() | ||
| if schema != "" { | ||
| if err := validateAgainstSchema(compiler, path, userVal, schema); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| } else { | ||
| if err := applyDefault(specMap, path, defaultVal); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| updatedJSON, err := json.Marshal(specMap) | ||
| if err != nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, "failed to serialize updated spec: %v", err) | ||
| } | ||
|
|
||
| proto.Reset(spec) | ||
| if err := protojson.Unmarshal(updatedJSON, spec); err != nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, "failed to apply updated spec: %v", err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // validateCatalogItemAccess checks that a catalog item is published and not deleted. | ||
| // Tenant visibility is enforced by the GenericDAO's tenancy logic at the query level. | ||
| func validateCatalogItemAccess(item catalogItem, ref string) error { | ||
| if item.GetMetadata().HasDeletionTimestamp() { | ||
| return grpcstatus.Errorf(grpccodes.InvalidArgument, | ||
| "catalog item '%s' has been deleted", ref) | ||
| } | ||
| if !item.GetPublished() { | ||
| return grpcstatus.Errorf(grpccodes.NotFound, | ||
| "catalog item '%s' is not published", ref) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func applyDefault(specMap map[string]any, path string, defaultVal *structpb.Value) error { | ||
| if defaultVal == nil { | ||
| return nil | ||
| } | ||
| defaultAny, err := defaultVal.MarshalJSON() | ||
| if err != nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, | ||
| "failed to marshal default for field '%s': %v", path, err) | ||
| } | ||
| var parsed any | ||
| if err := json.Unmarshal(defaultAny, &parsed); err != nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, | ||
| "failed to parse default for field '%s': %v", path, err) | ||
| } | ||
| setNestedValue(specMap, path, parsed) | ||
| return nil | ||
| } | ||
|
|
||
| func validateAgainstSchema(compiler *jsonschema.Compiler, path string, value any, schemaStr string) error { | ||
| resourceName := "schema_" + strings.ReplaceAll(path, ".", "_") + ".json" | ||
| var schemaDoc any | ||
| if err := json.Unmarshal([]byte(schemaStr), &schemaDoc); err != nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, | ||
| "invalid validation schema for field '%s': %v", path, err) | ||
| } | ||
| if err := compiler.AddResource(resourceName, schemaDoc); err != nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, | ||
| "invalid validation schema for field '%s': %v", path, err) | ||
| } | ||
| schema, err := compiler.Compile(resourceName) | ||
| if err != nil { | ||
| return grpcstatus.Errorf(grpccodes.Internal, | ||
| "failed to compile validation schema for field '%s': %v", path, err) | ||
| } | ||
| if err := schema.Validate(value); err != nil { | ||
| return grpcstatus.Errorf(grpccodes.InvalidArgument, | ||
| "validation failed for field '%s': %v", path, err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func getNestedValue(m map[string]any, path string) (any, bool) { | ||
| parts := strings.Split(path, ".") | ||
| current := any(m) | ||
| for _, part := range parts { | ||
| currentMap, ok := current.(map[string]any) | ||
| if !ok { | ||
| return nil, false | ||
| } | ||
| current, ok = currentMap[part] | ||
| if !ok { | ||
| return nil, false | ||
| } | ||
| } | ||
| return current, true | ||
| } | ||
|
|
||
| func setNestedValue(m map[string]any, path string, value any) { | ||
| parts := strings.Split(path, ".") | ||
| current := m | ||
| for i, part := range parts { | ||
| if i == len(parts)-1 { | ||
| current[part] = value | ||
| return | ||
| } | ||
| next, ok := current[part] | ||
| if !ok { | ||
| next = map[string]any{} | ||
| current[part] = next | ||
| } | ||
| currentMap, ok := next.(map[string]any) | ||
| if !ok { | ||
| currentMap = map[string]any{} | ||
| current[part] = currentMap | ||
| } | ||
| current = currentMap | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.