diff --git a/Makefile b/Makefile index 4ec39a34b8..8615e4abee 100644 --- a/Makefile +++ b/Makefile @@ -178,6 +178,11 @@ generate-flags-documentation: generate-metrics-documentation: go run internal/gen/docs/metrics/main.go +.PHONY: generate-sources-documentation +#? generate-sources-documentation: Generate documentation (docs/sources/index.md) +generate-sources-documentation: + go run internal/gen/docs/sources/main.go + #? pre-commit-install: Install pre-commit hooks pre-commit-install: @pre-commit install diff --git a/docs/contributing/sources-and-providers.md b/docs/contributing/sources-and-providers.md index 77e32bbe99..83120af519 100644 --- a/docs/contributing/sources-and-providers.md +++ b/docs/contributing/sources-and-providers.md @@ -1,3 +1,10 @@ +--- +tags: + - sources + - providers + - contributing +--- + # Sources and Providers ExternalDNS supports swapping out endpoint **sources** and DNS **providers** and both sides are pluggable. There currently exist multiple sources for different provider implementations. @@ -29,6 +36,37 @@ All sources live in package `source`. * `CRDSource`: returns a list of Endpoint objects sourced from the spec of CRD objects. For more details refer to [CRD source](../sources/crd.md) documentation. * `EmptySource`: returns an empty list of Endpoint objects for the purpose of testing and cleaning out entries. +### Adding New Sources + +When creating a new source, add the following annotations above the source struct definition: + +```go +// myNewSource is an implementation of Source for MyResource objects. +// +// +externaldns:source:name=my-new-source +// +externaldns:source:category=Kubernetes Core +// +externaldns:source:description=Creates DNS entries from MyResource objects +// +externaldns:source:resources=MyResource +// +externaldns:source:filters= +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false +type myNewSource struct { + // ... fields +} +``` + +**Annotation Reference:** + +* `+externaldns:source:name` - The CLI name used with `--source` flag (required) +* `+externaldns:source:category` - Category for documentation grouping (required) +* `+externaldns:source:description` - Short description of what the source does (required) +* `+externaldns:source:resources` - Kubernetes resources watched (comma-separated). Convention `Kind.apigroup.subdomain.domain` +* `+externaldns:source:filters` - Supported filter types (annotation, label) +* `+externaldns:source:namespace` - Namespace support: comma-separated values (all, single, multiple) +* `+externaldns:source:fqdn-template` - FQDN template support (true, false) + +After adding annotations, run `make generate-sources-documentation` to update sources file. + ## Providers Providers are an abstraction over any kind of sink for desired Endpoints, e.g.: diff --git a/docs/sources/index.md b/docs/sources/index.md new file mode 100644 index 0000000000..d83c418ba9 --- /dev/null +++ b/docs/sources/index.md @@ -0,0 +1,75 @@ +--- +tags: + - sources + - autogenerated +--- + +# Supported Sources + + + + + +ExternalDNS supports multiple sources for discovering DNS records. Each source watches specific Kubernetes or cloud platform resources and generates DNS records based on their configuration. + +## Overview + +Sources are responsible for: + +- Watching Kubernetes resources or external APIs +- Extracting DNS information from annotations and resource specifications +- Generating DNS endpoint records for providers to consume + +## Available Sources + +| **Source Name** | Resources | Filters | Namespace | FQDN Template | Category | +|:----------------|:----------|:--------|:----------|:--------------|:---------| +| **ambassador-host** | Host.getambassador.io | annotation,label | all,single | false | ingress controllers | +| **cloudfoundry** | CloudFoundry Routes | | | false | cloud platforms | +| **connector** | Remote TCP Server | | | false | special | +| **contour-httpproxy** | HTTPProxy.projectcontour.io | annotation | all,single | true | ingress controllers | +| **crd** | DNSEndpoint.k8s.io | annotation,label | all,single | false | externaldns | +| **empty** | None | | | false | testing | +| **f5-transportserver** | TransportServer.cis.f5.com | annotation | all,single | false | load balancers | +| **f5-virtualserver** | VirtualServer.cis.f5.com | annotation | all,single | false | load balancers | +| **fake** | Fake Endpoints | | | true | testing | +| **gateway-grpcroute** | GRPCRoute.gateway.networking.k8s.io | annotation,label | all,single | false | gateway api | +| **gateway-httproute** | HTTPRoute.gateway.networking.k8s.io | annotation,label | all,single | false | gateway api | +| **gateway-tcproute** | TCPRoute.gateway.networking.k8s.io | annotation,label | all,single | false | gateway api | +| **gateway-tlsroute** | TLSRoute.gateway.networking.k8s.io | annotation,label | all,single | false | gateway api | +| **gateway-udproute** | UDPRoute.gateway.networking.k8s.io | annotation,label | all,single | true | gateway api | +| **gloo-proxy** | Proxy.gloo.solo.io | | all,single | false | service mesh | +| **ingress** | Ingress | annotation,label | all,single | true | kubernetes core | +| **istio-gateway** | Gateway.networking.istio.io | annotation | all,single | true | service mesh | +| **istio-virtualservice** | VirtualService.networking.istio.io | annotation | all,single | true | service mesh | +| **kong-tcpingress** | TCPIngress.configuration.konghq.com | annotation | all,single | false | ingress controllers | +| **node** | Node | annotation,label | all | true | kubernetes core | +| **openshift-route** | Route.route.openshift.io | annotation,label | all,single | true | openshift | +| **pod** | Pod | annotation,label | all,single | true | kubernetes core | +| **service** | Service | annotation,label | all,single | true | kubernetes core | +| **skipper-routegroup** | RouteGroup.zalando.org | annotation | all,single | true | ingress controllers | +| **traefik-proxy** | IngressRoute.traefik.io
IngressRouteTCP.traefik.io
IngressRouteUDP.traefik.io | annotation | all,single | false | ingress controllers | + +## Usage + +To use a specific source, configure ExternalDNS with the `--source` flag: + +```bash +external-dns --source=service --source=ingress +``` + +Multiple sources can be combined to watch different resource types simultaneously. + +## Source Categories + +- **Kubernetes Core**: Native Kubernetes resources (Service, Ingress, Pod, Node) +- **ExternalDNS**: Native ExternalDNS resources +- **Gateway API**: Kubernetes Gateway API resources (Gateway, HTTPRoute, etc.) +- **Service Mesh**: Service mesh implementations (Istio, Gloo) +- **Ingress Controllers**: Third-party ingress controller resources (Contour, Traefik, Ambassador, etc.) +- **Load Balancers**: Load balancer specific resources (F5) +- **OpenShift**: OpenShift specific resources (Route) +- **Cloud Platforms**: Cloud platform integrations (Cloud Foundry) +- **Wrappers**: Source wrappers that modify or combine other sources +- **Special**: Special purpose sources (connector, empty) +- **Testing**: Sources used for testing purposes diff --git a/internal/gen/docs/metrics/main_test.go b/internal/gen/docs/metrics/main_test.go index dd9884e959..e8eda50347 100644 --- a/internal/gen/docs/metrics/main_test.go +++ b/internal/gen/docs/metrics/main_test.go @@ -79,7 +79,7 @@ func TestMetricsMdUpToDate(t *testing.T) { reg := metrics.RegisterMetric actual, err := generateMarkdownTable(reg, false) assert.NoError(t, err) - assert.Contains(t, string(expected), actual) + assert.Contains(t, string(expected), actual, "expected file 'docs/monitoring/metrics.md' to be up to date. execute 'make generate-metrics-documentation") } func TestMetricsMdExtraMetricAdded(t *testing.T) { diff --git a/internal/gen/docs/sources/main.go b/internal/gen/docs/sources/main.go new file mode 100644 index 0000000000..a1e6614bd4 --- /dev/null +++ b/internal/gen/docs/sources/main.go @@ -0,0 +1,284 @@ +/* +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 ( + "bytes" + "embed" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "text/template" + + "sigs.k8s.io/external-dns/internal/gen/docs/utils" +) + +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=" +) + +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" +} + +type Sources []Source + +// It generates a markdown file +// with the supported sources and writes it to the 'docs/sources/index.md' file. +// to re-generate `docs/sources/index.md` 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) + } + _ = utils.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 category, then by name + slices.SortFunc(sources, func(a, b Source) int { + return strings.Compare(a.Name, b.Name) + }) + + return sources, nil +} + +func (s *Sources) generateMarkdown() (string, error) { + tmpl := template.New("").Funcs(utils.FuncMap()) + template.Must(tmpl.ParseFS(templates, "templates/*.gotpl")) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "sources.gotpl", s) + if err != nil { + return "", err + } + return b.String(), nil +} + +// 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.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories and non-Go files + if info.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), + } + 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) + } + } + + // Don't forget the last source + if currentSource != nil && currentSource.Name != "" { + sources = append(sources, *currentSource) + } + + return sources, nil +} diff --git a/internal/gen/docs/sources/main_test.go b/internal/gen/docs/sources/main_test.go new file mode 100644 index 0000000000..c24f6ee655 --- /dev/null +++ b/internal/gen/docs/sources/main_test.go @@ -0,0 +1,420 @@ +/* +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 ( + "fmt" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + pathToDocs = "%s/../../../../docs/sources" + fileName = "index.md" +) + +func TestIndexMdExists(t *testing.T) { + testPath, _ := os.Getwd() + fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) + st, err := fs.Stat(fsys, fileName) + assert.NoError(t, err, "expected file %s to exist", fileName) + assert.Equal(t, fileName, st.Name()) +} + +func TestIndexMdUpToDate(t *testing.T) { + testPath, _ := os.Getwd() + fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) + expected, err := fs.ReadFile(fsys, fileName) + assert.NoError(t, err, "expected file %s to exist", fileName) + + // path to sources folder + ssys := os.DirFS(fmt.Sprintf("%s/../../../../source", testPath)) + sources, err := discoverSources(fmt.Sprintf("%s", ssys)) + require.NoError(t, err, "expected to find sources") + actual, err := sources.generateMarkdown() + assert.NoError(t, err) + assert.Contains(t, string(expected), actual, "expected file 'docs/source/index.md' to be up to date. execute 'make generate-sources-documentation'") +} + +func TestDiscoverSources(t *testing.T) { + testPath, _ := os.Getwd() + ssys := os.DirFS(fmt.Sprintf("%s/../../../../source", testPath)) + + sources, err := discoverSources(fmt.Sprintf("%s", ssys)) + require.NoError(t, err) + + assert.GreaterOrEqual(t, len(sources), 5, "Expected at least 5 sources with annotations") + + // Verify sources are sorted by category, then by name + for i := range len(sources) - 1 { + prev, curr := sources[i], sources[i+1] + if prev.Name > curr.Name { + t.Errorf("Sources not sorted correctly: %s should come before %s", curr.Name, prev.Name) + } + } +} + +func TestGenerateMarkdown(t *testing.T) { + sources := Sources{ + { + Name: "test", + Type: "testSource", + File: "source/test.go", + Category: "Test", + Description: "Test source", + Resources: "TestResource", + Filters: "annotation,label", + Namespace: "all,single", + FQDNTemplate: "true", + }, + } + + content, err := sources.generateMarkdown() + require.NoError(t, err) + assert.NotEmpty(t, content) + + assert.Contains(t, content, "# Supported Sources") + assert.Contains(t, content, "## Available Sources") +} + +func TestParseSourceAnnotations(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_source.go") + content := `package main + +// testSource is a test source implementation. +// +// +externaldns:source:name=test-source +// +externaldns:source:category=Testing +// +externaldns:source:description=A test source for unit testing +// +externaldns:source:resources=TestResource +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true +type testSource struct { + client string +} +` + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + sources, err := parseSourceAnnotations(tmpDir) + require.NoError(t, err) + assert.Len(t, sources, 1) + + source := sources[0] + assert.Equal(t, "test-source", source.Name) + assert.Equal(t, "Testing", source.Category) + assert.Equal(t, "TestResource", source.Resources) + assert.Equal(t, "annotation,label", source.Filters) + assert.Equal(t, "all,single", source.Namespace) + assert.Equal(t, "true", source.FQDNTemplate) +} + +func TestParseSourceAnnotations_SkipsTestFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Create a test file that should be skipped + testFile := filepath.Join(tmpDir, "test_source_test.go") + content := `package main + +// +externaldns:source:name=should-be-skipped +// +externaldns:source:category=Test +// +externaldns:source:description=Should be skipped +type testSource struct {} +` + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + sources, err := parseSourceAnnotations(tmpDir) + require.NoError(t, err) + assert.Empty(t, sources) +} + +func TestParseFile_MultipleSourcesInOneFile(t *testing.T) { + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "multi.go") + content := `package main + +// firstSource is the first source. +// +// +externaldns:source:name=first +// +externaldns:source:category=Testing +// +externaldns:source:description=First source +type firstSource struct {} + +// secondSource is the second source. +// +// +externaldns:source:name=second +// +externaldns:source:category=Testing +// +externaldns:source:description=Second source +type secondSource struct {} +` + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + sources, err := parseFile(testFile, tmpDir) + require.NoError(t, err) + assert.Len(t, sources, 2) + assert.Equal(t, "first", sources[0].Name) + assert.Equal(t, "second", sources[1].Name) +} + +func TestParseFile_IgnoresNonSourceTypes(t *testing.T) { + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "nonsource.go") + content := `package main + +// regularStruct is not a source (doesn't end with "Source"). +// +// +externaldns:source:name=should-not-parse +// +externaldns:source:category=Test +// +externaldns:source:description=Should not be parsed +type regularStruct struct {} +` + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + sources, err := parseFile(testFile, tmpDir) + require.NoError(t, err) + assert.Empty(t, sources) +} + +func TestParseSourceAnnotations_ErrorOnInvalidFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create a file with invalid Go syntax + testFile := filepath.Join(tmpDir, "invalid.go") + content := `package main + +this is not valid go syntax +` + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + _, err = parseSourceAnnotations(tmpDir) + require.Error(t, err) +} + +func TestParseFile_InvalidGoFile(t *testing.T) { + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "invalid.go") + content := `this is not valid go code` + + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + _, err = parseFile(testFile, tmpDir) + require.Error(t, err) +} + +func TestParseSourceAnnotations_WithSubdirectories(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "subdir") + if err := os.Mkdir(subDir, 0755); err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + // Create a test file in subdirectory + testFile := filepath.Join(subDir, "nested_source.go") + content := `package main + +// nestedSource is in a subdirectory. +// +// +externaldns:source:name=nested +// +externaldns:source:category=Testing +// +externaldns:source:description=Nested source +type nestedSource struct {} +` + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + sources, err := parseSourceAnnotations(tmpDir) + require.NoError(t, err) + assert.Len(t, sources, 1) + assert.Equal(t, "nested", sources[0].Name) + assert.Contains(t, sources[0].File, "subdir/nested_source.go") +} + +func TestGenerateMarkdown_WithMultipleCategories(t *testing.T) { + sources := Sources{ + { + Name: "service", + Category: "Kubernetes Core", + Description: "Service source", + Resources: "Service", + Filters: "annotation,label", + Namespace: "all,single", + FQDNTemplate: "true", + }, + { + Name: "ingress", + Category: "Kubernetes Core", + Description: "Ingress source", + Resources: "Ingress", + Filters: "annotation,label", + Namespace: "all,single", + FQDNTemplate: "true", + }, + { + Name: "gateway-httproute", + Category: "Gateway API", + Description: "HTTP route source", + Resources: "HTTPRoute.gateway.networking.k8s.io", + Filters: "annotation,label", + Namespace: "all,single", + FQDNTemplate: "false", + }, + } + + content, err := sources.generateMarkdown() + require.NoError(t, err) + assert.Contains(t, content, "service") + assert.Contains(t, content, "ingress") + assert.Contains(t, content, "gateway-httproute") +} + +func TestExtractSourcesFromComments(t *testing.T) { + tests := []struct { + name string + comments string + typeName string + filePath string + wantSources int + wantErr bool + validate func(*testing.T, Source) + }{ + { + name: "valid single source", + comments: `testSource is a test implementation. + ++externaldns:source:name=test ++externaldns:source:category=Testing ++externaldns:source:description=A test source ++externaldns:source:resources=TestResource ++externaldns:source:filters=annotation ++externaldns:source:namespace=all ++externaldns:source:fqdn-template=false +`, + typeName: "testSource", + filePath: "test.go", + wantSources: 1, + validate: func(t *testing.T, s Source) { + assert.Equal(t, "test", s.Name) + assert.Equal(t, "Testing", s.Category) + assert.Equal(t, "A test source", s.Description) + }, + }, + { + name: "multiple sources in same comment block", + comments: `gatewaySource handles multiple gateway types. + ++externaldns:source:name=http-route ++externaldns:source:category=Gateway ++externaldns:source:description=Handles HTTP routes ++externaldns:source:resources=HTTPRoute + ++externaldns:source:name=tcp-route ++externaldns:source:category=Gateway ++externaldns:source:description=Handles TCP routes ++externaldns:source:resources=TCPRoute +`, + typeName: "gatewaySource", + filePath: "gateway.go", + wantSources: 2, + validate: func(t *testing.T, s Source) { + assert.Contains(t, []string{"http-route", "tcp-route"}, s.Name) + }, + }, + { + name: "missing required name annotation", + comments: `testSource without name. + ++externaldns:source:category=Testing ++externaldns:source:description=Missing name +`, + typeName: "testSource", + filePath: "test.go", + wantErr: true, + }, + { + name: "optional annotations can be missing", + comments: `testSource with minimal annotations. + ++externaldns:source:name=minimal ++externaldns:source:category=Testing ++externaldns:source:description=Minimal source +`, + typeName: "testSource", + filePath: "test.go", + wantSources: 1, + validate: func(t *testing.T, s Source) { + assert.Equal(t, "minimal", s.Name) + assert.Empty(t, s.Resources) + assert.Empty(t, s.Filters) + }, + }, + { + name: "missing name annotation", + comments: `testSource with minimal annotations. + ++externaldns:source:name= ++externaldns:source:category=Testing ++externaldns:source:description=Minimal source +`, + typeName: "testSource", + filePath: "test.go", + validate: func(t *testing.T, s Source) { + require.Nil(t, s) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sources, err := extractSourcesFromComments(tt.comments, tt.typeName, tt.filePath) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Len(t, sources, tt.wantSources) + + if tt.validate != nil { + for _, source := range sources { + tt.validate(t, source) + } + } + + // Verify all sources have required fields + for _, source := range sources { + assert.Equal(t, source.Type, tt.typeName) + assert.Equal(t, source.File, tt.filePath) + } + }) + } +} diff --git a/internal/gen/docs/sources/templates/sources.gotpl b/internal/gen/docs/sources/templates/sources.gotpl new file mode 100644 index 0000000000..ccd7934921 --- /dev/null +++ b/internal/gen/docs/sources/templates/sources.gotpl @@ -0,0 +1,53 @@ +--- +tags: + - sources + - autogenerated +--- + +# Supported Sources + + + + + +ExternalDNS supports multiple sources for discovering DNS records. Each source watches specific Kubernetes or cloud platform resources and generates DNS records based on their configuration. + +## Overview + +Sources are responsible for: + +- Watching Kubernetes resources or external APIs +- Extracting DNS information from annotations and resource specifications +- Generating DNS endpoint records for providers to consume + +## Available Sources + +| **Source Name** | Resources | Filters | Namespace | FQDN Template | Category | +|:----------------|:----------|:--------|:----------|:--------------|:---------| +{{- range . }} +| **{{ .Name }}** | {{ replace .Resources "," "
" }} | {{ .Filters }} | {{ .Namespace }} | {{ .FQDNTemplate }} | {{ lower .Category }} | +{{- end }} + +## Usage + +To use a specific source, configure ExternalDNS with the {{backtick 1}}--source{{backtick 1}} flag: + +{{backtick 3}}bash +external-dns --source=service --source=ingress +{{backtick 3}} + +Multiple sources can be combined to watch different resource types simultaneously. + +## Source Categories + +- **Kubernetes Core**: Native Kubernetes resources (Service, Ingress, Pod, Node) +- **ExternalDNS**: Native ExternalDNS resources +- **Gateway API**: Kubernetes Gateway API resources (Gateway, HTTPRoute, etc.) +- **Service Mesh**: Service mesh implementations (Istio, Gloo) +- **Ingress Controllers**: Third-party ingress controller resources (Contour, Traefik, Ambassador, etc.) +- **Load Balancers**: Load balancer specific resources (F5) +- **OpenShift**: OpenShift specific resources (Route) +- **Cloud Platforms**: Cloud platform integrations (Cloud Foundry) +- **Wrappers**: Source wrappers that modify or combine other sources +- **Special**: Special purpose sources (connector, empty) +- **Testing**: Sources used for testing purposes diff --git a/internal/gen/docs/utils/utils.go b/internal/gen/docs/utils/utils.go index 618426d2d7..7dbc6495eb 100644 --- a/internal/gen/docs/utils/utils.go +++ b/internal/gen/docs/utils/utils.go @@ -46,5 +46,7 @@ func FuncMap() template.FuncMap { return strings.Repeat("`", times) }, "capitalize": cases.Title(language.English, cases.Compact).String, + "replace": strings.ReplaceAll, + "lower": strings.ToLower, } } diff --git a/internal/gen/docs/utils/utils_test.go b/internal/gen/docs/utils/utils_test.go index 1438938f90..5358904449 100644 --- a/internal/gen/docs/utils/utils_test.go +++ b/internal/gen/docs/utils/utils_test.go @@ -62,6 +62,11 @@ func TestFuncs(t *testing.T) { expect: "Capital", vars: map[string]any{"name": "capital"}, }, + { + tpl: `{{ replace .resources "," "
" }}`, + expect: "one
two
tree", + vars: map[string]any{"resources": "one,two,tree"}, + }, } for _, tt := range tests { diff --git a/source/ambassador_host.go b/source/ambassador_host.go index be72127bd1..730c05385f 100644 --- a/source/ambassador_host.go +++ b/source/ambassador_host.go @@ -57,6 +57,14 @@ var ( // ambassadorHostSource is an implementation of Source for Ambassador Host objects. // The IngressRoute implementation uses the spec.virtualHost.fqdn value for the hostname. // Use annotations.TargetKey to explicitly set Endpoint. +// +// +externaldns:source:name=ambassador-host +// +externaldns:source:category=Ingress Controllers +// +externaldns:source:description=Creates DNS entries from Ambassador Host resources +// +externaldns:source:resources=Host.getambassador.io +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false type ambassadorHostSource struct { dynamicKubeClient dynamic.Interface kubeClient kubernetes.Interface diff --git a/source/cloudfoundry.go b/source/cloudfoundry.go index 2c7215faee..6d1c456b4a 100644 --- a/source/cloudfoundry.go +++ b/source/cloudfoundry.go @@ -25,6 +25,13 @@ import ( "sigs.k8s.io/external-dns/endpoint" ) +// +externaldns:source:name=cloudfoundry +// +externaldns:source:category=Cloud Platforms +// +externaldns:source:description=Creates DNS entries from Cloud Foundry routes +// +externaldns:source:resources=CloudFoundry Routes +// +externaldns:source:filters= +// +externaldns:source:namespace= +// +externaldns:source:fqdn-template=false type cloudfoundrySource struct { client *cfclient.Client } diff --git a/source/connector.go b/source/connector.go index 0a1845b51f..2039175cfa 100644 --- a/source/connector.go +++ b/source/connector.go @@ -33,6 +33,14 @@ const ( // connectorSource is an implementation of Source that provides endpoints by connecting // to a remote tcp server. The encoding/decoding is done using encoder/gob package. +// +// +externaldns:source:name=connector +// +externaldns:source:category=Special +// +externaldns:source:description=Connects to a remote TCP server to receive DNS endpoints +// +externaldns:source:resources=Remote TCP Server +// +externaldns:source:filters= +// +externaldns:source:namespace= +// +externaldns:source:fqdn-template=false type connectorSource struct { remoteServer string } diff --git a/source/contour_httpproxy.go b/source/contour_httpproxy.go index b5e5ff2f41..d40580de64 100644 --- a/source/contour_httpproxy.go +++ b/source/contour_httpproxy.go @@ -41,6 +41,14 @@ import ( // HTTPProxySource is an implementation of Source for ProjectContour HTTPProxy objects. // The HTTPProxy implementation uses the spec.virtualHost.fqdn value for the hostname. // Use annotations.TargetKey to explicitly set Endpoint. +// +// +externaldns:source:name=contour-httpproxy +// +externaldns:source:category=Ingress Controllers +// +externaldns:source:description=Creates DNS entries from Contour HTTPProxy resources +// +externaldns:source:resources=HTTPProxy.projectcontour.io +// +externaldns:source:filters=annotation +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true type httpProxySource struct { dynamicKubeClient dynamic.Interface namespace string diff --git a/source/crd.go b/source/crd.go index ddffa82414..0fa01a1af1 100644 --- a/source/crd.go +++ b/source/crd.go @@ -44,6 +44,14 @@ import ( // crdSource is an implementation of Source that provides endpoints by listing // specified CRD and fetching Endpoints embedded in Spec. +// +// +externaldns:source:name=crd +// +externaldns:source:category=ExternalDNS +// +externaldns:source:description=Creates DNS entries from DNSEndpoint CRD resources +// +externaldns:source:resources=DNSEndpoint.k8s.io +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false type crdSource struct { crdClient rest.Interface namespace string diff --git a/source/empty.go b/source/empty.go index cf03fcef89..27d468ada0 100644 --- a/source/empty.go +++ b/source/empty.go @@ -23,13 +23,21 @@ import ( ) // emptySource is a Source that returns no endpoints. +// +// +externaldns:source:name=empty +// +externaldns:source:category=Testing +// +externaldns:source:description=Returns no endpoints (used for testing or as a placeholder) +// +externaldns:source:resources=None +// +externaldns:source:filters= +// +externaldns:source:namespace= +// +externaldns:source:fqdn-template=false type emptySource struct{} -func (e *emptySource) AddEventHandler(ctx context.Context, handler func()) { +func (e *emptySource) AddEventHandler(_ context.Context, handler func()) { } // Endpoints collects endpoints of all nested Sources and returns them in a single slice. -func (e *emptySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { +func (e *emptySource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { return []*endpoint.Endpoint{}, nil } diff --git a/source/f5_transportserver.go b/source/f5_transportserver.go index 367f168ce6..88f33a4517 100644 --- a/source/f5_transportserver.go +++ b/source/f5_transportserver.go @@ -49,6 +49,14 @@ var f5TransportServerGVR = schema.GroupVersionResource{ } // transportServerSource is an implementation of Source for F5 TransportServer objects. +// +// +externaldns:source:name=f5-transportserver +// +externaldns:source:category=Load Balancers +// +externaldns:source:description=Creates DNS entries from F5 TransportServer resources +// +externaldns:source:resources=TransportServer.cis.f5.com +// +externaldns:source:filters=annotation +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false type f5TransportServerSource struct { dynamicKubeClient dynamic.Interface transportServerInformer kubeinformers.GenericInformer diff --git a/source/f5_virtualserver.go b/source/f5_virtualserver.go index 460e373d98..bcc4772903 100644 --- a/source/f5_virtualserver.go +++ b/source/f5_virtualserver.go @@ -49,6 +49,14 @@ var f5VirtualServerGVR = schema.GroupVersionResource{ } // virtualServerSource is an implementation of Source for F5 VirtualServer objects. +// +// +externaldns:source:name=f5-virtualserver +// +externaldns:source:category=Load Balancers +// +externaldns:source:description=Creates DNS entries from F5 VirtualServer resources +// +externaldns:source:resources=VirtualServer.cis.f5.com +// +externaldns:source:filters=annotation +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false type f5VirtualServerSource struct { dynamicKubeClient dynamic.Interface virtualServerInformer kubeinformers.GenericInformer diff --git a/source/fake.go b/source/fake.go index b47501d1e6..d1faaae195 100644 --- a/source/fake.go +++ b/source/fake.go @@ -36,6 +36,14 @@ import ( // fakeSource is an implementation of Source that provides dummy endpoints for // testing/dry-running of dns providers without needing an attached Kubernetes cluster. +// +// +externaldns:source:name=fake +// +externaldns:source:category=Testing +// +externaldns:source:description=Provides dummy endpoints for testing and dry-running +// +externaldns:source:resources=Fake Endpoints +// +externaldns:source:filters= +// +externaldns:source:namespace= +// +externaldns:source:fqdn-template=true type fakeSource struct { dnsName string } diff --git a/source/gateway.go b/source/gateway.go index 7f4fe1afab..d91f6e00a7 100644 --- a/source/gateway.go +++ b/source/gateway.go @@ -86,6 +86,47 @@ func newGatewayInformerFactory(client gateway.Interface, namespace string, label return gwinformers.NewSharedInformerFactoryWithOptions(client, 0, opts...) } +// gatewayRouteSource is an implementation of Source for Gateway API Route objects. +// +// +externaldns:source:name=gateway-httproute +// +externaldns:source:category=Gateway API +// +externaldns:source:description=Creates DNS entries from Gateway API HTTPRoute resources +// +externaldns:source:resources=HTTPRoute.gateway.networking.k8s.io +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false +// +// +externaldns:source:name=gateway-grpcroute +// +externaldns:source:category=Gateway API +// +externaldns:source:description=Creates DNS entries from Gateway API GRPCRoute resources +// +externaldns:source:resources=GRPCRoute.gateway.networking.k8s.io +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false +// +// +externaldns:source:name=gateway-tcproute +// +externaldns:source:category=Gateway API +// +externaldns:source:description=Creates DNS entries from Gateway API TCPRoute resources +// +externaldns:source:resources=TCPRoute.gateway.networking.k8s.io +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false +// +// +externaldns:source:name=gateway-tlsroute +// +externaldns:source:category=Gateway API +// +externaldns:source:description=Creates DNS entries from Gateway API TLSRoute resources +// +externaldns:source:resources=TLSRoute.gateway.networking.k8s.io +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false +// +// +externaldns:source:name=gateway-udproute +// +externaldns:source:category=Gateway API +// +externaldns:source:description=Creates DNS entries from Gateway API UDPRoute resources +// +externaldns:source:resources=UDPRoute.gateway.networking.k8s.io +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true type gatewayRouteSource struct { gwName string gwNamespace string diff --git a/source/gloo_proxy.go b/source/gloo_proxy.go index 07db71f537..71022dbf73 100644 --- a/source/gloo_proxy.go +++ b/source/gloo_proxy.go @@ -125,6 +125,15 @@ type proxyVirtualHostMetadataSourceResourceRef struct { Namespace string `json:"namespace,omitempty"` } +// glooSource is an implementation of Source for Gloo Proxy objects. +// +// +externaldns:source:name=gloo-proxy +// +externaldns:source:category=Service Mesh +// +externaldns:source:description=Creates DNS entries from Gloo Proxy resources +// +externaldns:source:resources=Proxy.gloo.solo.io +// +externaldns:source:filters= +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false type glooSource struct { serviceInformer coreinformers.ServiceInformer ingressInformer netinformers.IngressInformer diff --git a/source/ingress.go b/source/ingress.go index c61328a8cd..8a2d133fc8 100644 --- a/source/ingress.go +++ b/source/ingress.go @@ -51,6 +51,14 @@ const ( // Ingress implementation will use the spec.rules.host value for the hostname // Use annotations.TargetKey to explicitly set Endpoint. (useful if the ingress // controller does not update, or to override with alternative endpoint) +// +// +externaldns:source:name=ingress +// +externaldns:source:category=Kubernetes Core +// +externaldns:source:description=Creates DNS entries based on Kubernetes Ingress resources +// +externaldns:source:resources=Ingress +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true type ingressSource struct { client kubernetes.Interface namespace string diff --git a/source/istio_gateway.go b/source/istio_gateway.go index 1d4e16c419..4a6723fbd8 100644 --- a/source/istio_gateway.go +++ b/source/istio_gateway.go @@ -49,6 +49,14 @@ var IstioGatewayIngressSource = annotations.Ingress // gatewaySource is an implementation of Source for Istio Gateway objects. // The gateway implementation uses the spec.servers.hosts values for the hostnames. // Use annotations.TargetKey to explicitly set Endpoint. +// +// +externaldns:source:name=istio-gateway +// +externaldns:source:category=Service Mesh +// +externaldns:source:description=Creates DNS entries from Istio Gateway resources +// +externaldns:source:resources=Gateway.networking.istio.io +// +externaldns:source:filters=annotation +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true type gatewaySource struct { kubeClient kubernetes.Interface istioClient istioclient.Interface diff --git a/source/istio_virtualservice.go b/source/istio_virtualservice.go index 9877e795da..6f42e15fad 100644 --- a/source/istio_virtualservice.go +++ b/source/istio_virtualservice.go @@ -50,6 +50,14 @@ const IstioMeshGateway = "mesh" // virtualServiceSource is an implementation of Source for Istio VirtualService objects. // The implementation uses the spec.hosts values for the hostnames. // Use annotations.TargetKey to explicitly set Endpoint. +// +// +externaldns:source:name=istio-virtualservice +// +externaldns:source:category=Service Mesh +// +externaldns:source:description=Creates DNS entries from Istio VirtualService resources +// +externaldns:source:resources=VirtualService.networking.istio.io +// +externaldns:source:filters=annotation +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true type virtualServiceSource struct { kubeClient kubernetes.Interface istioClient istioclient.Interface diff --git a/source/kong_tcpingress.go b/source/kong_tcpingress.go index f5c7cb185f..fad22fc271 100644 --- a/source/kong_tcpingress.go +++ b/source/kong_tcpingress.go @@ -48,6 +48,14 @@ var kongGroupdVersionResource = schema.GroupVersionResource{ } // kongTCPIngressSource is an implementation of Source for Kong TCPIngress objects. +// +// +externaldns:source:name=kong-tcpingress +// +externaldns:source:category=Ingress Controllers +// +externaldns:source:description=Creates DNS entries from Kong TCPIngress resources +// +externaldns:source:resources=TCPIngress.configuration.konghq.com +// +externaldns:source:filters=annotation +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false type kongTCPIngressSource struct { annotationFilter string ignoreHostnameAnnotation bool diff --git a/source/node.go b/source/node.go index 6b8a06af09..1df49982d8 100644 --- a/source/node.go +++ b/source/node.go @@ -34,6 +34,15 @@ import ( "sigs.k8s.io/external-dns/source/informers" ) +// nodeSource is an implementation of Source for Kubernetes Node objects. +// +// +externaldns:source:name=node +// +externaldns:source:category=Kubernetes Core +// +externaldns:source:description=Creates DNS entries based on Kubernetes Node resources +// +externaldns:source:resources=Node +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all +// +externaldns:source:fqdn-template=true type nodeSource struct { client kubernetes.Interface annotationFilter string diff --git a/source/openshift_route.go b/source/openshift_route.go index 27ecfe461f..fbcb5c28ef 100644 --- a/source/openshift_route.go +++ b/source/openshift_route.go @@ -43,6 +43,14 @@ import ( // and the Route status' canonicalHostname field as the target. // The annotations.TargetKey can be used to explicitly set an alternative // endpoint, if desired. +// +// +externaldns:source:name=openshift-route +// +externaldns:source:category=OpenShift +// +externaldns:source:description=Creates DNS entries from OpenShift Route resources +// +externaldns:source:resources=Route.route.openshift.io +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true type ocpRouteSource struct { client versioned.Interface namespace string diff --git a/source/pod.go b/source/pod.go index 06188cc779..5d90140b81 100644 --- a/source/pod.go +++ b/source/pod.go @@ -37,6 +37,15 @@ import ( "sigs.k8s.io/external-dns/source/informers" ) +// podSource is an implementation of Source for Kubernetes Pod objects. +// +// +externaldns:source:name=pod +// +externaldns:source:category=Kubernetes Core +// +externaldns:source:description=Creates DNS entries based on Kubernetes Pod resources +// +externaldns:source:resources=Pod +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true type podSource struct { client kubernetes.Interface namespace string diff --git a/source/service.go b/source/service.go index ac6f6a8993..2910b54ac3 100644 --- a/source/service.go +++ b/source/service.go @@ -62,6 +62,13 @@ var ( // desired hostname and matching or no controller annotation. For each of the // matched services' entrypoints it will return a corresponding // Endpoint object. +// +externaldns:source:name=service +// +externaldns:source:category=Kubernetes Core +// +externaldns:source:description=Creates DNS entries based on Kubernetes Service resources +// +externaldns:source:resources=Service +// +externaldns:source:filters=annotation,label +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true type serviceSource struct { client kubernetes.Interface namespace string diff --git a/source/skipper_routegroup.go b/source/skipper_routegroup.go index 98a06d0fe8..2906944770 100644 --- a/source/skipper_routegroup.go +++ b/source/skipper_routegroup.go @@ -48,6 +48,13 @@ const ( routeGroupNamespacedResource = "/apis/%s/namespaces/%s/routegroups" ) +// +externaldns:source:name=skipper-routegroup +// +externaldns:source:category=Ingress Controllers +// +externaldns:source:description=Creates DNS entries from Skipper RouteGroup resources +// +externaldns:source:resources=RouteGroup.zalando.org +// +externaldns:source:filters=annotation +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=true type routeGroupSource struct { cli routeGroupListClient apiServer string diff --git a/source/traefik_proxy.go b/source/traefik_proxy.go index bd2c96ddd9..c884a6d5fb 100644 --- a/source/traefik_proxy.go +++ b/source/traefik_proxy.go @@ -80,6 +80,13 @@ var ( traefikValueProcessor = regexp.MustCompile(`\x60([^,\x60]+)\x60`) ) +// +externaldns:source:name=traefik-proxy +// +externaldns:source:category=Ingress Controllers +// +externaldns:source:description=Creates DNS entries from Traefik IngressRoute, IngressRouteTCP, and IngressRouteUDP resources +// +externaldns:source:resources=IngressRoute.traefik.io,IngressRouteTCP.traefik.io,IngressRouteUDP.traefik.io +// +externaldns:source:filters=annotation +// +externaldns:source:namespace=all,single +// +externaldns:source:fqdn-template=false type traefikSource struct { dynamicKubeClient dynamic.Interface kubeClient kubernetes.Interface