mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2026-05-04 14:21:33 +02:00
* 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>
317 lines
9.9 KiB
Go
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
|
|
}
|