diff --git a/cmd/lint.go b/cmd/lint.go index a2a26025fe..5583dfc603 100644 --- a/cmd/lint.go +++ b/cmd/lint.go @@ -73,8 +73,14 @@ func validateSourceCommandAction(cmd *cobra.Command, args []string) error { if skipped != nil { logger.Infof("Skipped errors: %v", skipped) } + if errs != nil { return fmt.Errorf("linting package failed: %w", errs) } + + docsErrors := validation.ValidateDocsStructureFromPath(packageRootPath) + if docsErrors != nil { + return fmt.Errorf("documentation validation failed: %v", docsErrors) + } return nil } diff --git a/go.mod b/go.mod index 38654f36f5..416d88c2d6 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 + github.com/yuin/goldmark v1.4.13 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba golang.org/x/tools v0.34.0 gopkg.in/dnaeon/go-vcr.v3 v3.2.0 diff --git a/go.sum b/go.sum index d4cd20f900..03c1e42741 100644 --- a/go.sum +++ b/go.sum @@ -425,6 +425,7 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= diff --git a/internal/builder/packages.go b/internal/builder/packages.go index f71273c2d2..858eade760 100644 --- a/internal/builder/packages.go +++ b/internal/builder/packages.go @@ -202,6 +202,12 @@ func BuildPackage(ctx context.Context, options BuildOptions) (string, error) { if errs != nil { return "", fmt.Errorf("invalid content found in built package: %w", errs) } + + docsValidationErrors := validation.ValidateDocsStructureFromPath(destinationDir) + if docsValidationErrors != nil { + return "", fmt.Errorf("documentation validation failed: %v", docsValidationErrors) + } + return destinationDir, nil } @@ -230,6 +236,11 @@ func buildZippedPackage(ctx context.Context, options BuildOptions, destinationDi } } + docsValidationErrors := validation.ValidateDocsStructureFromZip(zippedPackagePath) + if docsValidationErrors != nil { + return "", fmt.Errorf("documentation validation failed: %v", docsValidationErrors) + } + if options.SignPackage { err := signZippedPackage(options, zippedPackagePath) if err != nil { diff --git a/internal/packages/buildmanifest/build_manifest.go b/internal/packages/buildmanifest/build_manifest.go index 7f57a38cb8..e9bee8c8a0 100644 --- a/internal/packages/buildmanifest/build_manifest.go +++ b/internal/packages/buildmanifest/build_manifest.go @@ -21,7 +21,8 @@ type BuildManifest struct { // Dependencies define external package dependencies. type Dependencies struct { - ECS ECSDependency `config:"ecs"` + ECS ECSDependency `config:"ecs"` + DocsStructureEnforced bool `config:"docs_structure_enforced"` } // ECSDependency defines a dependency on ECS fields. diff --git a/internal/packages/installer/factory.go b/internal/packages/installer/factory.go index 5229bcacb7..eaa0ca42c1 100644 --- a/internal/packages/installer/factory.go +++ b/internal/packages/installer/factory.go @@ -77,6 +77,12 @@ func NewForPackage(ctx context.Context, options Options) (Installer, error) { } } logger.Debug("Skip validation of the built .zip package") + + docsValidationErrors := validation.ValidateDocsStructureFromZip(options.ZipPath) + if docsValidationErrors != nil { + return nil, fmt.Errorf("documentation validation failed: %v", docsValidationErrors) + } + return CreateForZip(options.Kibana, options.ZipPath) } diff --git a/internal/validation/_static/docsValidationSchema/enforced_sections_v1.yml b/internal/validation/_static/docsValidationSchema/enforced_sections_v1.yml new file mode 100644 index 0000000000..ef10e62a79 --- /dev/null +++ b/internal/validation/_static/docsValidationSchema/enforced_sections_v1.yml @@ -0,0 +1,14 @@ +version: "1" +enforced_sections: + - name: "Overview" + description: "A brief introduction to the package, its purpose, and key features." + - name: "What data does this integration collect?" + description: "An overview of the data collected by the package, including any relevant metrics or logs." + - name: "What do I need to use this integration?" + description: "Requirements for using the package, such as dependencies, configurations, or prerequisites." + - name: "How do I deploy this integration?" + description: "Instructions for deploying the package, including any necessary configurations or setup steps." + - name: "Troubleshooting" + description: "Guidance on common issues and how to resolve them, including error messages and their meanings." + - name: "Performance and scaling" + description: "Information on how the package performs under different loads and how to scale it effectively." \ No newline at end of file diff --git a/internal/validation/readme_validator.go b/internal/validation/readme_validator.go new file mode 100644 index 0000000000..acb90c3a79 --- /dev/null +++ b/internal/validation/readme_validator.go @@ -0,0 +1,84 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package validation + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +func ValidateReadmeStructure(packageRoot string, enforcedSections []string) error { + docsFolderPath := filepath.Join(packageRoot, "docs") + files, err := os.ReadDir(docsFolderPath) + if err != nil && errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("docs folder %s not found: %w", docsFolderPath, err) + } + + var errs DocsValidationError + + for _, file := range files { + if !file.IsDir() { + fullPath := filepath.Join(docsFolderPath, file.Name()) + + content, err := os.ReadFile(fullPath) + if err != nil { + fmt.Printf("Error opening file %s: %v\n", fullPath, err) + continue + } + + validationErrs := validateContent(file.Name(), content, enforcedSections) + if validationErrs != nil { + errs = append(errs, validationErrs) + } + } + } + + if len(errs) == 0 { + return nil + } + + return errs +} + +func validateContent(filename string, content []byte, enforcedSections []string) error { + var errs DocsValidationError + + md := goldmark.New() + + reader := text.NewReader(content) + doc := md.Parser().Parse(reader) + + found := map[string]bool{} + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if heading, ok := n.(*ast.Heading); ok && entering { + text := extractHeadingText(heading, content) + for _, required := range enforcedSections { + if strings.EqualFold(text, required) { + found[required] = true + } + } + } + return ast.WalkContinue, nil + }) + + for _, header := range enforcedSections { + if !found[header] { + errs = append(errs, fmt.Errorf("missing required section '%s' in file '%s'", header, filename)) + } + } + + if len(errs) == 0 { + return nil + } + + return errs +} diff --git a/internal/validation/readme_validator_test.go b/internal/validation/readme_validator_test.go new file mode 100644 index 0000000000..cac1d2b629 --- /dev/null +++ b/internal/validation/readme_validator_test.go @@ -0,0 +1,74 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package validation + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateContent(t *testing.T) { + tests := []struct { + name string + filename string + content []byte + enforcedSections []string + expectedResult error + }{ + { + name: "Valid content", + filename: "test.md", + content: []byte("# Overview\n\nThis is a valid overview section."), + enforcedSections: []string{"Overview"}, + expectedResult: nil, + }, + { + name: "Missing header", + filename: "test.md", + content: []byte("# Overview\n\nThis is a valid overview section."), + enforcedSections: []string{"Overview", "Setup"}, + expectedResult: DocsValidationError{fmt.Errorf("missing required section 'Setup' in file 'test.md'")}, + }, { + name: "Empty content", + filename: "test.md", + content: []byte(""), + enforcedSections: []string{"Overview", "Setup"}, + expectedResult: DocsValidationError{fmt.Errorf("missing required section 'Overview' in file 'test.md'"), fmt.Errorf("missing required section 'Setup' in file 'test.md'")}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualResult := validateContent(tt.filename, tt.content, tt.enforcedSections) + assert.Equal(t, tt.expectedResult, actualResult, "Result does not match expected") + }) + } +} + +func TestValidateReadmeStructure(t *testing.T) { + tests := []struct { + name string + packageRoot string + enforcedSections []string + expectedResult string + }{ + { + name: "Valid test", + packageRoot: "testdata", + enforcedSections: []string{"Overview", "Setup"}, + expectedResult: "\n\nmissing required section 'Setup' in file 'missing_headers.md'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualResult := ValidateReadmeStructure(tt.packageRoot, tt.enforcedSections) + + assert.Equal(t, tt.expectedResult, actualResult.Error(), "Result does not match expected") + }) + } +} diff --git a/internal/validation/testdata/docs/missing_headers.md b/internal/validation/testdata/docs/missing_headers.md new file mode 100644 index 0000000000..b6bb0c77a4 --- /dev/null +++ b/internal/validation/testdata/docs/missing_headers.md @@ -0,0 +1,2 @@ +### Overview +This is the overview section diff --git a/internal/validation/testdata/docs/valid.md b/internal/validation/testdata/docs/valid.md new file mode 100644 index 0000000000..932e0b6569 --- /dev/null +++ b/internal/validation/testdata/docs/valid.md @@ -0,0 +1,6 @@ +### Overview +This is the overview section + + +### Setup +This is the setup section \ No newline at end of file diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 1b2ec19896..3301b3bf35 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -6,15 +6,40 @@ package validation import ( "archive/zip" + "embed" "errors" "fmt" "io/fs" "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/elastic/go-resource" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" "github.com/elastic/package-spec/v3/code/go/pkg/specerrors" "github.com/elastic/package-spec/v3/code/go/pkg/validator" ) +//go:embed _static +var static embed.FS + +var staticSource = resource.NewSourceFS(static) + +type DocsValidationError []error + +func (dve DocsValidationError) Error() string { + var out string + for _, err := range dve { + out += "\n" + err.Error() + } + return out +} + func ValidateFromPath(rootPath string) error { return validator.ValidateFromPath(rootPath) } @@ -61,6 +86,110 @@ func ValidateAndFilterFromZip(packagePath string) (error, error) { return result.Processed, result.Removed } +func ValidateDocsStructureFromPath(rootPath string) error { + fsys := os.DirFS(rootPath) + + enforcedSections, err := retrieveEnforcedDocsSections(fsys) + if err != nil { + return fmt.Errorf("failed to retrieve enforced documentation sections: %w", err) + } + + if len(enforcedSections) == 0 { + return nil + } + + return validateReadmeStructure(rootPath, enforcedSections) +} + +func ValidateDocsStructureFromZip(packagePath string) error { + fsys, err := zip.OpenReader(packagePath) + if err != nil { + return fmt.Errorf("failed to open zip file (%s): %w", packagePath, err) + } + defer fsys.Close() + + fsZip, err := fsFromPackageZip(fsys) + if err != nil { + return fmt.Errorf("failed to extract filesystem from zip file (%s): %w", packagePath, err) + } + + enforcedSections, err := retrieveEnforcedDocsSections(fsZip) + if err != nil { + return fmt.Errorf("failed to retrieve enforced documentation sections: %w", err) + } + if len(enforcedSections) == 0 { + return nil + } + + return validateReadmeStructure(packagePath, enforcedSections) +} + +func extractHeadingText(n ast.Node, source []byte) string { + var builder strings.Builder + + for child := n.FirstChild(); child != nil; child = child.NextSibling() { + switch t := child.(type) { + case *ast.Text: + builder.Write(t.Segment.Value(source)) + } + } + + return builder.String() +} + +func validateReadmeStructure(packageRoot string, enforcedSections []string) error { + docsFolderPath := filepath.Join(packageRoot, "docs") + files, err := os.ReadDir(docsFolderPath) + if err != nil && errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("docs folder %s not found: %w", docsFolderPath, err) + } + + md := goldmark.New() + var errs DocsValidationError + + for _, file := range files { + fmt.Println("Validating file:", file.Name()) + if !file.IsDir() { + fullPath := filepath.Join(docsFolderPath, file.Name()) + + content, err := os.ReadFile(fullPath) + if err != nil { + fmt.Printf("Error opening file %s: %v\n", fullPath, err) + continue + } + + reader := text.NewReader(content) + doc := md.Parser().Parse(reader) + + found := map[string]bool{} + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if heading, ok := n.(*ast.Heading); ok && entering { + text := extractHeadingText(heading, content) + for _, required := range enforcedSections { + if strings.EqualFold(text, required) { + found[required] = true + } + } + } + return ast.WalkContinue, nil + }) + + for _, header := range enforcedSections { + if !found[header] { + errs = append(errs, fmt.Errorf("missing required section '%s' in file '%s'", header, file.Name())) + } + } + + } + } + + if len(errs) == 0 { + return nil + } + + return errs +} + func fsFromPackageZip(fsys fs.FS) (fs.FS, error) { dirs, err := fs.ReadDir(fsys, ".") if err != nil { @@ -103,5 +232,84 @@ func filterErrors(allErrors error, fsys fs.FS) (specerrors.FilterResult, error) fmt.Errorf("failed to filter errors: %w", err) } return result, nil +} + +func retrieveEnforcedDocsSections(fsys fs.FS) ([]string, error) { + sections := []string{} + + config, err := specerrors.LoadConfigFilter(fsys) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return sections, fmt.Errorf("failed to read config filter: %w", err) + } + + if config == nil || !config.DocsStructureEnforced.Enabled { + return sections, nil + } + + defaultSections, err := loadSectionsFromConfig(fmt.Sprintf("%d", config.DocsStructureEnforced.Version)) + if err != nil { + fmt.Printf("Failed to load enforced sections from config: %v\n", err) + } + + for _, section := range defaultSections { + if contains(config.DocsStructureEnforced.Skip, section) { + continue + } + sections = append(sections, section) + } + + return sections, nil +} + +// contains checks if a string is present in a slice of strings. +func contains(slice []specerrors.Skip, item string) bool { + for _, s := range slice { + if s.Title == item { + return true + } + } + return false +} + +type EnforcedSections struct { + Version string `yaml:"version"` + Sections []Section `yaml:"enforced_sections"` +} + +type Section struct { + Name string `yaml:"name"` + Description string `yaml:"description"` +} + +func loadSectionsFromConfig(version string) ([]string, error) { + var schemaPath string + switch version { + case "1": + schemaPath = "_static/docsValidationSchema/enforced_sections_v1.yml" + default: + return nil, fmt.Errorf("unsupported format_version: %s", version) + } + + data, err := fs.ReadFile(staticSource.FS, schemaPath) + if err != nil { + return nil, fmt.Errorf("failed to read schema file: %w", err) + } + + var spec EnforcedSections + if err := yaml.Unmarshal(data, &spec); err != nil { + return nil, fmt.Errorf("invalid schema YAML: %w", err) + } + + if spec.Version != version { + return nil, fmt.Errorf("schema version mismatch: got %s, expected %s", spec.Version, version) + } + + sections := make([]string, 0, len(spec.Sections)) + for _, section := range spec.Sections { + if section.Name != "" { + sections = append(sections, section.Name) + } + } + return sections, nil } diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go new file mode 100644 index 0000000000..eed32ed3c1 --- /dev/null +++ b/internal/validation/validation_test.go @@ -0,0 +1,64 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package validation + +import ( + _ "embed" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateDocsStructureFromPath(t *testing.T) { + tests := []struct { + name string + rootPath string + expectedResult error + }{ + { + name: "Missing header", + rootPath: "../../test/packages/other/readme_structure", + expectedResult: DocsValidationError{fmt.Errorf("missing required section 'Overview' in file 'README.md'")}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualResult := ValidateDocsStructureFromPath(tt.rootPath) + assert.Equal(t, tt.expectedResult, actualResult, "Result does not match expected") + }) + } +} + +func TestLoadSectionsFromConfig(t *testing.T) { + tests := []struct { + name string + version string + expectedResult []string + expectedError error + }{ + { + name: "Valid version", + version: "1", + expectedResult: []string{"Overview", "What data does this integration collect?", "What do I need to use this integration?", "How do I deploy this integration?", "Troubleshooting", "Performance and scaling"}, + expectedError: nil, + }, + { + name: "Invalid version", + version: "999", + expectedResult: nil, + expectedError: fmt.Errorf("unsupported format_version: 999"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualResult, err := loadSectionsFromConfig(tt.version) + assert.Equal(t, tt.expectedResult, actualResult, "Result does not match expected") + assert.Equal(t, tt.expectedError, err, "Error does not match expected") + }) + } +} diff --git a/test/packages/other/readme_structure/build/build.yml b/test/packages/other/readme_structure/build/build.yml new file mode 100644 index 0000000000..002aa15659 --- /dev/null +++ b/test/packages/other/readme_structure/build/build.yml @@ -0,0 +1,3 @@ +dependencies: + ecs: + reference: git@1.10 diff --git a/test/packages/other/readme_structure/build/docs/README.md b/test/packages/other/readme_structure/build/docs/README.md new file mode 100644 index 0000000000..dc10130057 --- /dev/null +++ b/test/packages/other/readme_structure/build/docs/README.md @@ -0,0 +1,64 @@ +# Test Integration + +## Overview + +This is a test integration. + +### Compatibility + +Compatible with Linux, Windows, macOS, and imaginary systems. + +## What data does this integration collect? + +This integration collects basic event logs, dummy metrics, and simulated user actions. + +### Supported use cases + +- Testing pipeline connectivity +- Simulating event ingestion +- Dummy dashboard population + +## What do I need to use this integration? + +- A running instance of the test agent +- Dummy API credentials (username: `test`, password: `dummy`) +- Optional: a container or VM to simulate load + +## How do I deploy this integration? + +1. Install the test integration package. +2. Configure the dummy data source. +3. Enable the integration via the dashboard. +4. Verify events are received. + +## Troubleshooting + +- If no data is shown, ensure the dummy service is running. +- Check logs at `/var/log/dummy.log` for errors. +- Restart the test agent if needed. + +## Performance and scaling + +This integration is lightweight and scales linearly with event volume. +For high-load testing, deploy across multiple nodes. + +## Reference + +### ECS field reference + +**Exported fields** + +| Field | Description | Type | +|-------------|---------------------|---------| +| `@timestamp`| Event timestamp. | `date` | +| `message` | Dummy log message. | `text` | +| `event.type`| Type of event. | `keyword` | + +### Example event + +```json +{ + "@timestamp": "2012-10-30T09:46:12.000Z", + "message": "Dummy event triggered", + "event.type": "info" +} diff --git a/test/packages/other/readme_structure/changelog.yml b/test/packages/other/readme_structure/changelog.yml new file mode 100644 index 0000000000..bb0320a524 --- /dev/null +++ b/test/packages/other/readme_structure/changelog.yml @@ -0,0 +1,6 @@ +# newer versions go on top +- version: "0.0.1" + changes: + - description: Initial draft of the package + type: enhancement + link: https://github.com/elastic/integrations/pull/1 # FIXME Replace with the real PR link diff --git a/test/packages/other/readme_structure/docs/README.md b/test/packages/other/readme_structure/docs/README.md new file mode 100644 index 0000000000..11ea6bcd31 --- /dev/null +++ b/test/packages/other/readme_structure/docs/README.md @@ -0,0 +1,60 @@ +# Test Integration + +### Compatibility + +Compatible with Linux, Windows, macOS, and imaginary systems. + +## What data does this integration collect? + +This integration collects basic event logs, dummy metrics, and simulated user actions. + +### Supported use cases + +- Testing pipeline connectivity +- Simulating event ingestion +- Dummy dashboard population + +## What do I need to use this integration? + +- A running instance of the test agent +- Dummy API credentials (username: `test`, password: `dummy`) +- Optional: a container or VM to simulate load + +## How do I deploy this integration? + +1. Install the test integration package. +2. Configure the dummy data source. +3. Enable the integration via the dashboard. +4. Verify events are received. + +## Troubleshooting + +- If no data is shown, ensure the dummy service is running. +- Check logs at `/var/log/dummy.log` for errors. +- Restart the test agent if needed. + +## Performance and scaling + +This integration is lightweight and scales linearly with event volume. +For high-load testing, deploy across multiple nodes. + +## Reference + +### ECS field reference + +**Exported fields** + +| Field | Description | Type | +|-------------|---------------------|---------| +| `@timestamp`| Event timestamp. | `date` | +| `message` | Dummy log message. | `text` | +| `event.type`| Type of event. | `keyword` | + +### Example event + +```json +{ + "@timestamp": "2012-10-30T09:46:12.000Z", + "message": "Dummy event triggered", + "event.type": "info" +} diff --git a/test/packages/other/readme_structure/manifest.yml b/test/packages/other/readme_structure/manifest.yml new file mode 100644 index 0000000000..983a7b5c29 --- /dev/null +++ b/test/packages/other/readme_structure/manifest.yml @@ -0,0 +1,13 @@ +format_version: 1.0.0 +name: readme_structure +title: "Readme Structure" +version: 0.0.1 +license: basic +description: "These are tests of Readme's structure." +type: integration +categories: + - custom +conditions: + kibana.version: "^8.0.0" +owner: + github: elastic/integrations diff --git a/test/packages/other/readme_structure/validation.yml b/test/packages/other/readme_structure/validation.yml new file mode 100644 index 0000000000..14ed0d097f --- /dev/null +++ b/test/packages/other/readme_structure/validation.yml @@ -0,0 +1,6 @@ +errors: + exclude_checks: + - SVR00005 # Kibana version for saved tags. +docs_structure_enforced: + enabled: true + version: 1 \ No newline at end of file