mirror of
https://github.com/kyverno/kyverno.git
synced 2025-03-05 07:26:55 +00:00
refactor: cli test command (#5550)
Signed-off-by: Charles-Edouard Brétéché <charles.edouard@nirmata.com> Signed-off-by: Charles-Edouard Brétéché <charles.edouard@nirmata.com>
This commit is contained in:
parent
a7b4089613
commit
6893842226
8 changed files with 172 additions and 143 deletions
|
@ -13,7 +13,6 @@ import (
|
|||
"github.com/go-git/go-billy/v5/memfs"
|
||||
kyvernov1 "github.com/kyverno/kyverno/api/kyverno/v1"
|
||||
"github.com/kyverno/kyverno/api/kyverno/v1beta1"
|
||||
"github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/test"
|
||||
"github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/utils/common"
|
||||
sanitizederror "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/utils/sanitizedError"
|
||||
"github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/utils/store"
|
||||
|
@ -21,6 +20,7 @@ import (
|
|||
"github.com/kyverno/kyverno/pkg/config"
|
||||
"github.com/kyverno/kyverno/pkg/openapi"
|
||||
policy2 "github.com/kyverno/kyverno/pkg/policy"
|
||||
gitutils "github.com/kyverno/kyverno/pkg/utils/git"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
@ -251,13 +251,13 @@ func (c *ApplyCommandConfig) applyCommandHelper() (rc *common.ResultCounts, reso
|
|||
var gitPathToYamls string
|
||||
if isGit {
|
||||
c.GitBranch, gitPathToYamls = common.GetGitBranchOrPolicyPaths(c.GitBranch, repoURL, c.PolicyPaths)
|
||||
_, cloneErr := test.Clone(repoURL, fs, c.GitBranch)
|
||||
_, cloneErr := gitutils.Clone(repoURL, fs, c.GitBranch)
|
||||
if cloneErr != nil {
|
||||
fmt.Printf("Error: failed to clone repository \nCause: %s\n", cloneErr)
|
||||
log.Log.V(3).Info(fmt.Sprintf("failed to clone repository %v as it is not valid", repoURL), "error", cloneErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
policyYamls, err := test.ListYAMLs(fs, gitPathToYamls)
|
||||
policyYamls, err := gitutils.ListYamls(fs, gitPathToYamls)
|
||||
if err != nil {
|
||||
return rc, resources, skipInvalidPolicies, pvInfos, sanitizederror.NewWithError("failed to list YAMLs in repository", err)
|
||||
}
|
||||
|
|
67
cmd/cli/kubectl-kyverno/test/api/types.go
Normal file
67
cmd/cli/kubectl-kyverno/test/api/types.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
policyreportv1alpha2 "github.com/kyverno/kyverno/api/policyreport/v1alpha2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
type Test struct {
|
||||
Name string `json:"name"`
|
||||
Policies []string `json:"policies"`
|
||||
Resources []string `json:"resources"`
|
||||
Variables string `json:"variables"`
|
||||
UserInfo string `json:"userinfo"`
|
||||
Results []TestResults `json:"results"`
|
||||
}
|
||||
|
||||
type TestResults struct {
|
||||
// Policy mentions the name of the policy.
|
||||
Policy string `json:"policy"`
|
||||
// Rule mentions the name of the rule in the policy.
|
||||
Rule string `json:"rule"`
|
||||
// Result mentions the result that the user is expecting.
|
||||
// Possible values are pass, fail and skip.
|
||||
Result policyreportv1alpha2.PolicyResult `json:"result"`
|
||||
// Status mentions the status that the user is expecting.
|
||||
// Possible values are pass, fail and skip.
|
||||
Status policyreportv1alpha2.PolicyResult `json:"status"`
|
||||
// Resource mentions the name of the resource on which the policy is to be applied.
|
||||
Resource string `json:"resource"`
|
||||
// Resources gives us the list of resources on which the policy is going to be applied.
|
||||
Resources []string `json:"resources"`
|
||||
// Kind mentions the kind of the resource on which the policy is to be applied.
|
||||
Kind string `json:"kind"`
|
||||
// Namespace mentions the namespace of the policy which has namespace scope.
|
||||
Namespace string `json:"namespace"`
|
||||
// PatchedResource takes a resource configuration file in yaml format from
|
||||
// the user to compare it against the Kyverno mutated resource configuration.
|
||||
PatchedResource string `json:"patchedResource"`
|
||||
// AutoGeneratedRule is internally set by the CLI command. It takes values either
|
||||
// autogen or autogen-cronjob.
|
||||
AutoGeneratedRule string `json:"auto_generated_rule"`
|
||||
// GeneratedResource takes a resource configuration file in yaml format from
|
||||
// the user to compare it against the Kyverno generated resource configuration.
|
||||
GeneratedResource string `json:"generatedResource"`
|
||||
// CloneSourceResource takes the resource configuration file in yaml format
|
||||
// from the user which is meant to be cloned by the generate rule.
|
||||
CloneSourceResource string `json:"cloneSourceResource"`
|
||||
}
|
||||
|
||||
type ReportResult struct {
|
||||
TestResults
|
||||
Resources []*corev1.ObjectReference `json:"resources"`
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
Name string `json:"name"`
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
Name string `json:"name"`
|
||||
Resources []Resource `json:"resources"`
|
||||
}
|
||||
|
||||
type Values struct {
|
||||
Policies []Policy `json:"policies"`
|
||||
}
|
21
cmd/cli/kubectl-kyverno/test/manifest/mutate.go
Normal file
21
cmd/cli/kubectl-kyverno/test/manifest/mutate.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
package manifest
|
||||
|
||||
func PrintValidate() {
|
||||
print(`
|
||||
name: <test_name>
|
||||
policies:
|
||||
- <path/to/policy1.yaml>
|
||||
- <path/to/policy2.yaml>
|
||||
resources:
|
||||
- <path/to/resource1.yaml>
|
||||
- <path/to/resource2.yaml>
|
||||
variables: <variable_file> (OPTIONAL)
|
||||
results:
|
||||
- policy: <name> (For Namespaced [Policy] files, format is <policy_namespace>/<policy_name>)
|
||||
rule: <name>
|
||||
resource: <name>
|
||||
namespace: <name> (OPTIONAL)
|
||||
kind: <name>
|
||||
patchedResource: <path/to/patched/resource.yaml>
|
||||
result: <pass|fail|skip>`)
|
||||
}
|
7
cmd/cli/kubectl-kyverno/test/manifest/print.go
Normal file
7
cmd/cli/kubectl-kyverno/test/manifest/print.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package manifest
|
||||
|
||||
import "fmt"
|
||||
|
||||
func print(manifest string) {
|
||||
fmt.Println(manifest)
|
||||
}
|
20
cmd/cli/kubectl-kyverno/test/manifest/validate.go
Normal file
20
cmd/cli/kubectl-kyverno/test/manifest/validate.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package manifest
|
||||
|
||||
func PrintMutate() {
|
||||
print(`
|
||||
name: <test_name>
|
||||
policies:
|
||||
- <path/to/policy1.yaml>
|
||||
- <path/to/policy2.yaml>
|
||||
resources:
|
||||
- <path/to/resource1.yaml>
|
||||
- <path/to/resource2.yaml>
|
||||
variables: <variable_file> (OPTIONAL)
|
||||
results:
|
||||
- policy: <name> (For Namespaced [Policy] files, format is <policy_namespace>/<policy_name>)
|
||||
rule: <name>
|
||||
resource: <name>
|
||||
namespace: <name> (OPTIONAL)
|
||||
kind: <name>
|
||||
result: <pass|fail|skip>`)
|
||||
}
|
|
@ -20,6 +20,8 @@ import (
|
|||
kyvernov1 "github.com/kyverno/kyverno/api/kyverno/v1"
|
||||
"github.com/kyverno/kyverno/api/kyverno/v1beta1"
|
||||
policyreportv1alpha2 "github.com/kyverno/kyverno/api/policyreport/v1alpha2"
|
||||
"github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/test/api"
|
||||
"github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/test/manifest"
|
||||
"github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/utils/common"
|
||||
sanitizederror "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/utils/sanitizedError"
|
||||
"github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/utils/store"
|
||||
|
@ -29,6 +31,7 @@ import (
|
|||
"github.com/kyverno/kyverno/pkg/engine/response"
|
||||
"github.com/kyverno/kyverno/pkg/openapi"
|
||||
policy2 "github.com/kyverno/kyverno/pkg/policy"
|
||||
gitutils "github.com/kyverno/kyverno/pkg/utils/git"
|
||||
"github.com/lensesio/tableprinter"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/exp/slices"
|
||||
|
@ -154,9 +157,8 @@ For more information visit https://kyverno.io/docs/kyverno-cli/#test
|
|||
func Command() *cobra.Command {
|
||||
var cmd *cobra.Command
|
||||
var testCase string
|
||||
var testFile []byte
|
||||
var fileName, gitBranch string
|
||||
var registryAccess, failOnly, removeColor bool
|
||||
var registryAccess, failOnly, removeColor, manifestValidate, manifestMutate bool
|
||||
cmd = &cobra.Command{
|
||||
Use: "test <path_to_folder_Containing_test.yamls> [flags]\n kyverno test <path_to_gitRepository_with_dir> --git-branch <branchName>\n kyverno test --manifest-mutate > kyverno-test.yaml\n kyverno test --manifest-validate > kyverno-test.yaml",
|
||||
// Args: cobra.ExactArgs(1),
|
||||
|
@ -172,121 +174,32 @@ func Command() *cobra.Command {
|
|||
}
|
||||
}
|
||||
}()
|
||||
|
||||
mStatus, _ := cmd.Flags().GetBool("manifest-mutate")
|
||||
vStatus, _ := cmd.Flags().GetBool("manifest-validate")
|
||||
if mStatus {
|
||||
testFile = []byte(`name: <test_name>
|
||||
policies:
|
||||
- <path/to/policy1.yaml>
|
||||
- <path/to/policy2.yaml>
|
||||
resources:
|
||||
- <path/to/resource1.yaml>
|
||||
- <path/to/resource2.yaml>
|
||||
variables: <variable_file> (OPTIONAL)
|
||||
results:
|
||||
- policy: <name> (For Namespaced [Policy] files, format is <policy_namespace>/<policy_name>)
|
||||
rule: <name>
|
||||
resource: <name>
|
||||
namespace: <name> (OPTIONAL)
|
||||
kind: <name>
|
||||
patchedResource: <path/to/patched/resource.yaml>
|
||||
result: <pass|fail|skip>`)
|
||||
fmt.Println(string(testFile))
|
||||
return nil
|
||||
if manifestMutate {
|
||||
manifest.PrintMutate()
|
||||
} else if manifestValidate {
|
||||
manifest.PrintValidate()
|
||||
} else {
|
||||
store.SetRegistryAccess(registryAccess)
|
||||
_, err = testCommandExecute(dirPath, fileName, gitBranch, testCase, failOnly, removeColor)
|
||||
if err != nil {
|
||||
log.Log.V(3).Info("a directory is required")
|
||||
return err
|
||||
}
|
||||
}
|
||||
if vStatus {
|
||||
testFile = []byte(`name: <test_name>
|
||||
policies:
|
||||
- <path/to/policy1.yaml>
|
||||
- <path/to/policy2.yaml>
|
||||
resources:
|
||||
- <path/to/resource1.yaml>
|
||||
- <path/to/resource2.yaml>
|
||||
variables: <variable_file> (OPTIONAL)
|
||||
results:
|
||||
- policy: <name> (For Namespaced [Policy] files, format is <policy_namespace>/<policy_name>)
|
||||
rule: <name>
|
||||
resource: <name>
|
||||
namespace: <name> (OPTIONAL)
|
||||
kind: <name>
|
||||
result: <pass|fail|skip>`)
|
||||
fmt.Println(string(testFile))
|
||||
return nil
|
||||
}
|
||||
store.SetRegistryAccess(registryAccess)
|
||||
_, err = testCommandExecute(dirPath, fileName, gitBranch, testCase, failOnly, removeColor)
|
||||
if err != nil {
|
||||
log.Log.V(3).Info("a directory is required")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&fileName, "file-name", "f", "kyverno-test.yaml", "test filename")
|
||||
cmd.Flags().StringVarP(&gitBranch, "git-branch", "b", "", "test github repository branch")
|
||||
cmd.Flags().StringVarP(&testCase, "test-case-selector", "t", "", `run some specific test cases by passing a string argument in double quotes to this flag like - "policy=<policy_name>, rule=<rule_name>, resource=<resource_name". The argument could be any combination of policy, rule and resource.`)
|
||||
cmd.Flags().BoolP("manifest-mutate", "", false, "prints out a template test manifest for a mutate policy")
|
||||
cmd.Flags().BoolP("manifest-validate", "", false, "prints out a template test manifest for a validate policy")
|
||||
cmd.Flags().BoolVarP(&manifestMutate, "manifest-mutate", "", false, "prints out a template test manifest for a mutate policy")
|
||||
cmd.Flags().BoolVarP(&manifestValidate, "manifest-validate", "", false, "prints out a template test manifest for a validate policy")
|
||||
cmd.Flags().BoolVarP(®istryAccess, "registry", "", false, "If set to true, access the image registry using local docker credentials to populate external data")
|
||||
cmd.Flags().BoolVarP(&failOnly, "fail-only", "", false, "If set to true, display all the failing test only as output for the test command")
|
||||
cmd.Flags().BoolVarP(&removeColor, "remove-color", "", false, "Remove any color from output")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type Test struct {
|
||||
Name string `json:"name"`
|
||||
Policies []string `json:"policies"`
|
||||
Resources []string `json:"resources"`
|
||||
Variables string `json:"variables"`
|
||||
UserInfo string `json:"userinfo"`
|
||||
Results []TestResults `json:"results"`
|
||||
}
|
||||
|
||||
type TestResults struct {
|
||||
// Policy mentions the name of the policy.
|
||||
Policy string `json:"policy"`
|
||||
// Rule mentions the name of the rule in the policy.
|
||||
Rule string `json:"rule"`
|
||||
// Result mentions the result that the user is expecting.
|
||||
// Possible values are pass, fail and skip.
|
||||
Result policyreportv1alpha2.PolicyResult `json:"result"`
|
||||
// Status mentions the status that the user is expecting.
|
||||
// Possible values are pass, fail and skip.
|
||||
Status policyreportv1alpha2.PolicyResult `json:"status"`
|
||||
// Resource mentions the name of the resource on which the policy is to be applied.
|
||||
Resource string `json:"resource"`
|
||||
// Resources gives us the list of resources on which the policy is going to be applied.
|
||||
Resources []string `json:"resources"`
|
||||
// Kind mentions the kind of the resource on which the policy is to be applied.
|
||||
Kind string `json:"kind"`
|
||||
// Namespace mentions the namespace of the policy which has namespace scope.
|
||||
Namespace string `json:"namespace"`
|
||||
// PatchedResource takes a resource configuration file in yaml format from
|
||||
// the user to compare it against the Kyverno mutated resource configuration.
|
||||
PatchedResource string `json:"patchedResource"`
|
||||
// AutoGeneratedRule is internally set by the CLI command. It takes values either
|
||||
// autogen or autogen-cronjob.
|
||||
AutoGeneratedRule string `json:"auto_generated_rule"`
|
||||
// GeneratedResource takes a resource configuration file in yaml format from
|
||||
// the user to compare it against the Kyverno generated resource configuration.
|
||||
GeneratedResource string `json:"generatedResource"`
|
||||
// CloneSourceResource takes the resource configuration file in yaml format
|
||||
// from the user which is meant to be cloned by the generate rule.
|
||||
CloneSourceResource string `json:"cloneSourceResource"`
|
||||
}
|
||||
|
||||
type ReportResult struct {
|
||||
TestResults
|
||||
Resources []*corev1.ObjectReference `json:"resources"`
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
Name string `json:"name"`
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
ID int `header:"#"`
|
||||
Policy string `header:"policy"`
|
||||
|
@ -294,14 +207,6 @@ type Table struct {
|
|||
Resource string `header:"resource"`
|
||||
Result string `header:"result"`
|
||||
}
|
||||
type Policy struct {
|
||||
Name string `json:"name"`
|
||||
Resources []Resource `json:"resources"`
|
||||
}
|
||||
|
||||
type Values struct {
|
||||
Policies []Policy `json:"policies"`
|
||||
}
|
||||
|
||||
type resultCounts struct {
|
||||
Skip int
|
||||
|
@ -404,14 +309,14 @@ func testCommandExecute(dirPath []string, fileName string, gitBranch string, tes
|
|||
}
|
||||
}
|
||||
|
||||
_, cloneErr := Clone(repoURL, fs, gitBranch)
|
||||
_, cloneErr := gitutils.Clone(repoURL, fs, gitBranch)
|
||||
if cloneErr != nil {
|
||||
fmt.Printf("Error: failed to clone repository \nCause: %s\n", cloneErr)
|
||||
log.Log.V(3).Info(fmt.Sprintf("failed to clone repository %v as it is not valid", repoURL), "error", cloneErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
policyYamls, err := ListYAMLs(fs, gitPathToYamls)
|
||||
policyYamls, err := gitutils.ListYamls(fs, gitPathToYamls)
|
||||
if err != nil {
|
||||
return rc, sanitizederror.NewWithError("failed to list YAMLs in repository", err)
|
||||
}
|
||||
|
@ -513,7 +418,7 @@ func getLocalDirTestFiles(fs billy.Filesystem, path, fileName string, rc *result
|
|||
return errors
|
||||
}
|
||||
|
||||
func buildPolicyResults(engineResponses []*response.EngineResponse, testResults []TestResults, infos []common.Info, policyResourcePath string, fs billy.Filesystem, isGit bool) (map[string]policyreportv1alpha2.PolicyReportResult, []TestResults) {
|
||||
func buildPolicyResults(engineResponses []*response.EngineResponse, testResults []api.TestResults, infos []common.Info, policyResourcePath string, fs billy.Filesystem, isGit bool) (map[string]policyreportv1alpha2.PolicyReportResult, []api.TestResults) {
|
||||
results := make(map[string]policyreportv1alpha2.PolicyReportResult)
|
||||
now := metav1.Timestamp{Seconds: time.Now().Unix()}
|
||||
|
||||
|
@ -819,7 +724,7 @@ func getFullPath(paths []string, policyResourcePath string, isGit bool) []string
|
|||
func applyPoliciesFromPath(fs billy.Filesystem, policyBytes []byte, isGit bool, policyResourcePath string, rc *resultCounts, openApiManager openapi.Manager, tf *testFilter, failOnly, removeColor bool) (err error) {
|
||||
engineResponses := make([]*response.EngineResponse, 0)
|
||||
var dClient dclient.Interface
|
||||
values := &Test{}
|
||||
values := &api.Test{}
|
||||
var variablesString string
|
||||
var pvInfos []common.Info
|
||||
var resultCounts common.ResultCounts
|
||||
|
@ -830,7 +735,7 @@ func applyPoliciesFromPath(fs billy.Filesystem, policyBytes []byte, isGit bool,
|
|||
}
|
||||
|
||||
if tf.enabled {
|
||||
var filteredResults []TestResults
|
||||
var filteredResults []api.TestResults
|
||||
for _, res := range values.Results {
|
||||
if (len(tf.policy) == 0 || tf.policy == res.Policy) && (len(tf.resource) == 0 || tf.resource == res.Resource) && (len(tf.rule) == 0 || tf.rule == res.Rule) {
|
||||
filteredResults = append(filteredResults, res)
|
||||
|
@ -1045,7 +950,7 @@ func applyPoliciesFromPath(fs billy.Filesystem, policyBytes []byte, isGit bool,
|
|||
return
|
||||
}
|
||||
|
||||
func printTestResult(resps map[string]policyreportv1alpha2.PolicyReportResult, testResults []TestResults, rc *resultCounts, failOnly, removeColor bool) error {
|
||||
func printTestResult(resps map[string]policyreportv1alpha2.PolicyReportResult, testResults []api.TestResults, rc *resultCounts, failOnly, removeColor bool) error {
|
||||
printer := tableprinter.New(os.Stdout)
|
||||
table := []Table{}
|
||||
boldGreen := color.New(color.FgGreen).Add(color.Bold)
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
package test
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
|
@ -21,37 +22,31 @@ func Clone(path string, fs billy.Filesystem, branch string) (*git.Repository, er
|
|||
})
|
||||
}
|
||||
|
||||
func ListYAMLs(fs billy.Filesystem, path string) ([]string, error) {
|
||||
func ListFiles(fs billy.Filesystem, path string, predicate func(fs.FileInfo) bool) ([]string, error) {
|
||||
path = filepath.Clean(path)
|
||||
|
||||
if _, err := fs.Stat(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fis, err := fs.ReadDir(path)
|
||||
files, err := fs.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
yamls := make([]string, 0)
|
||||
|
||||
for _, fi := range fis {
|
||||
name := filepath.Join(path, fi.Name())
|
||||
if fi.IsDir() {
|
||||
moreYAMLs, err := ListYAMLs(fs, name)
|
||||
var results []string
|
||||
for _, file := range files {
|
||||
name := filepath.Join(path, file.Name())
|
||||
if file.IsDir() {
|
||||
children, err := ListFiles(fs, name, predicate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
yamls = append(yamls, moreYAMLs...)
|
||||
continue
|
||||
results = append(results, children...)
|
||||
} else if predicate(file) {
|
||||
results = append(results, name)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(name)
|
||||
if ext != ".yml" && ext != ".yaml" {
|
||||
continue
|
||||
}
|
||||
|
||||
yamls = append(yamls, name)
|
||||
}
|
||||
return yamls, nil
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func ListYamls(f billy.Filesystem, path string) ([]string, error) {
|
||||
return ListFiles(f, path, IsYaml)
|
||||
}
|
14
pkg/utils/git/predicate.go
Normal file
14
pkg/utils/git/predicate.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func IsYaml(file fs.FileInfo) bool {
|
||||
if file.IsDir() {
|
||||
return false
|
||||
}
|
||||
ext := filepath.Ext(file.Name())
|
||||
return ext == ".yml" || ext == ".yaml"
|
||||
}
|
Loading…
Add table
Reference in a new issue