Files
Jan KoronaandGitHub 8bbee0176d [chore][cmd/mdatagen] Add exported_configs for reusable config schemas (#15304)
<!--Ex. Fixing a bug - Describe the bug and how this fixes the issue.
Ex. Adding a feature - Explain what this achieves.-->
#### Description

Adds `exported_configs` as the metadata.yaml field for config schemas
that should be exported from a component or package.

This keeps source metadata from using `config.$defs` directly while
still emitting JSON Schema `$defs` in generated schema output. The
loader maps `exported_configs` into internal schema defs for resolution,
and mdatagen does the same before config generation.

Also updates generated/internal schema handling so mdatagen-injected
defs are marked internal-only and removed after resolution.
User-authored exported defs are preserved, while generated
telemetry/resource/event helper defs no longer inflate generated
component config schemas or duplicate Go types.

<!-- Issue number if applicable -->
#### Link to tracking issue
Fixes #15304

<!--Describe what testing was performed and which tests were added.-->
#### Testing

- Sample components amended with new functionality
- Unit test coverage
2026-05-18 18:27:13 +00:00

303 lines
8.2 KiB
Go

// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package schemagen // import "go.opentelemetry.io/collector/internal/schemagen"
import (
"fmt"
"log"
"maps"
"reflect"
"slices"
)
const (
schemaVersion = "https://json-schema.org/draft/2020-12/schema"
// goDurationPattern matches Go duration strings (e.g., "30s", "1h30m", "500ms")
goDurationPattern = `^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$`
)
type Resolver struct {
pkgID string
class string
name string
loader Loader
}
func NewResolver(pkgID, class, name, dir string) *Resolver {
loader := NewLoader(dir)
return &Resolver{
loader: loader,
pkgID: pkgID,
class: class,
name: name,
}
}
// ResolveSchema takes a source configuration metadata schema and resolves all references ($ref)
// to produce a fully resolved schema. It handles both internal references (within the same schema) and external references
// (pointing to other schemas, either locally or remotely). The resolver uses registered loaders to fetch external schemas as needed.
//
// Returns a new ConfigMetadata with all references resolved, or an error if resolution fails.
func (r *Resolver) ResolveSchema(src *ConfigMetadata) (*ConfigMetadata, error) {
target := &ConfigMetadata{}
err := r.resolveSchema(src, src, target, nil)
if err != nil {
return nil, err
}
target.Schema = schemaVersion
target.ID = r.pkgID
target.Title = fmt.Sprintf("%s/%s", r.class, r.name)
if len(src.Properties) > 0 {
target.Type = "object"
}
return target, nil
}
func (r *Resolver) resolveSchema(root, current, target *ConfigMetadata, origin *Ref) error {
if current.Ref != "" {
// Preserve custom extensions defined on the reference node
customGoType := current.GoType
customIsPointer := current.IsPointer
customIsOptional := current.IsOptional
customDescription := current.Description
customDefault := current.Default
customEnum := current.Enum
customInternalOnly := current.InternalOnly
finalRef := current.Ref
resolved, err := r.resolveRef(root, current, origin)
if err != nil {
return fmt.Errorf("failed to resolve $ref %q: %w", current.Ref, err)
}
// Follow alias chains: an internal $defs entry may itself be a $ref (e.g. to an external type).
// Keep resolving until we reach a concrete schema node.
// Stop if resolveRef returns the same pointer (unknown-ref fallback sets GoType and returns current).
for resolved.Ref != "" {
next, err := r.resolveRef(root, resolved, origin)
if err != nil {
return fmt.Errorf("failed to resolve $ref %q: %w", resolved.Ref, err)
}
if next == resolved {
// fallback "any" case: resolveRef returned the node unchanged
break
}
finalRef = resolved.Ref
resolved = next
}
// Copy the resolved node
newCurrent := *resolved
newCurrent.ResolvedFrom = finalRef
newCurrent.GoStruct = current.GoStruct
newCurrent.Embed = current.Embed
newCurrent.InternalOnly = customInternalOnly
// Restore custom extensions if they were explicitly set on the reference
if customGoType != "" {
newCurrent.GoType = customGoType
}
if customIsPointer {
newCurrent.IsPointer = customIsPointer
}
if customIsOptional {
newCurrent.IsOptional = customIsOptional
}
if customDescription != "" {
newCurrent.Description = customDescription
}
if customDefault != nil {
newCurrent.Default = customDefault
}
if len(customEnum) > 0 {
newCurrent.Enum = customEnum
}
current = &newCurrent
}
currRef := reflect.ValueOf(current).Elem()
targetRef := reflect.ValueOf(target).Elem()
for i := 0; i < currRef.NumField(); i++ {
field := currRef.Field(i)
targetField := targetRef.Field(i)
if !targetField.CanSet() {
continue
}
switch field.Kind() {
case reflect.Ptr:
if !field.IsNil() && field.Elem().Kind() == reflect.Struct {
if field.Type() == reflect.TypeFor[*ConfigMetadata]() {
newMeta := &ConfigMetadata{}
if err := r.resolveSchema(root, field.Interface().(*ConfigMetadata), newMeta, origin); err != nil {
return err
}
targetField.Set(reflect.ValueOf(newMeta))
}
} else {
targetField.Set(field)
}
case reflect.Map:
if field.Type().Elem() == reflect.TypeFor[*ConfigMetadata]() {
newMap := reflect.MakeMap(field.Type())
iter := field.MapRange()
for iter.Next() {
key := iter.Key()
value := iter.Value()
if !value.IsNil() {
newMeta := &ConfigMetadata{}
if err := r.resolveSchema(root, value.Interface().(*ConfigMetadata), newMeta, origin); err != nil {
return err
}
newMap.SetMapIndex(key, reflect.ValueOf(newMeta))
}
}
targetField.Set(newMap)
} else {
targetField.Set(field)
}
case reflect.Slice:
if field.Type().Elem() == reflect.TypeFor[*ConfigMetadata]() {
newSlice := reflect.MakeSlice(field.Type(), field.Len(), field.Len())
for j := 0; j < field.Len(); j++ {
elem := field.Index(j)
if !elem.IsNil() {
newMeta := &ConfigMetadata{}
if err := r.resolveSchema(root, elem.Interface().(*ConfigMetadata), newMeta, origin); err != nil {
return err
}
newSlice.Index(j).Set(reflect.ValueOf(newMeta))
}
}
targetField.Set(newSlice)
} else {
targetField.Set(field)
}
default:
targetField.Set(field)
}
}
handleEmbeddedStructs(target)
enhanceTimeTypes(target)
cleanupInternalDefs(target)
return nil
}
// resolveRef resolves a JSON Schema $ref, handling both internal and external references.
// The origin parameter tracks which namespace the current schema was loaded from,
// enabling local refs in remotely-fetched schemas to be converted to external refs.
func (r *Resolver) resolveRef(root, current *ConfigMetadata, origin *Ref) (*ConfigMetadata, error) {
ref := WithOrigin(current.Ref, origin)
if err := ref.Validate(); err != nil {
return nil, fmt.Errorf("invalid reference format %q: %w", current.Ref, err)
}
if def, ok := lookupRootDef(root, current, ref); ok {
return def, nil
}
if ref.IsLocal() {
return r.loadExternalRef(ref)
}
// check if it's in known namespace
if _, ok := ref.Namespace(); ok {
return r.loadExternalRef(ref)
}
// fallback to type "any"
current.GoType = current.Ref
current.Comment = "Uses `any` type."
return current, nil
}
func lookupRootDef(root, current *ConfigMetadata, ref *Ref) (*ConfigMetadata, bool) {
if root.Defs == nil || (!ref.IsInternal() && !ref.IsLocal()) {
return nil, false
}
def, ok := root.Defs[ref.DefName()]
if !ok || def == current {
return nil, false
}
return def, ok
}
// loadExternalRef uses SchemaLoader to load external references
func (r *Resolver) loadExternalRef(ref *Ref) (*ConfigMetadata, error) {
md, err := r.loader.Load(*ref)
if err != nil {
return nil, err
}
if md == nil {
return nil, fmt.Errorf("no loader could resolve external reference: %s", ref)
}
if md.Defs != nil {
if def, ok := md.Defs[ref.DefName()]; ok {
resolved := &ConfigMetadata{}
if err := r.resolveSchema(md, def, resolved, ref); err != nil {
return nil, fmt.Errorf("failed to resolve internal references in external schema %s: %w", ref, err)
}
return resolved, nil
}
}
return nil, fmt.Errorf("type %q not found in loaded schema for reference %s", ref.DefName(), ref)
}
func handleEmbeddedStructs(md *ConfigMetadata) {
embeddedStructs := make([]*ConfigMetadata, 0)
properties := make(map[string]*ConfigMetadata)
if len(md.AllOf) > 0 {
log.Printf("warning: found deprecated allOf, use properties with `embed: true` annotation instead\n")
embeddedStructs = md.AllOf
}
for _, propName := range slices.Sorted(maps.Keys(md.Properties)) {
prop := md.Properties[propName]
if prop.Embed {
if !prop.GoStruct.Anonymous {
prop.EmbeddedName = propName
}
embeddedStructs = append(embeddedStructs, prop)
} else {
properties[propName] = prop
}
}
md.AllOf = embeddedStructs
md.Properties = properties
}
func enhanceTimeTypes(md *ConfigMetadata) {
if md.Type == "string" {
switch md.Format {
case "duration":
md.Format = ""
md.GoType = "time.Duration"
md.Pattern = goDurationPattern
case "date-time":
md.GoType = "time.Time"
}
}
}
func cleanupInternalDefs(md *ConfigMetadata) {
for name, def := range md.Defs {
if def != nil && def.InternalOnly {
delete(md.Defs, name)
}
}
if len(md.Defs) == 0 {
md.Defs = nil
}
}