Ivan Ka eeb72d62e5
refactor(docs): documentation generators (#6221)
* refactore(gen): shared functions

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactore(gen): shared functions. simplify metrics

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactore(gen): shared functions. comments

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactore(gen): shared functions. comments

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactore(gen): shared functions. file writes

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactore(gen): shared functions. file writes

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(docs): documentation generators

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(docs): documentation generators

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(docs): documentation generators

* refactor(docs): documentation generators

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(docs): documentation generators

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(docs): documentation generators

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(docs): documentation generators

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
2026-03-20 14:26:13 +05:30

317 lines
9.9 KiB
Go

/*
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, ",", "<br/>") }),
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
}