Skip to content

Commit 0b4f640

Browse files
committed
✨ Add automated schema generation and validation for registry+v1 bundle configuration
Summary: Implements Phase 1 of the variable configuration feature to achieve feature parity with OLMv0's SubscriptionConfig for registry+v1 bundles. RFC: https://docs.google.com/document/d/18O4qBvu5I4WIJgo5KU1opyUKcrfgk64xsI3tyXxmVEU/edit?tab=t.0#heading=h.x3tfh25grvnv Details: This PR adds infrastructure for JSON schema-based validation of registry+v1 bundle configuration in ClusterExtension, including both `watchNamespace` and `deploymentConfig` fields: - **Schema Generation Tool**: Parses SubscriptionConfig using AST to extract field names/types - Fetches Kubernetes OpenAPI v3 specs from GitHub releases - Maps fields to official Kubernetes schemas - Generates schema with `watchNamespace` (for operator scope) and `deploymentConfig` (for deployment customization) - Excludes `selector` field (unused in OLMv0) - **Runtime Schema Customization**: The base schema is loaded and modified at runtime based on operator install modes: `watchNamespace` validation rules are customized per operator's supported install modes - **Validation Infrastructure**: Validation integrated into bundle config unmarshaling via `config.UnmarshalConfig()` - **Regeneration Workflow**: Added `make update-registryv1-bundle-schema` target to regenerate schema when upstream SubscriptionConfig changes. When the upstream `v1alpha1.SubscriptionConfig` adds new fields (e.g., new k8s corev1 types), running the regeneration target will automatically include them in the schema without manual updates. Addresses OPRUN-4112 (Phase 1) Future PRs will implement: - Phase 2: Renderer integration to apply deploymentConfig to Deployments - Phase 3: Provider integration to extract bundle configuration from bundles - Phase 4: Documentation
1 parent 8167ff8 commit 0b4f640

11 files changed

Lines changed: 1497 additions & 21 deletions

File tree

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ fmt: $(YAMLFMT) #EXHELP Formats code
222222
update-tls-profiles: $(GOJQ) #EXHELP Update TLS profiles from the Mozilla wiki
223223
env JQ=$(GOJQ) hack/tools/update-tls-profiles.sh
224224

