diff --git a/Makefile b/Makefile index 0dad46a96b..1cab6c5e15 100644 --- a/Makefile +++ b/Makefile @@ -222,6 +222,10 @@ fmt: $(YAMLFMT) #EXHELP Formats code update-tls-profiles: $(GOJQ) #EXHELP Update TLS profiles from the Mozilla wiki env JQ=$(GOJQ) hack/tools/update-tls-profiles.sh +.PHONY: update-registryv1-bundle-schema +update-registryv1-bundle-schema: #EXHELP Update registry+v1 bundle configuration JSON schema + hack/tools/update-registryv1-bundle-schema.sh + .PHONY: verify-crd-compatibility CRD_DIFF_ORIGINAL_REF := git://main?path= CRD_DIFF_UPDATED_REF := file:// diff --git a/hack/tools/schema-generator/main.go b/hack/tools/schema-generator/main.go new file mode 100644 index 0000000000..cf684fc1f8 --- /dev/null +++ b/hack/tools/schema-generator/main.go @@ -0,0 +1,442 @@ +package main + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/operator-framework/api/pkg/operators/v1alpha1" +) + +const ( + schemaID = "https://operator-framework.io/schemas/registry-v1-bundle-config.json" + schemaDraft = "http://json-schema.org/draft-07/schema#" + schemaTitle = "Registry+v1 Bundle Configuration" + schemaDescription = "Configuration schema for registry+v1 bundles. Includes watchNamespace for controlling operator scope and deploymentConfig for customizing operator deployment (environment variables, resource scheduling, storage, and pod placement). The deploymentConfig follows the same structure and behavior as OLM v0's SubscriptionConfig. Note: The 'selector' field from v0's SubscriptionConfig is not included as it was never used." +) + +// Schema represents a JSON Schema Draft 7 document +type Schema struct { + Schema string `json:"$schema"` + ID string `json:"$id"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + Properties map[string]*Property `json:"properties,omitempty"` + AdditionalProperties interface{} `json:"additionalProperties,omitempty"` // can be bool or Property + Defs map[string]*Property `json:"$defs,omitempty"` + Required []string `json:"required,omitempty"` +} + +// Property represents a JSON Schema property +type Property struct { + Type interface{} `json:"type,omitempty"` // can be string or array of types + Description string `json:"description,omitempty"` + Properties map[string]*Property `json:"properties,omitempty"` + AdditionalProperties interface{} `json:"additionalProperties,omitempty"` // can be bool or Property + Items *Property `json:"items,omitempty"` + Required []string `json:"required,omitempty"` + Enum []string `json:"enum,omitempty"` + OneOf []*Property `json:"oneOf,omitempty"` + AnyOf []*Property `json:"anyOf,omitempty"` + Ref string `json:"$ref,omitempty"` + Format string `json:"format,omitempty"` + Minimum *int `json:"minimum,omitempty"` + Maximum *int `json:"maximum,omitempty"` +} + +type schemaGenerator struct { + defs map[string]*Property + // Track types we've already processed to avoid infinite recursion + processedTypes map[string]bool + // Cache of field documentation extracted from Go source + fieldDocs map[string]map[string]string // typeName -> fieldName -> doc +} + +func newSchemaGenerator() *schemaGenerator { + return &schemaGenerator{ + defs: make(map[string]*Property), + processedTypes: make(map[string]bool), + fieldDocs: make(map[string]map[string]string), + } +} + +func main() { + if len(os.Args) != 4 { + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + os.Exit(1) + } + + subscriptionTypesFile := os.Args[1] + coreV1TypesFile := os.Args[2] + outputFile := os.Args[3] + + gen := newSchemaGenerator() + + // Extract documentation from source files + if err := gen.extractDocumentation(subscriptionTypesFile, coreV1TypesFile); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Could not extract documentation: %v\n", err) + // Continue anyway, schema will just lack descriptions + } + + schema := gen.generateSchema() + + // Marshal to JSON with indentation + data, err := json.MarshalIndent(schema, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling schema: %v\n", err) + os.Exit(1) + } + + // Ensure output directory exists + dir := filepath.Dir(outputFile) + if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) + os.Exit(1) + } + + // Write to file + if err := os.WriteFile(outputFile, data, 0600); err != nil { + fmt.Fprintf(os.Stderr, "Error writing schema file: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Successfully generated schema at %s\n", outputFile) +} + +func (g *schemaGenerator) extractDocumentation(subscriptionTypesPath, coreV1Path string) error { + // Parse SubscriptionConfig documentation + if err := g.parseSourceFile(subscriptionTypesPath, "SubscriptionConfig"); err != nil { + return fmt.Errorf("error parsing SubscriptionConfig: %w", err) + } + + // Parse corev1 types documentation + _ = g.parseSourceFile(coreV1Path, "Toleration") + _ = g.parseSourceFile(coreV1Path, "ResourceRequirements") + _ = g.parseSourceFile(coreV1Path, "EnvVar") + _ = g.parseSourceFile(coreV1Path, "EnvFromSource") + _ = g.parseSourceFile(coreV1Path, "Volume") + _ = g.parseSourceFile(coreV1Path, "VolumeMount") + _ = g.parseSourceFile(coreV1Path, "Affinity") + _ = g.parseSourceFile(coreV1Path, "PodAffinityTerm") + + return nil +} + +func (g *schemaGenerator) parseSourceFile(filepath, structName string) error { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filepath, nil, parser.ParseComments) + if err != nil { + return err + } + + // Find the struct declaration + ast.Inspect(node, func(n ast.Node) bool { + typeSpec, ok := n.(*ast.TypeSpec) + if !ok || typeSpec.Name.Name != structName { + return true + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return true + } + + // Extract field documentation + fieldMap := make(map[string]string) + for _, field := range structType.Fields.List { + if field.Names == nil { + continue + } + + fieldName := field.Names[0].Name + doc := "" + + if field.Doc != nil { + doc = strings.TrimSpace(field.Doc.Text()) + } else if field.Comment != nil { + doc = strings.TrimSpace(field.Comment.Text()) + } + + // Clean up the documentation + doc = strings.ReplaceAll(doc, "\n", " ") + doc = strings.TrimSpace(doc) + + fieldMap[fieldName] = doc + } + + g.fieldDocs[structName] = fieldMap + return false + }) + + return nil +} + +func (g *schemaGenerator) generateSchema() *Schema { + // Get the SubscriptionConfig type + configType := reflect.TypeOf(v1alpha1.SubscriptionConfig{}) + + schema := &Schema{ + Schema: schemaDraft, + ID: schemaID, + Title: schemaTitle, + Description: schemaDescription, + Type: "object", + Properties: make(map[string]*Property), + AdditionalProperties: false, + } + + // Add watchNamespace property (base definition - will be modified at runtime based on install modes) + schema.Properties["watchNamespace"] = &Property{ + Description: "The namespace that the operator should watch for custom resources. The meaning and validation of this field depends on the operator's install modes. This field may be optional or required, and may have format constraints, based on the operator's supported install modes.", + AnyOf: []*Property{ + {Type: "null"}, + {Type: "string"}, + }, + } + + // Create deploymentConfig property + deploymentConfigProp := &Property{ + Type: "object", + Description: "Configuration for customizing operator deployment (environment variables, resources, volumes, etc.)", + Properties: make(map[string]*Property), + AdditionalProperties: false, + } + + // Process all fields in SubscriptionConfig into deploymentConfig.properties + for i := 0; i < configType.NumField(); i++ { + field := configType.Field(i) + + // Skip the Selector field as per RFC + if field.Name == "Selector" { + continue + } + + // Get JSON tag + jsonTag := field.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + continue + } + + // Parse JSON tag (format: "name,omitempty") + jsonName := strings.Split(jsonTag, ",")[0] + + // Get description from extracted documentation + description := g.getFieldDescription("SubscriptionConfig", field.Name) + + // Generate property schema + prop := g.generateProperty(field.Type, description) + if prop != nil { + deploymentConfigProp.Properties[jsonName] = prop + } + } + + schema.Properties["deploymentConfig"] = deploymentConfigProp + + // Add definitions if any were created + if len(g.defs) > 0 { + schema.Defs = g.defs + } + + return schema +} + +func (g *schemaGenerator) generateProperty(t reflect.Type, description string) *Property { + // Handle pointers + if t.Kind() == reflect.Ptr { + return g.generateProperty(t.Elem(), description) + } + + prop := &Property{Description: description} + + switch t.Kind() { + case reflect.String: + prop.Type = "string" + + case reflect.Bool: + prop.Type = "boolean" + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + prop.Type = "integer" + if t.Kind() == reflect.Int32 || t.Kind() == reflect.Uint32 { + prop.Format = "int32" + } else if t.Kind() == reflect.Int64 || t.Kind() == reflect.Uint64 { + prop.Format = "int64" + } + + case reflect.Float32, reflect.Float64: + prop.Type = "number" + + case reflect.Slice, reflect.Array: + prop.Type = "array" + elemType := t.Elem() + prop.Items = g.generateProperty(elemType, "") + + case reflect.Map: + prop.Type = "object" + // For maps, use additionalProperties to define value type + valueType := t.Elem() + valueProp := g.generateProperty(valueType, "") + prop.AdditionalProperties = valueProp + + case reflect.Struct: + return g.generateStructProperty(t, description) + + default: + // Unknown type, treat as generic object + prop.Type = "object" + } + + return prop +} + +func (g *schemaGenerator) generateStructProperty(t reflect.Type, description string) *Property { + // Handle resource.Quantity as string or number + if t.PkgPath() == "k8s.io/apimachinery/pkg/api/resource" && t.Name() == "Quantity" { + return &Property{ + Description: description, + OneOf: []*Property{ + {Type: "string"}, + {Type: "number"}, + }, + } + } + + // Check if we should create a definition + defName := g.getDefinitionName(t) + if defName != "" && !g.processedTypes[defName] { + g.processedTypes[defName] = true + g.defs[defName] = g.generateStructSchema(t) + return &Property{ + Description: description, + Ref: fmt.Sprintf("#/$defs/%s", defName), + } + } else if defName != "" && g.processedTypes[defName] { + // Already processed, just reference it + return &Property{ + Description: description, + Ref: fmt.Sprintf("#/$defs/%s", defName), + } + } + + // Generate inline struct schema + return g.generateStructSchema(t) +} + +func (g *schemaGenerator) generateStructSchema(t reflect.Type) *Property { + prop := &Property{ + Type: "object", + Properties: make(map[string]*Property), + } + + var required []string + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + // Get JSON tag + jsonTag := field.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + continue + } + + // Parse JSON tag + parts := strings.Split(jsonTag, ",") + jsonName := parts[0] + + // Check for inline fields (embedded structs with json:",inline") + isInline := false + for _, part := range parts { + if part == "inline" { + isInline = true + break + } + } + + // Handle inline fields by merging their properties + if isInline && field.Type.Kind() == reflect.Struct { + inlinedProp := g.generateStructSchema(field.Type) + // Merge inlined properties into parent + for k, v := range inlinedProp.Properties { + prop.Properties[k] = v + } + // Merge required fields + required = append(required, inlinedProp.Required...) + continue + } + + // Skip fields with empty JSON name (shouldn't happen after inline handling) + if jsonName == "" { + continue + } + + // Check if field is required (no omitempty) + isRequired := true + for _, part := range parts[1:] { + if part == "omitempty" { + isRequired = false + break + } + } + + // Get field description from extracted documentation + fieldDesc := g.getFieldDescription(t.Name(), field.Name) + + // Generate field property + fieldProp := g.generateProperty(field.Type, fieldDesc) + if fieldProp != nil { + prop.Properties[jsonName] = fieldProp + + if isRequired { + required = append(required, jsonName) + } + } + } + + if len(required) > 0 { + prop.Required = required + } + + return prop +} + +func (g *schemaGenerator) getDefinitionName(t reflect.Type) string { + // Create definitions for reusable types like PodAffinityTerm + if t.PkgPath() == "k8s.io/api/core/v1" { + switch t.Name() { + case "PodAffinityTerm": + return "podAffinityTerm" + } + } + return "" +} + +func (g *schemaGenerator) getFieldDescription(structName, fieldName string) string { + if docs, ok := g.fieldDocs[structName]; ok { + if doc, ok := docs[fieldName]; ok { + return doc + } + } + return "" +} + +// Register types to ensure they're available for reflection +var _ = v1alpha1.SubscriptionConfig{} +var _ = corev1.Toleration{} +var _ = corev1.ResourceRequirements{} +var _ = corev1.EnvVar{} +var _ = corev1.EnvFromSource{} +var _ = corev1.Volume{} +var _ = corev1.VolumeMount{} +var _ = corev1.Affinity{} +var _ = resource.Quantity{} diff --git a/hack/tools/schema-generator/main_test.go b/hack/tools/schema-generator/main_test.go new file mode 100644 index 0000000000..11b6fce257 --- /dev/null +++ b/hack/tools/schema-generator/main_test.go @@ -0,0 +1,238 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSchemaStructure validates the structure of the generated schema +// without comparing it to the committed file. This ensures the generator +// produces valid JSON Schema with expected properties. +func TestSchemaStructure(t *testing.T) { + gen := newSchemaGenerator() + schema := gen.generateSchema() + + t.Run("schema has correct metadata", func(t *testing.T) { + assert.Equal(t, "http://json-schema.org/draft-07/schema#", schema.Schema) + assert.Equal(t, schemaID, schema.ID) + assert.Equal(t, schemaTitle, schema.Title) + assert.NotEmpty(t, schema.Description) + assert.Equal(t, "object", schema.Type) + }) + + t.Run("schema includes expected top-level properties", func(t *testing.T) { + require.NotNil(t, schema.Properties) + + // Top-level properties should be watchNamespace and deploymentConfig + assert.Contains(t, schema.Properties, "watchNamespace", "schema should include watchNamespace field") + assert.Contains(t, schema.Properties, "deploymentConfig", "schema should include deploymentConfig field") + }) + + t.Run("deploymentConfig includes expected fields", func(t *testing.T) { + deploymentConfig := schema.Properties["deploymentConfig"] + require.NotNil(t, deploymentConfig, "deploymentConfig should be present") + require.NotNil(t, deploymentConfig.Properties, "deploymentConfig should have properties") + + // All fields from SubscriptionConfig except Selector + expectedFields := []string{ + "nodeSelector", + "tolerations", + "resources", + "env", + "envFrom", + "volumes", + "volumeMounts", + "affinity", + "annotations", + } + + for _, field := range expectedFields { + assert.Contains(t, deploymentConfig.Properties, field, "deploymentConfig should include %s field", field) + } + }) + + t.Run("deploymentConfig excludes selector field", func(t *testing.T) { + deploymentConfig := schema.Properties["deploymentConfig"] + require.NotNil(t, deploymentConfig) + assert.NotContains(t, deploymentConfig.Properties, "selector", "selector field should be excluded per RFC") + }) + + t.Run("watchNamespace has correct schema", func(t *testing.T) { + watchNamespace := schema.Properties["watchNamespace"] + require.NotNil(t, watchNamespace, "watchNamespace should be present") + + // Should have anyOf with null and string + require.NotNil(t, watchNamespace.AnyOf, "watchNamespace should have anyOf") + assert.Len(t, watchNamespace.AnyOf, 2, "watchNamespace anyOf should have 2 options") + + // Check that it includes null and string options + var hasNull, hasString bool + for _, option := range watchNamespace.AnyOf { + if option.Type == "null" { + hasNull = true + } + if option.Type == "string" { + hasString = true + } + } + assert.True(t, hasNull, "watchNamespace should allow null") + assert.True(t, hasString, "watchNamespace should allow string") + }) + + t.Run("nodeSelector has correct schema", func(t *testing.T) { + deploymentConfig := schema.Properties["deploymentConfig"] + require.NotNil(t, deploymentConfig) + + nodeSelector := deploymentConfig.Properties["nodeSelector"] + require.NotNil(t, nodeSelector) + + assert.Equal(t, "object", nodeSelector.Type) + assert.NotNil(t, nodeSelector.AdditionalProperties) + + // Should accept string values (map[string]string) + addlProps, ok := nodeSelector.AdditionalProperties.(*Property) + require.True(t, ok, "additionalProperties should be a Property") + assert.Equal(t, "string", addlProps.Type) + }) + + t.Run("tolerations has correct schema", func(t *testing.T) { + deploymentConfig := schema.Properties["deploymentConfig"] + require.NotNil(t, deploymentConfig) + + tolerations := deploymentConfig.Properties["tolerations"] + require.NotNil(t, tolerations) + + assert.Equal(t, "array", tolerations.Type) + require.NotNil(t, tolerations.Items) + + // Items should be objects with key, operator, value, effect, tolerationSeconds + item := tolerations.Items + assert.Equal(t, "object", item.Type) + require.NotNil(t, item.Properties) + + expectedItemFields := []string{"key", "operator", "value", "effect", "tolerationSeconds"} + for _, field := range expectedItemFields { + assert.Contains(t, item.Properties, field, "toleration item should include %s", field) + } + }) + + t.Run("env items handle inline fields correctly", func(t *testing.T) { + deploymentConfig := schema.Properties["deploymentConfig"] + require.NotNil(t, deploymentConfig) + + env := deploymentConfig.Properties["env"] + require.NotNil(t, env) + + item := env.Items + require.NotNil(t, item) + + valueFrom := item.Properties["valueFrom"] + require.NotNil(t, valueFrom) + + secretKeyRef := valueFrom.Properties["secretKeyRef"] + require.NotNil(t, secretKeyRef) + require.NotNil(t, secretKeyRef.Properties) + + // LocalObjectReference is inlined, so "name" should be at this level + assert.Contains(t, secretKeyRef.Properties, "name", "inline LocalObjectReference should merge 'name' field") + assert.Contains(t, secretKeyRef.Properties, "key") + assert.Contains(t, secretKeyRef.Properties, "optional") + + // key should be required + assert.Contains(t, secretKeyRef.Required, "key") + }) + + t.Run("volumes handle inline fields correctly", func(t *testing.T) { + deploymentConfig := schema.Properties["deploymentConfig"] + require.NotNil(t, deploymentConfig) + + volumes := deploymentConfig.Properties["volumes"] + require.NotNil(t, volumes) + + item := volumes.Items + require.NotNil(t, item) + + // ConfigMap volume should have inline LocalObjectReference + configMap := item.Properties["configMap"] + require.NotNil(t, configMap) + assert.Contains(t, configMap.Properties, "name", "configMap should have inlined 'name' from LocalObjectReference") + }) + + t.Run("podAffinityTerm is defined as reusable definition", func(t *testing.T) { + require.NotNil(t, schema.Defs, "schema should have $defs for reusable types") + assert.Contains(t, schema.Defs, "podAffinityTerm") + + podAffinityTerm := schema.Defs["podAffinityTerm"] + require.NotNil(t, podAffinityTerm) + + assert.Equal(t, "object", podAffinityTerm.Type) + require.NotNil(t, podAffinityTerm.Properties) + + // Should have topologyKey (required) + assert.Contains(t, podAffinityTerm.Properties, "topologyKey") + assert.Contains(t, podAffinityTerm.Required, "topologyKey") + }) + + t.Run("resources property supports Quantity types", func(t *testing.T) { + deploymentConfig := schema.Properties["deploymentConfig"] + require.NotNil(t, deploymentConfig) + + resources := deploymentConfig.Properties["resources"] + require.NotNil(t, resources) + + assert.Equal(t, "object", resources.Type) + require.NotNil(t, resources.Properties) + + // Should have limits and requests + limits := resources.Properties["limits"] + require.NotNil(t, limits) + + // Limits should be an object with additionalProperties that accept string or number + assert.Equal(t, "object", limits.Type) + limitsAddl, ok := limits.AdditionalProperties.(*Property) + require.True(t, ok) + assert.NotNil(t, limitsAddl.OneOf, "resource limits should accept string or number (Quantity)") + assert.Len(t, limitsAddl.OneOf, 2) + }) +} + +// TestSchemaIsValidJSON verifies that the generated schema is valid JSON +// and can be marshaled/unmarshaled without errors. +func TestSchemaIsValidJSON(t *testing.T) { + gen := newSchemaGenerator() + schema := gen.generateSchema() + + // Marshal to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err, "should marshal schema to JSON") + + // Unmarshal back to verify it's valid + var unmarshaled map[string]any + err = json.Unmarshal(data, &unmarshaled) + require.NoError(t, err, "generated JSON should be valid and unmarshalable") + + // Verify key top-level fields exist + assert.Contains(t, unmarshaled, "$schema") + assert.Contains(t, unmarshaled, "$id") + assert.Contains(t, unmarshaled, "type") + assert.Contains(t, unmarshaled, "properties") +} + +// TestGeneratorExcludesSelectorField is a focused test ensuring we meet +// the RFC requirement to exclude the selector field. +func TestGeneratorExcludesSelectorField(t *testing.T) { + gen := newSchemaGenerator() + schema := gen.generateSchema() + + // This is a critical requirement from the RFC + require.NotNil(t, schema.Properties) + deploymentConfig := schema.Properties["deploymentConfig"] + require.NotNil(t, deploymentConfig) + require.NotNil(t, deploymentConfig.Properties) + + assert.NotContains(t, deploymentConfig.Properties, "selector", + "RFC requirement: selector field must be excluded from DeploymentConfig schema") +} diff --git a/hack/tools/update-registryv1-bundle-schema.sh b/hack/tools/update-registryv1-bundle-schema.sh new file mode 100755 index 0000000000..210016bac9 --- /dev/null +++ b/hack/tools/update-registryv1-bundle-schema.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -e + +# This script generates the registry+v1 bundle configuration JSON schema +# by introspecting v1alpha1.SubscriptionConfig from github.com/operator-framework/api + +# Source files +SUBSCRIPTION_TYPES="vendor/github.com/operator-framework/api/pkg/operators/v1alpha1/subscription_types.go" +COREV1_TYPES="vendor/k8s.io/api/core/v1/types.go" + +# Output file +SCHEMA_OUTPUT="internal/operator-controller/rukpak/bundle/schema/registryv1bundleconfig.json" + +# Verify required source files exist +if [[ ! -f "${SUBSCRIPTION_TYPES}" ]]; then + echo "Error: ${SUBSCRIPTION_TYPES} not found. Run 'go mod vendor' first." + exit 1 +fi + +if [[ ! -f "${COREV1_TYPES}" ]]; then + echo "Error: ${COREV1_TYPES} not found. Run 'go mod vendor' first." + exit 1 +fi + +echo "$(date '+%Y/%m/%d %T') Generating registry+v1 bundle configuration JSON schema..." + +# Run the schema generator +go run ./hack/tools/schema-generator "${SUBSCRIPTION_TYPES}" "${COREV1_TYPES}" "${SCHEMA_OUTPUT}" + +echo "$(date '+%Y/%m/%d %T') Schema generation complete: ${SCHEMA_OUTPUT}" diff --git a/internal/operator-controller/config/config.go b/internal/operator-controller/config/config.go index 8fcadf40ad..02fa51d960 100644 --- a/internal/operator-controller/config/config.go +++ b/internal/operator-controller/config/config.go @@ -98,6 +98,29 @@ func (c *Config) GetWatchNamespace() *string { return &str } +// GetDeploymentConfig returns the deploymentConfig value if present in the configuration. +// Returns nil if deploymentConfig is not set or is explicitly set to null. +// The returned value is a generic map[string]any that can be marshaled to JSON +// for validation or conversion to specific types (like v1alpha1.SubscriptionConfig). +func (c *Config) GetDeploymentConfig() map[string]any { + if c == nil || *c == nil { + return nil + } + val, exists := (*c)["deploymentConfig"] + if !exists { + return nil + } + // User set deploymentConfig: null - treat as "not configured" + if val == nil { + return nil + } + // Schema validation ensures this is an object (map) + if dcMap, ok := val.(map[string]any); ok { + return dcMap + } + return nil +} + // UnmarshalConfig takes user configuration, validates it, and creates a Config object. // This is the only way to create a Config. // diff --git a/internal/operator-controller/config/config_test.go b/internal/operator-controller/config/config_test.go index 95bb98f0b4..97216679b0 100644 --- a/internal/operator-controller/config/config_test.go +++ b/internal/operator-controller/config/config_test.go @@ -572,3 +572,83 @@ type mockEmptySchemaBundle struct{} func (e *mockEmptySchemaBundle) GetConfigSchema() (map[string]any, error) { return nil, nil } + +// Test_GetDeploymentConfig tests the GetDeploymentConfig accessor method. +func Test_GetDeploymentConfig(t *testing.T) { + // Create a simple bundle that accepts any config (empty schema) + bundle := &mockEmptySchemaBundle{} + + tests := []struct { + name string + rawConfig []byte + expectedDeploymentConfig map[string]any + expectedDeploymentConfigNil bool + }{ + { + name: "empty config returns nil", + rawConfig: []byte(`{}`), + expectedDeploymentConfigNil: true, + }, + { + name: "config without deploymentConfig field returns nil", + rawConfig: []byte(`{"watchNamespace": "test-ns"}`), + expectedDeploymentConfigNil: true, + }, + { + name: "config with null deploymentConfig returns nil", + rawConfig: []byte(`{"deploymentConfig": null}`), + expectedDeploymentConfigNil: true, + }, + { + name: "config with valid deploymentConfig returns the object", + rawConfig: []byte(`{ + "deploymentConfig": { + "nodeSelector": { + "kubernetes.io/os": "linux" + }, + "resources": { + "requests": { + "memory": "128Mi" + } + } + } + }`), + expectedDeploymentConfig: map[string]any{ + "nodeSelector": map[string]any{ + "kubernetes.io/os": "linux", + }, + "resources": map[string]any{ + "requests": map[string]any{ + "memory": "128Mi", + }, + }, + }, + expectedDeploymentConfigNil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema, err := bundle.GetConfigSchema() + require.NoError(t, err) + + cfg, err := config.UnmarshalConfig(tt.rawConfig, schema, "") + require.NoError(t, err) + + result := cfg.GetDeploymentConfig() + if tt.expectedDeploymentConfigNil { + require.Nil(t, result) + } else { + require.NotNil(t, result) + require.Equal(t, tt.expectedDeploymentConfig, result) + } + }) + } + + // Test nil config separately + t.Run("nil config returns nil", func(t *testing.T) { + var cfg *config.Config + result := cfg.GetDeploymentConfig() + require.Nil(t, result) + }) +} diff --git a/internal/operator-controller/rukpak/bundle/registryv1.go b/internal/operator-controller/rukpak/bundle/registryv1.go index 7fc3e3e185..d45052232a 100644 --- a/internal/operator-controller/rukpak/bundle/registryv1.go +++ b/internal/operator-controller/rukpak/bundle/registryv1.go @@ -1,6 +1,8 @@ package bundle import ( + "fmt" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/sets" @@ -8,10 +10,12 @@ import ( "github.com/operator-framework/api/pkg/operators/v1alpha1" "github.com/operator-framework/operator-controller/internal/operator-controller/config" + bundleSchema "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/schema" ) const ( - BundleConfigWatchNamespaceKey = "watchNamespace" + BundleConfigWatchNamespaceKey = "watchNamespace" + BundleConfigDeploymentConfigKey = "deploymentConfig" ) type RegistryV1 struct { @@ -31,38 +35,50 @@ func (rv1 *RegistryV1) GetConfigSchema() (map[string]any, error) { return buildBundleConfigSchema(installModes) } -// buildBundleConfigSchema creates validation rules based on what the operator supports. +// buildBundleConfigSchema loads the base bundle config schema and modifies it based on +// the operator's install modes. // -// Examples of how install modes affect validation: -// - AllNamespaces only: user can't set watchNamespace (operator watches everything) -// - OwnNamespace only: user must set watchNamespace to the install namespace -// - SingleNamespace only: user must set watchNamespace to a different namespace -// - AllNamespaces + OwnNamespace: user can optionally set watchNamespace +// The base schema includes +// 1. watchNamespace +// 2. deploymentConfig properties. +// The watchNamespace property is modified based on what the operator supports: +// - AllNamespaces only: remove watchNamespace (operator always watches everything) +// - OwnNamespace only: make watchNamespace required, must equal install namespace +// - SingleNamespace only: make watchNamespace required, must differ from install namespace +// - AllNamespaces + OwnNamespace: make watchNamespace optional func buildBundleConfigSchema(installModes sets.Set[v1alpha1.InstallMode]) (map[string]any, error) { - schema := map[string]any{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "additionalProperties": false, // Reject unknown fields (catches typos and misconfigurations) + // Load the base schema + baseSchema, err := bundleSchema.GetBundleConfigSchemaMap() + if err != nil { + return nil, fmt.Errorf("failed to get base bundle config schema: %w", err) } - properties := map[string]any{} - var required []any + // Get properties map from the schema + properties, ok := baseSchema["properties"].(map[string]any) + if !ok { + return nil, fmt.Errorf("base schema missing properties") + } - // Add watchNamespace property if the bundle supports it + // Modify watchNamespace field based on install modes if isWatchNamespaceConfigurable(installModes) { + // Replace the generic watchNamespace with install-mode-specific version watchNSProperty, isRequired := buildWatchNamespaceProperty(installModes) properties["watchNamespace"] = watchNSProperty + if isRequired { - required = append(required, "watchNamespace") + baseSchema["required"] = []any{"watchNamespace"} + } else { + // Ensure no required field if it's optional + delete(baseSchema, "required") } + } else { + // AllNamespaces only - remove watchNamespace property entirely + // (operator always watches all namespaces, no configuration needed) + delete(properties, "watchNamespace") + delete(baseSchema, "required") } - schema["properties"] = properties - if len(required) > 0 { - schema["required"] = required - } - - return schema, nil + return baseSchema, nil } // buildWatchNamespaceProperty creates the validation rules for the watchNamespace field. diff --git a/internal/operator-controller/rukpak/bundle/schema/README.md b/internal/operator-controller/rukpak/bundle/schema/README.md new file mode 100644 index 0000000000..fdd78957ae --- /dev/null +++ b/internal/operator-controller/rukpak/bundle/schema/README.md @@ -0,0 +1,51 @@ +# Registry+v1 Bundle Configuration JSON Schema + +This directory contains the JSON schema for registry+v1 bundle configuration validation. + +## Overview + +The `registryv1bundleconfig.json` schema is used to validate the bundle configuration in the ClusterExtension's inline configuration. This includes: + +- `watchNamespace`: Controls which namespace(s) the operator watches for custom resources +- `deploymentConfig`: Customizes operator deployment (environment variables, resources, volumes, etc.) + +The `deploymentConfig` portion is based on OLM v0's `SubscriptionConfig` struct but excludes the `selector` field which was never used in v0. + +## Schema Generation + +The schema in `registryv1bundleconfig.json` is a frozen snapshot that provides stability for validation. It is based on the `v1alpha1.SubscriptionConfig` type from `github.com/operator-framework/api/pkg/operators/v1alpha1/subscription_types.go`. + +### Fields Included + +- `nodeSelector`: Map of node selector labels +- `tolerations`: Array of pod tolerations +- `resources`: Container resource requirements (requests/limits) +- `envFrom`: Environment variables from ConfigMaps/Secrets +- `env`: Individual environment variables +- `volumes`: Pod volumes +- `volumeMounts`: Container volume mounts +- `affinity`: Pod affinity/anti-affinity rules +- `annotations`: Custom annotations for deployments/pods + +### Fields Excluded + +- `selector`: This field exists in v0's `SubscriptionConfig` but is never used by the v0 controller. It has been intentionally excluded from the v1 schema. + +## Regenerating the Schema + +To regenerate the schema when the `github.com/operator-framework/api` dependency is updated: + +```bash +make update-registryv1-bundle-schema +``` + +This will regenerate the schema based on the current version of `v1alpha1.SubscriptionConfig` in the vendor directory. + +## Validation + +The schema is used to validate user-provided bundle configuration (including `watchNamespace` and `deploymentConfig`) in ClusterExtension resources. The base schema is loaded and customized at runtime based on the operator's install modes to ensure proper validation of the `watchNamespace` field. Validation happens during: + +1. **Admission**: When a ClusterExtension is created or updated +2. **Runtime**: When extracting configuration from the inline field + +Validation errors provide clear, semantic feedback to users about what fields are invalid and why. diff --git a/internal/operator-controller/rukpak/bundle/schema/registryv1bundleconfig.json b/internal/operator-controller/rukpak/bundle/schema/registryv1bundleconfig.json new file mode 100644 index 0000000000..4b17d6b133 --- /dev/null +++ b/internal/operator-controller/rukpak/bundle/schema/registryv1bundleconfig.json @@ -0,0 +1,1881 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://operator-framework.io/schemas/registry-v1-bundle-config.json", + "title": "Registry+v1 Bundle Configuration", + "description": "Configuration schema for registry+v1 bundles. Includes watchNamespace for controlling operator scope and deploymentConfig for customizing operator deployment (environment variables, resource scheduling, storage, and pod placement). The deploymentConfig follows the same structure and behavior as OLM v0's SubscriptionConfig. Note: The 'selector' field from v0's SubscriptionConfig is not included as it was never used.", + "type": "object", + "properties": { + "deploymentConfig": { + "type": "object", + "description": "Configuration for customizing operator deployment (environment variables, resources, volumes, etc.)", + "properties": { + "affinity": { + "type": "object", + "properties": { + "nodeAffinity": { + "type": "object", + "properties": { + "preferredDuringSchedulingIgnoredDuringExecution": { + "type": "array", + "items": { + "type": "object", + "properties": { + "preference": { + "type": "object", + "properties": { + "matchExpressions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "operator" + ] + } + }, + "matchFields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "operator" + ] + } + } + } + }, + "weight": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "weight", + "preference" + ] + } + }, + "requiredDuringSchedulingIgnoredDuringExecution": { + "type": "object", + "properties": { + "nodeSelectorTerms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "matchExpressions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "operator" + ] + } + }, + "matchFields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "operator" + ] + } + } + } + } + } + }, + "required": [ + "nodeSelectorTerms" + ] + } + } + }, + "podAffinity": { + "type": "object", + "properties": { + "preferredDuringSchedulingIgnoredDuringExecution": { + "type": "array", + "items": { + "type": "object", + "properties": { + "podAffinityTerm": { + "$ref": "#/$defs/podAffinityTerm" + }, + "weight": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "weight", + "podAffinityTerm" + ] + } + }, + "requiredDuringSchedulingIgnoredDuringExecution": { + "type": "array", + "items": { + "$ref": "#/$defs/podAffinityTerm" + } + } + } + }, + "podAntiAffinity": { + "type": "object", + "properties": { + "preferredDuringSchedulingIgnoredDuringExecution": { + "type": "array", + "items": { + "type": "object", + "properties": { + "podAffinityTerm": { + "$ref": "#/$defs/podAffinityTerm" + }, + "weight": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "weight", + "podAffinityTerm" + ] + } + }, + "requiredDuringSchedulingIgnoredDuringExecution": { + "type": "array", + "items": { + "$ref": "#/$defs/podAffinityTerm" + } + } + } + } + } + }, + "annotations": { + "type": "object", + "description": "Annotations is an unstructured key value map stored with each Deployment, Pod, APIService in the Operator. Typically, annotations may be set by external tools to store and retrieve arbitrary metadata. Use this field to pre-define annotations that OLM should add to each of the Subscription's deployments, pods, and apiservices. +optional", + "additionalProperties": { + "type": "string" + } + }, + "env": { + "type": "array", + "description": "Env is a list of environment variables to set in the container. Cannot be updated. +patchMergeKey=name +patchStrategy=merge +optional", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the environment variable. May consist of any printable ASCII characters except '='." + }, + "value": { + "type": "string", + "description": "Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and any service environment variables. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. \"$$(VAR_NAME)\" will produce the string literal \"$(VAR_NAME)\". Escaped references will never be expanded, regardless of whether the variable exists or not. Defaults to \"\". +optional" + }, + "valueFrom": { + "type": "object", + "properties": { + "configMapKeyRef": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "optional": { + "type": "boolean" + } + }, + "required": [ + "key" + ] + }, + "fieldRef": { + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "fieldPath": { + "type": "string" + } + }, + "required": [ + "fieldPath" + ] + }, + "fileKeyRef": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "volumeName": { + "type": "string" + } + }, + "required": [ + "volumeName", + "path", + "key" + ] + }, + "resourceFieldRef": { + "type": "object", + "properties": { + "containerName": { + "type": "string" + }, + "divisor": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "resource": { + "type": "string" + } + }, + "required": [ + "resource" + ] + }, + "secretKeyRef": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "optional": { + "type": "boolean" + } + }, + "required": [ + "key" + ] + } + } + } + }, + "required": [ + "name" + ] + } + }, + "envFrom": { + "type": "array", + "description": "EnvFrom is a list of sources to populate environment variables in the container. The keys defined within a source must be a C_IDENTIFIER. All invalid keys will be reported as an event when the container is starting. When a key exists in multiple sources, the value associated with the last source will take precedence. Values defined by an Env with a duplicate key will take precedence. Immutable. +optional", + "items": { + "type": "object", + "properties": { + "configMapRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "optional": { + "type": "boolean" + } + } + }, + "prefix": { + "type": "string", + "description": "Optional text to prepend to the name of each environment variable. May consist of any printable ASCII characters except '='. +optional" + }, + "secretRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "optional": { + "type": "boolean" + } + } + } + } + } + }, + "nodeSelector": { + "type": "object", + "description": "NodeSelector is a selector which must be true for the pod to fit on a node. Selector which must match a node's labels for the pod to be scheduled on that node. More info: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/ +optional", + "additionalProperties": { + "type": "string" + } + }, + "resources": { + "type": "object", + "properties": { + "claims": { + "type": "array", + "description": "Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. This field depends on the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. +listType=map +listMapKey=name +featureGate=DynamicResourceAllocation +optional", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "request": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + }, + "limits": { + "type": "object", + "description": "Limits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ +optional", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "requests": { + "type": "object", + "description": "Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. Requests cannot exceed Limits. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ +optional", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + } + }, + "tolerations": { + "type": "array", + "description": "Tolerations are the pod's tolerations. +optional", + "items": { + "type": "object", + "properties": { + "effect": { + "type": "string", + "description": "Effect indicates the taint effect to match. Empty means match all taint effects. When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. +optional" + }, + "key": { + "type": "string", + "description": "Key is the taint key that the toleration applies to. Empty means match all taint keys. If the key is empty, operator must be Exists; this combination means to match all values and all keys. +optional" + }, + "operator": { + "type": "string", + "description": "Operator represents a key's relationship to the value. Valid operators are Exists and Equal. Defaults to Equal. Exists is equivalent to wildcard for value, so that a pod can tolerate all taints of a particular category. +optional" + }, + "tolerationSeconds": { + "type": "integer", + "description": "TolerationSeconds represents the period of time the toleration (which must be of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, it is not set, which means tolerate the taint forever (do not evict). Zero and negative values will be treated as 0 (evict immediately) by the system. +optional", + "format": "int64" + }, + "value": { + "type": "string", + "description": "Value is the taint value the toleration matches to. If the operator is Exists, the value should be empty, otherwise just a regular string. +optional" + } + } + } + }, + "volumeMounts": { + "type": "array", + "description": "List of VolumeMounts to set in the container. +optional", + "items": { + "type": "object", + "properties": { + "mountPath": { + "type": "string", + "description": "Path within the container at which the volume should be mounted. Must not contain ':'." + }, + "mountPropagation": { + "type": "string", + "description": "mountPropagation determines how mounts are propagated from the host to container and the other way around. When not set, MountPropagationNone is used. This field is beta in 1.10. When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified (which defaults to None). +optional" + }, + "name": { + "type": "string", + "description": "This must match the Name of a Volume." + }, + "readOnly": { + "type": "boolean", + "description": "Mounted read-only if true, read-write otherwise (false or unspecified). Defaults to false. +optional" + }, + "recursiveReadOnly": { + "type": "string", + "description": "RecursiveReadOnly specifies whether read-only mounts should be handled recursively. If ReadOnly is false, this field has no meaning and must be unspecified. If ReadOnly is true, and this field is set to Disabled, the mount is not made recursively read-only. If this field is set to IfPossible, the mount is made recursively read-only, if it is supported by the container runtime. If this field is set to Enabled, the mount is made recursively read-only if it is supported by the container runtime, otherwise the pod will not be started and an error will be generated to indicate the reason. If this field is set to IfPossible or Enabled, MountPropagation must be set to None (or be unspecified, which defaults to None). If this field is not specified, it is treated as an equivalent of Disabled. +featureGate=RecursiveReadOnlyMounts +optional" + }, + "subPath": { + "type": "string", + "description": "Path within the volume from which the container's volume should be mounted. Defaults to \"\" (volume's root). +optional" + }, + "subPathExpr": { + "type": "string", + "description": "Expanded path within the volume from which the container's volume should be mounted. Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. Defaults to \"\" (volume's root). SubPathExpr and SubPath are mutually exclusive. +optional" + } + }, + "required": [ + "name", + "mountPath" + ] + } + }, + "volumes": { + "type": "array", + "description": "List of Volumes to set in the podSpec. +optional", + "items": { + "type": "object", + "properties": { + "awsElasticBlockStore": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "partition": { + "type": "integer", + "format": "int32" + }, + "readOnly": { + "type": "boolean" + }, + "volumeID": { + "type": "string" + } + }, + "required": [ + "volumeID" + ] + }, + "azureDisk": { + "type": "object", + "properties": { + "cachingMode": { + "type": "string" + }, + "diskName": { + "type": "string" + }, + "diskURI": { + "type": "string" + }, + "fsType": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } + }, + "required": [ + "diskName", + "diskURI" + ] + }, + "azureFile": { + "type": "object", + "properties": { + "readOnly": { + "type": "boolean" + }, + "secretName": { + "type": "string" + }, + "shareName": { + "type": "string" + } + }, + "required": [ + "secretName", + "shareName" + ] + }, + "cephfs": { + "type": "object", + "properties": { + "monitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "path": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "secretFile": { + "type": "string" + }, + "secretRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "user": { + "type": "string" + } + }, + "required": [ + "monitors" + ] + }, + "cinder": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "secretRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "volumeID": { + "type": "string" + } + }, + "required": [ + "volumeID" + ] + }, + "configMap": { + "type": "object", + "properties": { + "defaultMode": { + "type": "integer", + "format": "int32" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "mode": { + "type": "integer", + "format": "int32" + }, + "path": { + "type": "string" + } + }, + "required": [ + "key", + "path" + ] + } + }, + "name": { + "type": "string" + }, + "optional": { + "type": "boolean" + } + } + }, + "csi": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "fsType": { + "type": "string" + }, + "nodePublishSecretRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "readOnly": { + "type": "boolean" + }, + "volumeAttributes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "driver" + ] + }, + "downwardAPI": { + "type": "object", + "properties": { + "defaultMode": { + "type": "integer", + "format": "int32" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fieldRef": { + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "fieldPath": { + "type": "string" + } + }, + "required": [ + "fieldPath" + ] + }, + "mode": { + "type": "integer", + "format": "int32" + }, + "path": { + "type": "string" + }, + "resourceFieldRef": { + "type": "object", + "properties": { + "containerName": { + "type": "string" + }, + "divisor": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "resource": { + "type": "string" + } + }, + "required": [ + "resource" + ] + } + }, + "required": [ + "path" + ] + } + } + } + }, + "emptyDir": { + "type": "object", + "properties": { + "medium": { + "type": "string" + }, + "sizeLimit": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + }, + "ephemeral": { + "type": "object", + "properties": { + "volumeClaimTemplate": { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "creationTimestamp": { + "type": "object" + }, + "deletionGracePeriodSeconds": { + "type": "integer", + "format": "int64" + }, + "deletionTimestamp": { + "type": "object" + }, + "finalizers": { + "type": "array", + "items": { + "type": "string" + } + }, + "generateName": { + "type": "string" + }, + "generation": { + "type": "integer", + "format": "int64" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "managedFields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "fieldsType": { + "type": "string" + }, + "fieldsV1": { + "type": "object" + }, + "manager": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "subresource": { + "type": "string" + }, + "time": { + "type": "object" + } + } + } + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "ownerReferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "blockOwnerDeletion": { + "type": "boolean" + }, + "controller": { + "type": "boolean" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uid": { + "type": "string" + } + }, + "required": [ + "apiVersion", + "kind", + "name", + "uid" + ] + } + }, + "resourceVersion": { + "type": "string" + }, + "selfLink": { + "type": "string" + }, + "uid": { + "type": "string" + } + } + }, + "spec": { + "type": "object", + "properties": { + "accessModes": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataSource": { + "type": "object", + "properties": { + "apiGroup": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "apiGroup", + "kind", + "name" + ] + }, + "dataSourceRef": { + "type": "object", + "properties": { + "apiGroup": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "apiGroup", + "kind", + "name" + ] + }, + "resources": { + "type": "object", + "properties": { + "limits": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "requests": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + } + }, + "selector": { + "type": "object", + "properties": { + "matchExpressions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "operator" + ] + } + }, + "matchLabels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "storageClassName": { + "type": "string" + }, + "volumeAttributesClassName": { + "type": "string" + }, + "volumeMode": { + "type": "string" + }, + "volumeName": { + "type": "string" + } + } + } + }, + "required": [ + "spec" + ] + } + } + }, + "fc": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "lun": { + "type": "integer", + "format": "int32" + }, + "readOnly": { + "type": "boolean" + }, + "targetWWNs": { + "type": "array", + "items": { + "type": "string" + } + }, + "wwids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "flexVolume": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "fsType": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "readOnly": { + "type": "boolean" + }, + "secretRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + }, + "required": [ + "driver" + ] + }, + "flocker": { + "type": "object", + "properties": { + "datasetName": { + "type": "string" + }, + "datasetUUID": { + "type": "string" + } + } + }, + "gcePersistentDisk": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "partition": { + "type": "integer", + "format": "int32" + }, + "pdName": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } + }, + "required": [ + "pdName" + ] + }, + "gitRepo": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "repository" + ] + }, + "glusterfs": { + "type": "object", + "properties": { + "endpoints": { + "type": "string" + }, + "path": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } + }, + "required": [ + "endpoints", + "path" + ] + }, + "hostPath": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "image": { + "type": "object", + "properties": { + "pullPolicy": { + "type": "string" + }, + "reference": { + "type": "string" + } + } + }, + "iscsi": { + "type": "object", + "properties": { + "chapAuthDiscovery": { + "type": "boolean" + }, + "chapAuthSession": { + "type": "boolean" + }, + "fsType": { + "type": "string" + }, + "initiatorName": { + "type": "string" + }, + "iqn": { + "type": "string" + }, + "iscsiInterface": { + "type": "string" + }, + "lun": { + "type": "integer", + "format": "int32" + }, + "portals": { + "type": "array", + "items": { + "type": "string" + } + }, + "readOnly": { + "type": "boolean" + }, + "secretRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "targetPortal": { + "type": "string" + } + }, + "required": [ + "targetPortal", + "iqn", + "lun" + ] + }, + "name": { + "type": "string", + "description": "name of the volume. Must be a DNS_LABEL and unique within the pod. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names" + }, + "nfs": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "server": { + "type": "string" + } + }, + "required": [ + "server", + "path" + ] + }, + "persistentVolumeClaim": { + "type": "object", + "properties": { + "claimName": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } + }, + "required": [ + "claimName" + ] + }, + "photonPersistentDisk": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "pdID": { + "type": "string" + } + }, + "required": [ + "pdID" + ] + }, + "portworxVolume": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "volumeID": { + "type": "string" + } + }, + "required": [ + "volumeID" + ] + }, + "projected": { + "type": "object", + "properties": { + "defaultMode": { + "type": "integer", + "format": "int32" + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clusterTrustBundle": { + "type": "object", + "properties": { + "labelSelector": { + "type": "object", + "properties": { + "matchExpressions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "operator" + ] + } + }, + "matchLabels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "name": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "signerName": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "configMap": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "mode": { + "type": "integer", + "format": "int32" + }, + "path": { + "type": "string" + } + }, + "required": [ + "key", + "path" + ] + } + }, + "name": { + "type": "string" + }, + "optional": { + "type": "boolean" + } + } + }, + "downwardAPI": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fieldRef": { + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "fieldPath": { + "type": "string" + } + }, + "required": [ + "fieldPath" + ] + }, + "mode": { + "type": "integer", + "format": "int32" + }, + "path": { + "type": "string" + }, + "resourceFieldRef": { + "type": "object", + "properties": { + "containerName": { + "type": "string" + }, + "divisor": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "resource": { + "type": "string" + } + }, + "required": [ + "resource" + ] + } + }, + "required": [ + "path" + ] + } + } + } + }, + "podCertificate": { + "type": "object", + "properties": { + "certificateChainPath": { + "type": "string" + }, + "credentialBundlePath": { + "type": "string" + }, + "keyPath": { + "type": "string" + }, + "keyType": { + "type": "string" + }, + "maxExpirationSeconds": { + "type": "integer", + "format": "int32" + }, + "signerName": { + "type": "string" + } + } + }, + "secret": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "mode": { + "type": "integer", + "format": "int32" + }, + "path": { + "type": "string" + } + }, + "required": [ + "key", + "path" + ] + } + }, + "name": { + "type": "string" + }, + "optional": { + "type": "boolean" + } + } + }, + "serviceAccountToken": { + "type": "object", + "properties": { + "audience": { + "type": "string" + }, + "expirationSeconds": { + "type": "integer", + "format": "int64" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + } + } + } + }, + "required": [ + "sources" + ] + }, + "quobyte": { + "type": "object", + "properties": { + "group": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "registry": { + "type": "string" + }, + "tenant": { + "type": "string" + }, + "user": { + "type": "string" + }, + "volume": { + "type": "string" + } + }, + "required": [ + "registry", + "volume" + ] + }, + "rbd": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "image": { + "type": "string" + }, + "keyring": { + "type": "string" + }, + "monitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "pool": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "secretRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "user": { + "type": "string" + } + }, + "required": [ + "monitors", + "image" + ] + }, + "scaleIO": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "gateway": { + "type": "string" + }, + "protectionDomain": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "secretRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "sslEnabled": { + "type": "boolean" + }, + "storageMode": { + "type": "string" + }, + "storagePool": { + "type": "string" + }, + "system": { + "type": "string" + }, + "volumeName": { + "type": "string" + } + }, + "required": [ + "gateway", + "system", + "secretRef" + ] + }, + "secret": { + "type": "object", + "properties": { + "defaultMode": { + "type": "integer", + "format": "int32" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "mode": { + "type": "integer", + "format": "int32" + }, + "path": { + "type": "string" + } + }, + "required": [ + "key", + "path" + ] + } + }, + "optional": { + "type": "boolean" + }, + "secretName": { + "type": "string" + } + } + }, + "storageos": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "secretRef": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "volumeName": { + "type": "string" + }, + "volumeNamespace": { + "type": "string" + } + } + }, + "vsphereVolume": { + "type": "object", + "properties": { + "fsType": { + "type": "string" + }, + "storagePolicyID": { + "type": "string" + }, + "storagePolicyName": { + "type": "string" + }, + "volumePath": { + "type": "string" + } + }, + "required": [ + "volumePath" + ] + } + }, + "required": [ + "name" + ] + } + } + }, + "additionalProperties": false + }, + "watchNamespace": { + "description": "The namespace that the operator should watch for custom resources. The meaning and validation of this field depends on the operator's install modes. This field may be optional or required, and may have format constraints, based on the operator's supported install modes.", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "additionalProperties": false, + "$defs": { + "podAffinityTerm": { + "type": "object", + "properties": { + "labelSelector": { + "type": "object", + "properties": { + "matchExpressions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "operator" + ] + } + }, + "matchLabels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "matchLabelKeys": { + "type": "array", + "description": "MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. +listType=atomic +optional", + "items": { + "type": "string" + } + }, + "mismatchLabelKeys": { + "type": "array", + "description": "MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. +listType=atomic +optional", + "items": { + "type": "string" + } + }, + "namespaceSelector": { + "type": "object", + "properties": { + "matchExpressions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "operator" + ] + } + }, + "matchLabels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "namespaces": { + "type": "array", + "description": "namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means \"this pod's namespace\". +optional +listType=atomic", + "items": { + "type": "string" + } + }, + "topologyKey": { + "type": "string", + "description": "This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed." + } + }, + "required": [ + "topologyKey" + ] + } + } +} \ No newline at end of file diff --git a/internal/operator-controller/rukpak/bundle/schema/validator.go b/internal/operator-controller/rukpak/bundle/schema/validator.go new file mode 100644 index 0000000000..3d148fcb3f --- /dev/null +++ b/internal/operator-controller/rukpak/bundle/schema/validator.go @@ -0,0 +1,25 @@ +package schema + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +var ( + //go:embed registryv1bundleconfig.json + bundleConfigSchemaJSON []byte +) + +// GetBundleConfigSchemaMap returns the complete registry+v1 bundle configuration schema +// as a map[string]any. This includes the following properties: +// 1. watchNamespace +// 2. deploymentConfig +// The schema can be modified at runtime based on operator install modes before validation. +func GetBundleConfigSchemaMap() (map[string]any, error) { + var schemaMap map[string]any + if err := json.Unmarshal(bundleConfigSchemaJSON, &schemaMap); err != nil { + return nil, fmt.Errorf("failed to unmarshal bundle config schema: %w", err) + } + return schemaMap, nil +} diff --git a/internal/operator-controller/rukpak/bundle/schema/validator_test.go b/internal/operator-controller/rukpak/bundle/schema/validator_test.go new file mode 100644 index 0000000000..1484ea0ec2 --- /dev/null +++ b/internal/operator-controller/rukpak/bundle/schema/validator_test.go @@ -0,0 +1,86 @@ +package schema + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetBundleConfigSchemaMap(t *testing.T) { + schema, err := GetBundleConfigSchemaMap() + require.NoError(t, err, "should successfully get bundle config schema") + require.NotNil(t, schema, "schema should not be nil") + + t.Run("schema has correct metadata", func(t *testing.T) { + assert.Equal(t, "http://json-schema.org/draft-07/schema#", schema["$schema"]) + assert.Contains(t, schema["$id"], "registry-v1-bundle-config") + assert.Equal(t, "Registry+v1 Bundle Configuration", schema["title"]) + assert.NotEmpty(t, schema["description"]) + assert.Equal(t, "object", schema["type"]) + assert.Equal(t, false, schema["additionalProperties"]) + }) + + t.Run("schema includes watchNamespace and deploymentConfig properties", func(t *testing.T) { + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok, "schema should have properties") + + assert.Contains(t, properties, "watchNamespace") + assert.Contains(t, properties, "deploymentConfig") + }) + + t.Run("watchNamespace has anyOf with null and string", func(t *testing.T) { + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok) + + watchNamespace, ok := properties["watchNamespace"].(map[string]any) + require.True(t, ok, "watchNamespace should be present") + + anyOf, ok := watchNamespace["anyOf"].([]any) + require.True(t, ok, "watchNamespace should have anyOf") + assert.Len(t, anyOf, 2, "watchNamespace anyOf should have 2 options") + }) + + t.Run("deploymentConfig has expected structure", func(t *testing.T) { + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok) + + deploymentConfig, ok := properties["deploymentConfig"].(map[string]any) + require.True(t, ok, "deploymentConfig should be present") + + assert.Equal(t, "object", deploymentConfig["type"]) + assert.Equal(t, false, deploymentConfig["additionalProperties"]) + assert.NotEmpty(t, deploymentConfig["description"]) + + dcProps, ok := deploymentConfig["properties"].(map[string]any) + require.True(t, ok, "deploymentConfig should have properties") + + // Verify expected fields from SubscriptionConfig + expectedFields := []string{ + "nodeSelector", + "tolerations", + "resources", + "env", + "envFrom", + "volumes", + "volumeMounts", + "affinity", + "annotations", + } + + for _, field := range expectedFields { + assert.Contains(t, dcProps, field, "deploymentConfig should include %s field", field) + } + + // Verify selector is NOT included + assert.NotContains(t, dcProps, "selector", "selector field should be excluded per RFC") + }) + + t.Run("schema includes $defs for reusable types", func(t *testing.T) { + defs, ok := schema["$defs"].(map[string]any) + if ok { + // If $defs exists, verify it contains expected reusable types + assert.Contains(t, defs, "podAffinityTerm") + } + }) +}