/* Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "embed" "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "regexp" "slices" "strings" "sigs.k8s.io/external-dns/internal/gen/docs/render" ) const ( annotationPrefix = "+externaldns:source:" annotationName = annotationPrefix + "name=" annotationCategory = annotationPrefix + "category=" annotationDesc = annotationPrefix + "description=" annotationResources = annotationPrefix + "resources=" annotationFilters = annotationPrefix + "filters=" annotationNamespace = annotationPrefix + "namespace=" annotationFQDNTemplate = annotationPrefix + "fqdn-template=" annotationEvents = annotationPrefix + "events=" annotationProviderSpecific = annotationPrefix + "provider-specific=" ) var ( //go:embed "templates/*" templates embed.FS // Regex to match source type names (must end with "Source") sourceTypeRegex = regexp.MustCompile(`^(\w+)Source$`) ) // Source represents metadata about a source implementation type Source struct { Name string // e.g., "service", "ingress", "crd" Type string // e.g., "serviceSource" File string // e.g., "source/service.go" Description string // Description of what this source does Category string // e.g., "Kubernetes", "Gateway", "Service Mesh", "Wrapper" Resources string // Kubernetes resources watched, e.g., "Service", "Ingress" Filters string // Supported filters, e.g., "annotation,label" Namespace string // Namespace support: "all", "single", "multiple" FQDNTemplate string // FQDN template support: "true", "false" Events string // Events support: "true", "false" ProviderSpecific string // Provider-specific properties support: "true", "false" } type Sources []Source // main generates a markdown file with the supported sources // and writes it to the 'docs/sources/index.md' file. // To re-generate, execute 'go run internal/gen/docs/sources/main.go'. func main() { cPath, _ := os.Getwd() path := fmt.Sprintf("%s/docs/sources/index.md", cPath) fmt.Printf("generate file '%s' with supported sources\n", path) sources, err := discoverSources(fmt.Sprintf("%s/source", cPath)) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to discover sources: %v\n", err) os.Exit(1) } content, err := sources.generateMarkdown() if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to generate markdown file '%s': %v\n", path, err) os.Exit(1) } _ = render.WriteToFile(path, content) } // discoverSources scans the source directory and discovers all source implementations // by parsing Go files and extracting +externaldns:source annotations func discoverSources(dir string) (Sources, error) { // Parse all source files for annotations sources, err := parseSourceAnnotations(dir) if err != nil { return nil, err } // Sort sources by name slices.SortFunc(sources, func(a, b Source) int { return strings.Compare(a.Name, b.Name) }) return sources, nil } type columnWidths struct { Name int Resources int Filters int Namespace int FQDNTemplate int Events int ProviderSpecific int Category int } func computeColumnWidths(sources Sources) columnWidths { return columnWidths{ Name: render.MapColumn("**Source Name**", sources, func(s Source) string { return "**" + s.Name + "**" }), Resources: render.MapColumn("Resources", sources, func(s Source) string { return strings.ReplaceAll(s.Resources, ",", "
") }), Filters: render.MapColumn("Filters", sources, func(s Source) string { return s.Filters }), Namespace: render.MapColumn("Namespace", sources, func(s Source) string { return s.Namespace }), FQDNTemplate: render.MapColumn("FQDN Template", sources, func(s Source) string { return s.FQDNTemplate }), Events: render.MapColumn("Events", sources, func(s Source) string { return s.Events }), ProviderSpecific: render.MapColumn("Provider Specific", sources, func(s Source) string { return s.ProviderSpecific }), Category: render.MapColumn("Category", sources, func(s Source) string { return strings.ToLower(s.Category) }), } } type templateData struct { Sources Sources ColWidths columnWidths } func (s *Sources) generateMarkdown() (string, error) { return render.RenderTemplate(templates, "sources.gotpl", templateData{ Sources: *s, ColWidths: computeColumnWidths(*s), }) } // parseSourceAnnotations parses all Go files in the source directory // and extracts source metadata from +externaldns:source annotations func parseSourceAnnotations(sourceDir string) (Sources, error) { var sources Sources // Walk through the source directory err := filepath.WalkDir(sourceDir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } // Skip directories and non-Go files if d.IsDir() || !strings.HasSuffix(path, ".go") { return nil } // Skip test files if strings.HasSuffix(path, "_test.go") { return nil } // Parse the Go file fileSources, err := parseFile(path, sourceDir) if err != nil { return fmt.Errorf("failed to parse %s: %w", path, err) } sources = append(sources, fileSources...) return nil }) if err != nil { return nil, err } return sources, nil } // parseFile parses a single Go file and extracts source annotations func parseFile(filePath, baseDir string) (Sources, error) { var sources Sources fset := token.NewFileSet() node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) if err != nil { return nil, err } // Get relative path for the File field relPath, err := filepath.Rel(baseDir, filePath) if err != nil { relPath = filePath } // Normalize to use forward slashes relPath = filepath.ToSlash(relPath) // Create a map of all comments by their starting position cmap := ast.NewCommentMap(fset, node, node.Comments) var errFound error // Inspect the AST for type declarations ast.Inspect(node, func(n ast.Node) bool { // Look for type declarations that are GenDecl (general declarations) genDecl, ok := n.(*ast.GenDecl) if !ok { return true } // Get comments associated with this declaration comments := cmap[genDecl] if len(comments) == 0 { return true } // Check each spec in the declaration for _, spec := range genDecl.Specs { typeSpec, ok := spec.(*ast.TypeSpec) if !ok { continue } // Check if it's a struct type _, ok = typeSpec.Type.(*ast.StructType) if !ok { continue } // Check if the type name matches *Source pattern typeName := typeSpec.Name.Name if !sourceTypeRegex.MatchString(typeName) { continue } // Combine all comment text var commentText strings.Builder for _, cg := range comments { commentText.WriteString(cg.Text()) } if commentText.Len() == 0 { continue } extractedSources, err := extractSourcesFromComments(commentText.String(), typeName, relPath) if err != nil { errFound = err return false } sources = append(sources, extractedSources...) } return true }) return sources, errFound } // extractSourcesFromComments extracts source metadata from comment text. // It can extract multiple sources from the same comment block (e.g., for gateway routes). func extractSourcesFromComments(comments, typeName, filePath string) (Sources, error) { var sources Sources var currentSource *Source for line := range strings.SplitSeq(comments, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, annotationPrefix) { continue } // When we see a name annotation, start a new source switch { case strings.HasPrefix(line, annotationName): // Save previous source if it exists if currentSource != nil && currentSource.Name != "" { sources = append(sources, *currentSource) } // Start new source currentSource = &Source{ Type: typeName, File: filePath, Name: strings.TrimPrefix(line, annotationName), Events: "false", ProviderSpecific: "false", } case currentSource == nil: return nil, fmt.Errorf("found annotation line without preceding source name in type %s: %s", typeName, line) case strings.HasPrefix(line, annotationCategory): currentSource.Category = strings.TrimPrefix(line, annotationCategory) case strings.HasPrefix(line, annotationDesc): currentSource.Description = strings.TrimPrefix(line, annotationDesc) case strings.HasPrefix(line, annotationResources): currentSource.Resources = strings.TrimPrefix(line, annotationResources) case strings.HasPrefix(line, annotationFilters): currentSource.Filters = strings.TrimPrefix(line, annotationFilters) case strings.HasPrefix(line, annotationNamespace): currentSource.Namespace = strings.TrimPrefix(line, annotationNamespace) case strings.HasPrefix(line, annotationFQDNTemplate): currentSource.FQDNTemplate = strings.TrimPrefix(line, annotationFQDNTemplate) case strings.HasPrefix(line, annotationEvents): currentSource.Events = strings.TrimPrefix(line, annotationEvents) case strings.HasPrefix(line, annotationProviderSpecific): currentSource.ProviderSpecific = strings.TrimPrefix(line, annotationProviderSpecific) } } // Don't forget the last source if currentSource != nil && currentSource.Name != "" { sources = append(sources, *currentSource) } return sources, nil }