diff --git a/cmd/suite/render/doc.go b/cmd/suite/render/doc.go new file mode 100644 index 00000000..a6f79b35 --- /dev/null +++ b/cmd/suite/render/doc.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 The Falco 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 render provides the implementation of the "render" command. This command lets users render the test(s) YAML +// description and display it to output. +package render diff --git a/cmd/suite/render/render.go b/cmd/suite/render/render.go new file mode 100644 index 00000000..c011f953 --- /dev/null +++ b/cmd/suite/render/render.go @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 The Falco 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 render + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/go-logr/logr" + "github.com/spf13/cobra" + + "github.com/falcosecurity/event-generator/cmd/suite/config" + "github.com/falcosecurity/event-generator/pkg/envvar" + "github.com/falcosecurity/event-generator/pkg/test/loader" +) + +const ( + longDescriptionPrefaceTemplate = `%s. +The rendering process instantiates any template present in the YAML description. +It is possible to provide the YAML description in multiple ways. The order of evaluation is the following: +1) If --%s= and/or --%s= flags are/is provided, it is read from the file(s) at and/or contained in +2) If the --%s= flag is provided, it is read from the string +3) Otherwise, it is read from standard input +For each YAML description source, a separate YAML document is written to output. Each document is annotated with a comment indicating the source name. +` + longDescriptionHeading = "Render the test(s) YAML description and display it to output" +) + +var ( + longDescription = fmt.Sprintf(longDescriptionPrefaceTemplate, longDescriptionHeading, + config.DescriptionFileFlagName, config.DescriptionDirFlagName, config.DescriptionFlagName) +) + +// CommandWrapper wraps the command and stores the associated flag values. +type CommandWrapper struct { + Command *cobra.Command + envKeysPrefix string + suiteEnvKey string + + // Flags + // + // testsDescriptionFiles is the list of pathnames of files containing the YAML tests descriptions. If + // testsDescription is provided, this is empty. + testsDescriptionFiles []string + // testsDescriptionDirs is the list of pathnames of directories containing the YAML tests description files. If + // testsDescription is provided, this is empty. + testsDescriptionDirs []string + // testsDescription is the YAML tests description. If testsDescriptionFiles or testsDescriptionDirs are provided, + // this is empty. + testsDescription string +} + +// New creates a new render command. +func New(suiteEnvKey, envKeysPrefix string) *CommandWrapper { + cw := &CommandWrapper{envKeysPrefix: envKeysPrefix, suiteEnvKey: suiteEnvKey} + c := &cobra.Command{ + Use: "render", + Short: longDescriptionHeading, + Long: longDescription, + DisableAutoGenTag: true, + Run: cw.run, + } + cw.Command = c + cw.initCommandFlags() + return cw +} + +// initCommandFlags initializes the command's flags. +func (cw *CommandWrapper) initCommandFlags() { + cmd := cw.Command + flags := cmd.Flags() + + flags.StringSliceVarP(&cw.testsDescriptionFiles, config.DescriptionFileFlagName, "f", nil, + "The pathnames of tests description YAML files specifying the tests to be rendered. Multiple pathnames can be "+ + "specified as a comma-separated list. The flag can be specified multiple times. Pathnames are evaluated "+ + "in order of appearance") + flags.StringSliceVarP(&cw.testsDescriptionDirs, config.DescriptionDirFlagName, "d", nil, + "The pathnames of directories containing tests description YAML files specifying the tests to be rendered. "+ + "Sub-directories of the provided pathnames are not recursively loaded. Only files with YAML extensions "+ + "are loaded. Multiple pathnames can be specified as a comma-separated list. The flag can be specified "+ + "multiple times. Pathnames are evaluated in order of appearance") + flags.StringVar(&cw.testsDescription, config.DescriptionFlagName, "", + "The YAML-formatted tests description string specifying the tests to be rendered") + cmd.MarkFlagsMutuallyExclusive(config.DescriptionFileFlagName, config.DescriptionFlagName) + cmd.MarkFlagsMutuallyExclusive(config.DescriptionDirFlagName, config.DescriptionFlagName) +} + +func (cw *CommandWrapper) run(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + logger, err := logr.FromContext(ctx) + if err != nil { + panic(fmt.Sprintf("logger unconfigured: %v", err)) + } + + logger = logger.WithName("main") + + // Note: keep the following in sync with the corresponding code in "test" package. + reservedEnvKeyPrefixes := []string{envvar.KeyFromFlagName(cw.envKeysPrefix, "")} + reservedEnvKeys := []string{cw.suiteEnvKey} + testDescs, err := loadTests(logger, cw.testsDescriptionFiles, cw.testsDescriptionDirs, cw.testsDescription, + reservedEnvKeyPrefixes, reservedEnvKeys) + if err != nil { + logger.Error(err, "Error loading tests") + os.Exit(1) + } + + w := os.Stdout + for _, testDesc := range testDescs { + source := testDesc.source + logger := logger.WithValues("source", source) + header := fmt.Sprintf("---\n# source: %s\n", source) + if _, err := w.WriteString(header); err != nil { + logger.Error(err, "Error writing tests description header") + os.Exit(1) + } + + if err := testDesc.desc.Write(w); err != nil { + logger.Error(err, "Error writing tests description") + os.Exit(1) + } + } +} + +type sourcedDescription struct { + source string + desc *loader.Description +} + +// loadTests loads tests description(s) from a different source, depending on the content of the provided values: +// - if the provided descriptionFilePaths or descriptionDirPaths are not empty, they are loaded both from the +// specified files (if any) and from the YAML files (if any) in the specified directories (if any); +// - if the provided description is not empty, they are loaded from its content; +// - otherwise, they are loaded from standard input. +func loadTests(logger logr.Logger, descriptionFilePaths, descriptionDirPaths []string, description string, + reservedEnvKeyPrefixes, reservedEnvKeys []string) ([]*sourcedDescription, error) { + descLoader := loader.New(reservedEnvKeyPrefixes, reservedEnvKeys) + + // Load from the specified files or directories. + if len(descriptionFilePaths) > 0 || len(descriptionDirPaths) > 0 { + var testsDescs []*sourcedDescription + for _, descriptionDirPath := range descriptionDirPaths { + descs, err := loadTestsFromDescriptionDir(logger, descLoader, descriptionDirPath) + if err != nil { + return nil, fmt.Errorf("error loading description directory %q: %w", descriptionDirPath, err) + } + testsDescs = append(testsDescs, descs...) + } + + for _, descriptionFilePath := range descriptionFilePaths { + desc, err := loadTestsFromDescriptionFile(logger, descLoader, descriptionFilePath) + if err != nil { + return nil, fmt.Errorf("error loading description file %q: %w", descriptionFilePath, err) + } + testsDescs = append(testsDescs, desc) + } + + return testsDescs, nil + } + + // Load from the provided description string. + if description != "" { + desc, err := descLoader.Load(strings.NewReader(description)) + if err != nil { + return nil, fmt.Errorf("error loading from description flag: %w", err) + } + return []*sourcedDescription{{source: "", desc: desc}}, nil + } + + // Load from standard input. + desc, err := descLoader.Load(os.Stdin) + if err != nil { + return nil, fmt.Errorf("error loading from stdin: %w", err) + } + return []*sourcedDescription{{source: "", desc: desc}}, nil +} + +// loadTestsFromDescriptionDir loads tests descriptions from YAML files inside the directory at the provided path and +// returns them. +func loadTestsFromDescriptionDir(logger logr.Logger, descLoader *loader.Loader, + descriptionDirPath string) ([]*sourcedDescription, error) { + descriptionDirPath = filepath.Clean(descriptionDirPath) + dirEntries, err := os.ReadDir(descriptionDirPath) + if err != nil { + return nil, fmt.Errorf("error reading entries in directory %q: %w", descriptionDirPath, err) + } + + testsDescs := make([]*sourcedDescription, 0, len(dirEntries)) + + for _, dirEntry := range dirEntries { + if dirEntry.IsDir() { + continue + } + + name := dirEntry.Name() + if !strings.HasSuffix(name, ".yaml") { + continue + } + + descriptionFilePath := filepath.Join(descriptionDirPath, name) + testsDesc, err := loadTestsFromDescriptionFile(logger, descLoader, descriptionFilePath) + if err != nil { + return nil, fmt.Errorf("error loading description file %q: %w", name, err) + } + + testsDescs = append(testsDescs, testsDesc) + } + + return testsDescs, nil +} + +// loadTestsFromDescriptionFile loads tests description from the file at the provided path and returns it. The +// associated name is the "cleaned" version of the provided path (see filepath.Clean). +func loadTestsFromDescriptionFile(logger logr.Logger, descLoader *loader.Loader, + descriptionFilePath string) (*sourcedDescription, error) { + descriptionFilePath = filepath.Clean(descriptionFilePath) + descriptionFile, err := os.Open(descriptionFilePath) + if err != nil { + return nil, fmt.Errorf("error opening file path %q: %w", descriptionFilePath, err) + } + defer func() { + if err := descriptionFile.Close(); err != nil { + logger.Error(err, "Error closing description file", "path", descriptionFilePath) + } + }() + + desc, err := descLoader.Load(descriptionFile) + if err != nil { + return nil, err + } + + return &sourcedDescription{source: descriptionFilePath, desc: desc}, nil +} diff --git a/cmd/suite/run/run.go b/cmd/suite/run/run.go index 161fc586..7cbe17dd 100644 --- a/cmd/suite/run/run.go +++ b/cmd/suite/run/run.go @@ -27,8 +27,8 @@ import ( const ( longDescriptionPrefaceTemplate = `%s. It is possible to provide the YAML description in multiple ways. The order of evaluation is the following: -1) If the --%s= flag is provided the description is read from the file at -2) If the --%s= flag is provided, the description is read from the string +1) If --%s= and/or --%s= flags are/is provided, it is read from the file(s) at and/or contained in +2) If the --%s= flag is provided, it is read from the string 3) Otherwise, it is read from standard input` longDescriptionHeading = "Run test(s) specified via a YAML description" warningMessage = `Warning: @@ -38,7 +38,7 @@ It is possible to provide the YAML description in multiple ways. The order of ev var ( longDescriptionPreface = fmt.Sprintf(longDescriptionPrefaceTemplate, longDescriptionHeading, - config.DescriptionFileFlagName, config.DescriptionFlagName) + config.DescriptionFileFlagName, config.DescriptionDirFlagName, config.DescriptionFlagName) longDescription = fmt.Sprintf("%s\n\n%s", longDescriptionPreface, warningMessage) ) diff --git a/cmd/suite/suite.go b/cmd/suite/suite.go index a3503ce0..2cc3fa4c 100644 --- a/cmd/suite/suite.go +++ b/cmd/suite/suite.go @@ -20,6 +20,7 @@ import ( "github.com/falcosecurity/event-generator/cmd/suite/config" "github.com/falcosecurity/event-generator/cmd/suite/explain" + "github.com/falcosecurity/event-generator/cmd/suite/render" "github.com/falcosecurity/event-generator/cmd/suite/run" "github.com/falcosecurity/event-generator/cmd/suite/test" "github.com/falcosecurity/event-generator/cmd/suite/verify" @@ -40,9 +41,11 @@ func New(suiteEnvKey, envKeysPrefix string) *cobra.Command { testCmd := test.New(commonConf, false).Command explainCmd := explain.New().Command verifyCmd := verify.New(suiteEnvKey, envKeysPrefix).Command + renderCmd := render.New(suiteEnvKey, envKeysPrefix).Command c.AddCommand(runCmd) c.AddCommand(testCmd) c.AddCommand(explainCmd) c.AddCommand(verifyCmd) + c.AddCommand(renderCmd) return c } diff --git a/cmd/suite/test/test.go b/cmd/suite/test/test.go index 5d36ccc4..0838868d 100644 --- a/cmd/suite/test/test.go +++ b/cmd/suite/test/test.go @@ -63,8 +63,8 @@ const ( const ( longDescriptionPrefaceTemplate = `%s. It is possible to provide the YAML description in multiple ways. The order of evaluation is the following: -1) If --%s= and/or --%s= flags are/is provided, the description is read from the file at -2) If the --%s= flag is provided, the description is read from the string +1) If --%s= and/or --%s= flags are/is provided, it is read from the file(s) at and/or contained in +2) If the --%s= flag is provided, it is read from the string 3) Otherwise, it is read from standard input` longDescriptionHeading = "Run test(s) specified via a YAML description and verify that they produce the expected outcomes" warningMessage = `Warning: @@ -173,7 +173,7 @@ func (cw *CommandWrapper) run(cmd *cobra.Command, _ []string) { isRootProcess := cw.TestID == "" verifyExpectedOutcome := isRootProcess && !cw.skipOutcomeVerification - // Note: keep the following in sync with the corresponding code in "verify" package. + // Note: keep the following in sync with the corresponding code in "verify" and "render" package. reservedEnvKeyPrefixes := []string{envvar.KeyFromFlagName(cw.EnvKeysPrefix, "")} reservedEnvKeys := []string{cw.SuiteEnvKey} testSuites, err := LoadSuites(logger, !verifyExpectedOutcome, cw.TestsDescriptionFiles, cw.TestsDescriptionDirs,