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