225+
.PHONY: update-registryv1-bundle-schema
226+
update-registryv1-bundle-schema: #EXHELP Update registry+v1 bundle configuration JSON schema
227+
hack/tools/update-registryv1-bundle-schema.sh
228+
225229
.PHONY: verify-crd-compatibility
226230
CRD_DIFF_ORIGINAL_REF := git://main?path=
227231
CRD_DIFF_UPDATED_REF := file://
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"go/ast"
7+
"go/parser"
8+
"go/token"
9+
"io"
10+
"net/http"
11+
"os"
12+
"path/filepath"
13+
"strings"
14+
)
15+
16+
const (
17+
schemaID = "https://operator-framework.io/schemas/registry-v1-bundle-config.json"
18+
schemaDraft = "http://json-schema.org/draft-07/schema#"
19+
schemaTitle = "Registry+v1 Bundle Configuration"
20+
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."
21+
)
22+
23+
// OpenAPISpec represents the structure of Kubernetes OpenAPI v3 spec
24+
type OpenAPISpec struct {
25+
Components struct {
26+
Schemas map[string]interface{} `json:"schemas"`
27+
} `json:"components"`
28+
}
29+
30+
// Schema represents a JSON Schema Draft 7 document
31+
type Schema struct {
32+
Schema string `json:"$schema"`
33+
ID string `json:"$id"`
34+
Title string `json:"title"`
35+
Description string `json:"description"`
36+
Type string `json:"type"`
37+
Properties map[string]interface{} `json:"properties"`
38+
AdditionalProperties bool `json:"additionalProperties"`
39+
Defs map[string]interface{} `json:"$defs,omitempty"`
40+
}
41+
42+
// FieldInfo contains parsed information about a struct field
43+
type FieldInfo struct {
44+
JSONName string
45+
TypeName string
46+
TypePkg string
47+
IsSlice bool
48+
IsPtr bool
49+
IsMap bool
50+
}
51+
52+
func main() {
53+
if len(os.Args) != 4 {
54+
fmt.Fprintf(os.Stderr, "Usage: %s <k8s-openapi-spec-url> <subscription-types-file> <output-file>\n", os.Args[0])
55+
os.Exit(1)
56+
}
57+
58+
k8sOpenAPISpecURL := os.Args[1]
59+
subscriptionTypesFile := os.Args[2]
60+
outputFile := os.Args[3]
61+
62+
fmt.Printf("Fetching Kubernetes OpenAPI spec from %s...\n", k8sOpenAPISpecURL)
63+
64+
// Fetch the Kubernetes OpenAPI spec
65+
openAPISpec, err := fetchOpenAPISpec(k8sOpenAPISpecURL)
66+
if err != nil {
67+
fmt.Fprintf(os.Stderr, "Error fetching OpenAPI spec: %v\n", err)
68+
os.Exit(1)
69+
}
70+
71+
fmt.Printf("Parsing SubscriptionConfig from %s...\n", subscriptionTypesFile)
72+
73+
// Parse SubscriptionConfig structure
74+
fields, err := parseSubscriptionConfig(subscriptionTypesFile)
75+
if err != nil {
76+
fmt.Fprintf(os.Stderr, "Error parsing SubscriptionConfig: %v\n", err)
77+
os.Exit(1)
78+
}
79+
80+
fmt.Printf("Generating registry+v1 bundle configuration schema...\n")
81+
82+
// Generate the schema
83+
schema := generateBundleConfigSchema(openAPISpec, fields)
84+
85+
// Marshal to JSON with indentation
86+
data, err := json.MarshalIndent(schema, "", " ")
87+
if err != nil {
88+
fmt.Fprintf(os.Stderr, "Error marshaling schema: %v\n", err)
89+
os.Exit(1)
90+
}
91+
92+
// Ensure output directory exists
93+
dir := filepath.Dir(outputFile)
94+
if err := os.MkdirAll(dir, 0755); err != nil {
95+
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
96+
os.Exit(1)
97+
}
98+
99+
// Write to file
100+
if err := os.WriteFile(outputFile, data, 0644); err != nil {
101+
fmt.Fprintf(os.Stderr, "Error writing schema file: %v\n", err)
102+
os.Exit(1)
103+
}
104+
105+
fmt.Printf("Successfully generated schema at %s\n", outputFile)
106+
}
107+
108+
func fetchOpenAPISpec(url string) (*OpenAPISpec, error) {
109+
resp, err := http.Get(url)
110+
if err != nil {
111+
return nil, fmt.Errorf("failed to fetch spec: %w", err)
112+
}
113+
defer resp.Body.Close()
114+
115+
if resp.StatusCode != http.StatusOK {
116+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
117+
}
118+
119+
body, err := io.ReadAll(resp.Body)
120+
if err != nil {
121+
return nil, fmt.Errorf("failed to read response: %w", err)
122+
}
123+
124+
var spec OpenAPISpec
125+
if err := json.Unmarshal(body, &spec); err != nil {
126+
return nil, fmt.Errorf("failed to unmarshal spec: %w", err)
127+
}
128+
129+
return &spec, nil
130+
}
131+
132+
func parseSubscriptionConfig(filepath string) ([]FieldInfo, error) {
133+
fset := token.NewFileSet()
134+
node, err := parser.ParseFile(fset, filepath, nil, parser.ParseComments)
135+
if err != nil {
136+
return nil, err
137+
}
138+
139+
var fields []FieldInfo
140+
141+
// Find the SubscriptionConfig struct
142+
ast.Inspect(node, func(n ast.Node) bool {
143+
typeSpec, ok := n.(*ast.TypeSpec)
144+
if !ok || typeSpec.Name.Name != "SubscriptionConfig" {
145+
return true
146+
}
147+
148+
structType, ok := typeSpec.Type.(*ast.StructType)
149+
if !ok {
150+
return true
151+
}
152+
153+
// Extract field information
154+
for _, field := range structType.Fields.List {
155+
if field.Names == nil {
156+
continue
157+
}
158+
159+
fieldName := field.Names[0].Name
160+
161+
// Skip Selector field
162+
if fieldName == "Selector" {
163+
continue
164+
}
165+
166+
// Get JSON tag
167+
jsonName := extractJSONTag(field.Tag)
168+
if jsonName == "" || jsonName == "-" {
169+
continue
170+
}
171+
172+
// Parse the field type
173+
fieldInfo := FieldInfo{
174+
JSONName: jsonName,
175+
}
176+
177+
parseFieldType(field.Type, &fieldInfo)
178+
179+
fields = append(fields, fieldInfo)
180+
}
181+
182+
return false
183+
})
184+
185+
return fields, nil
186+
}
187+
188+
func extractJSONTag(tag *ast.BasicLit) string {
189+
if tag == nil {
190+
return ""
191+
}
192+
193+
tagValue := strings.Trim(tag.Value, "`")
194+
for _, part := range strings.Split(tagValue, " ") {
195+
if strings.HasPrefix(part, "json:") {
196+
jsonTag := strings.Trim(strings.TrimPrefix(part, "json:"), "\"")
197+
return strings.Split(jsonTag, ",")[0]
198+
}
199+
}
200+
201+
return ""
202+
}
203+
204+
func parseFieldType(expr ast.Expr, info *FieldInfo) {
205+
switch t := expr.(type) {
206+
case *ast.ArrayType:
207+
info.IsSlice = true
208+
parseFieldType(t.Elt, info)
209+
210+
case *ast.StarExpr:
211+
info.IsPtr = true
212+
parseFieldType(t.X, info)
213+
214+
case *ast.MapType:
215+
info.IsMap = true
216+
info.TypeName = "map[string]string" // Simplified for our use case
217+
218+
case *ast.Ident:
219+
info.TypeName = t.Name
220+
221+
case *ast.SelectorExpr:
222+
if pkg, ok := t.X.(*ast.Ident); ok {
223+
info.TypePkg = pkg.Name
224+
info.TypeName = t.Sel.Name
225+
}
226+
}
227+
}
228+
229+
func generateBundleConfigSchema(openAPISpec *OpenAPISpec, fields []FieldInfo) *Schema {
230+
schema := &Schema{
231+
Schema: schemaDraft,
232+
ID: schemaID,
233+
Title: schemaTitle,
234+
Description: schemaDescription,
235+
Type: "object",
236+
Properties: make(map[string]interface{}),
237+
AdditionalProperties: false,
238+
Defs: make(map[string]interface{}),
239+
}
240+
241+
// Add watchNamespace property (base definition - will be modified at runtime)
242+
schema.Properties["watchNamespace"] = map[string]interface{}{
243+
"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.",
244+
"anyOf": []interface{}{
245+
map[string]string{"type": "null"},
246+
map[string]string{"type": "string"},
247+
},
248+
}
249+
250+
// Create deploymentConfig property
251+
deploymentConfigProp := map[string]interface{}{
252+
"type": "object",
253+
"description": "Configuration for customizing operator deployment (environment variables, resources, volumes, etc.)",
254+
"properties": make(map[string]interface{}),
255+
"additionalProperties": false,
256+
}
257+
258+
// Build deploymentConfig properties from parsed fields
259+
deploymentConfigProps := deploymentConfigProp["properties"].(map[string]interface{})
260+
261+
for _, field := range fields {
262+
fieldSchema := mapFieldToOpenAPISchema(field, openAPISpec)
263+
if fieldSchema != nil {
264+
deploymentConfigProps[field.JSONName] = fieldSchema
265+
}
266+
}
267+
268+
schema.Properties["deploymentConfig"] = deploymentConfigProp
269+
270+
return schema
271+
}
272+
273+
func mapFieldToOpenAPISchema(field FieldInfo, openAPISpec *OpenAPISpec) interface{} {
274+
// Handle map types (nodeSelector, annotations)
275+
if field.IsMap {
276+
return map[string]interface{}{
277+
"type": "object",
278+
"additionalProperties": map[string]string{
279+
"type": "string",
280+
},
281+
}
282+
}
283+
284+
// Get the OpenAPI schema for the base type
285+
openAPITypeName := getOpenAPITypeName(field)
286+
if openAPITypeName == "" {
287+
fmt.Fprintf(os.Stderr, "Warning: Could not map field %s (type: %s.%s) to OpenAPI schema\n",
288+
field.JSONName, field.TypePkg, field.TypeName)
289+
return nil
290+
}
291+
292+
baseSchema, ok := openAPISpec.Components.Schemas[openAPITypeName]
293+
if !ok {
294+
fmt.Fprintf(os.Stderr, "Warning: Schema for %s not found in OpenAPI spec\n", openAPITypeName)
295+
return nil
296+
}
297+
298+
// Wrap in array if it's a slice field
299+
if field.IsSlice {
300+
return map[string]interface{}{
301+
"type": "array",
302+
"items": baseSchema,
303+
}
304+
}
305+
306+
return baseSchema
307+
}
308+
309+
func getOpenAPITypeName(field FieldInfo) string {
310+
// Map package names to OpenAPI prefixes
311+
pkgMap := map[string]string{
312+
"corev1": "io.k8s.api.core.v1",
313+
"v1": "io.k8s.api.core.v1",
314+
}
315+
316+
prefix, ok := pkgMap[field.TypePkg]
317+
if !ok {
318+
return ""
319+
}
320+
321+
return fmt.Sprintf("%s.%s", prefix, field.TypeName)
322+
}

0 commit comments

Comments
 (0)