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

426 lines
12 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 (
"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)
sourceDir := fmt.Sprintf("%s/../../../../source", testPath)
sources, err := discoverSources(sourceDir)
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/sources/index.md' to be up to date. execute 'make generate-sources-documentation'")
}
func TestDiscoverSources(t *testing.T) {
testPath, _ := os.Getwd()
sourceDir := fmt.Sprintf("%s/../../../../source", testPath)
sources, err := discoverSources(sourceDir)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(sources), 5, "Expected at least 5 sources with annotations")
// Verify sources are sorted 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
// +externaldns:source:provider-specific=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)
assert.Equal(t, "false", source.Events)
assert.Equal(t, "true", source.ProviderSpecific)
}
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
// +externaldns:source:events=true
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, "false", sources[0].Events)
assert.Equal(t, "false", sources[0].ProviderSpecific)
assert.Equal(t, "second", sources[1].Name)
assert.Equal(t, "true", sources[1].Events)
assert.Equal(t, "false", sources[1].ProviderSpecific)
}
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: "empty name annotation",
wantSources: 0,
comments: `testSource with minimal annotations.
+externaldns:source:name=
+externaldns:source:category=Testing
+externaldns:source:description=Minimal source
`,
typeName: "testSource",
filePath: "test.go",
},
}
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, tt.typeName, source.Type)
assert.Equal(t, tt.filePath, source.File)
}
})
}
}