diff --git a/cmd/cli/kubectl-kyverno/commands/create/command.go b/cmd/cli/kubectl-kyverno/commands/create/command.go index 68aa2025a9..95365a271b 100644 --- a/cmd/cli/kubectl-kyverno/commands/create/command.go +++ b/cmd/cli/kubectl-kyverno/commands/create/command.go @@ -4,6 +4,7 @@ import ( "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/command" "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/commands/create/exception" metricsconfig "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/commands/create/metrics-config" + "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/commands/create/role" "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/commands/create/test" userinfo "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/commands/create/user-info" "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/commands/create/values" @@ -28,6 +29,7 @@ func Command() *cobra.Command { test.Command(), userinfo.Command(), values.Command(), + role.Command(), ) return cmd } diff --git a/cmd/cli/kubectl-kyverno/commands/create/role/command.go b/cmd/cli/kubectl-kyverno/commands/create/role/command.go new file mode 100644 index 0000000000..cdc7c710aa --- /dev/null +++ b/cmd/cli/kubectl-kyverno/commands/create/role/command.go @@ -0,0 +1,102 @@ +package role + +import ( + "fmt" + "log" + "os" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/kyverno/kyverno/cmd/cli/kubectl-kyverno/commands/create/templates" + "github.com/spf13/cobra" +) + +type options struct { + Verbs []string + Controllers []string + ApiGroup string + ResourceTypes []string + Name string +} + +func Command() *cobra.Command { + var verbs []string + var path string + var opts options + + cmd := &cobra.Command{ + Use: "cluster-role [name] ", + Short: "Create an aggregated role for given resource types", + Long: `This command generates a Kubernetes ClusterRole for specified resource types. +The output is printed to stdout by default or saved to a specified file. +Required flags include 'api-groups', 'verbs', and 'resources'.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // Validate input arguments + if args[0] == "" { + return fmt.Errorf("name argument is required") + } + opts.Name = args[0] + + if opts.ApiGroup == "" { + return fmt.Errorf("required flag(s) \"api-groups\" not set") + } + if len(opts.ResourceTypes) == 0 { + return fmt.Errorf("required flag(s) \"resources\" not set") + } + if len(verbs) == 0 { + return fmt.Errorf("required flag(s) \"verbs\" not set") + } + + if len(opts.Controllers) == 0 || (len(opts.Controllers) == 1 && opts.Controllers[0] == "") { + return fmt.Errorf("invalid controller provided") + } + + // Handle 'all' verb + if verbs[0] == "all" { + verbs = []string{"create", "get", "update", "delete", "list", "watch"} + } + opts.Verbs = verbs + + // Parse the role template + tmpl, err := template.New("aggregatedRole").Funcs(sprig.HermeticTxtFuncMap()).Parse(templates.AggregatedRoleTemplate) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + // Set output: file or stdout + output := cmd.OutOrStdout() + if path != "" { + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + output = file + } + + // Execute template with name and options + return tmpl.Execute(output, opts) + }, + } + + // Define flags + cmd.Flags().StringArrayVar(&opts.Controllers, "controllers", []string{"background-controller"}, "List of controllers for the ClusterRole (default = background-controller)") + cmd.Flags().StringVarP(&path, "output", "o", "", "Output file path (prints to console if not set)") + cmd.Flags().StringVarP(&opts.ApiGroup, "api-groups", "g", "", "API group for the resource (required)") + cmd.Flags().StringArrayVar(&verbs, "verbs", nil, "A comma separated list of verbs or 'all' for all verbs") + cmd.Flags().StringArrayVar(&opts.ResourceTypes, "resources", nil, "A comma separated list of resources (required)") + + // Mark required flags + if err := cmd.MarkFlagRequired("api-groups"); err != nil { + log.Println("WARNING", err) + } + if err := cmd.MarkFlagRequired("verbs"); err != nil { + log.Println("WARNING", err) + } + if err := cmd.MarkFlagRequired("resources"); err != nil { + log.Println("WARNING", err) + } + + return cmd +} diff --git a/cmd/cli/kubectl-kyverno/commands/create/role/command_test.go b/cmd/cli/kubectl-kyverno/commands/create/role/command_test.go new file mode 100644 index 0000000000..69a3fd0ff7 --- /dev/null +++ b/cmd/cli/kubectl-kyverno/commands/create/role/command_test.go @@ -0,0 +1,171 @@ +package role + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCommand(t *testing.T) { + tempDir := t.TempDir() + templateFile := filepath.Join(tempDir, "templates", "aggregated-role.yaml") + + // Sample template content for testing + templateContent := ` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kyverno-{{.Name}}-permission + labels: + {{- range .Controllers }} + rbac.kyverno.io/aggregate-to-{{ . }}: "true" + {{- end }} +rules: + - apiGroups: ["{{.ApiGroup}}"] + resources: ["{{.ResourceTypes | join \",\"}}"] + verbs: [{{range .Verbs}}"{{.}}",{{end}}] +` + + // Write the template file to the temporary directory + err := os.MkdirAll(filepath.Dir(templateFile), os.ModePerm) + assert.NoError(t, err) + err = os.WriteFile(templateFile, []byte(templateContent), 0644) + assert.NoError(t, err) + + // Define test cases + tests := []struct { + name string + args []string + expectedFile string + errorMsg string + }{ + { + name: "ValidCommandWithMultipleControllers", + args: []string{"name1", "--resources=crontabs", "--api-groups=stable.example.com", "--verbs=get,list", "--controllers=controller1", "--controllers=controller2"}, + expectedFile: "stdout", + }, + { + name: "ValidCommandWithDefaultController", + args: []string{"name2", "--resources=pods", "--api-groups=core", "--verbs=get,list"}, + expectedFile: "stdout", + }, + { + name: "MissingResources", + args: []string{"name3", "--api-groups=stable.example.com", "--verbs=get,list"}, + errorMsg: "required flag(s) \"resources\" not set", + }, + { + name: "MissingApiGroup", + args: []string{"name4", "--resources=crontabs", "--verbs=get,list"}, + errorMsg: "required flag(s) \"api-groups\" not set", + }, + { + name: "MissingVerbs", + args: []string{"name5", "--resources=crontabs", "--api-groups=stable.example.com"}, + errorMsg: "required flag(s) \"verbs\" not set", + }, + { + name: "AllVerbExpands", + args: []string{"name6", "--resources=pods", "--api-groups=core", "--verbs=all"}, + expectedFile: "stdout", + }, + { + name: "OutputToFile", + args: []string{"name7", "--resources=pods", "--api-groups=core", "--verbs=get,list", "--output=" + filepath.Join(tempDir, "test-output.yaml")}, + expectedFile: "test-output.yaml", + }, + { + name: "NoFlags", + args: []string{"name10"}, + errorMsg: "required flag(s) \"api-groups\", \"resources\", \"verbs\" not set", + }, + { + name: "InvalidController", + args: []string{"name8", "--resources=pods", "--api-groups=core", "--verbs=get,list", "--controllers="}, + errorMsg: "invalid controller provided", + }, + { + name: "MultipleResources", + args: []string{"name11", "--resources=pods,services", "--api-groups=core", "--verbs=get,list"}, + expectedFile: "stdout", + }, + { + name: "SingleVerb", + args: []string{"name12", "--resources=pods", "--api-groups=core", "--verbs=get"}, + expectedFile: "stdout", + }, + { + name: "NoApiGroup", + args: []string{"name13", "--resources=pods", "--verbs=get"}, + errorMsg: "required flag(s) \"api-groups\" not set", + }, + { + name: "EmptyName", + args: []string{"", "--resources=pods", "--api-groups=stable.example.com", "--verbs=get,list"}, + errorMsg: "name argument is required", + }, + { + name: "DifferentVerbCombinations", + args: []string{"name14", "--resources=pods", "--api-groups=core", "--verbs=create,delete"}, + expectedFile: "stdout", + }, + { + name: "ValidCommandWithMixedControllers", + args: []string{"name15", "--resources=pods", "--api-groups=core", "--verbs=get,list", "--controllers=controller1,controller2"}, + expectedFile: "stdout", + }, + + { + name: "AllFlagsWithComplexInput", + args: []string{"nameComplex", "--resources=pods,services", "--api-groups=core", "--verbs=get,list"}, + expectedFile: "stdout", + }, + { + name: "OutputFileCreationFailure", + args: []string{"nameOutputFail", "--resources=pods", "--api-groups=core", "--verbs=get,list", "--output=/invalid/path/test-output.yaml"}, + errorMsg: "failed to create file: ", + }, + { + name: "SpecialCharacterName", + args: []string{"name@#%", "--resources=pods", "--api-groups=core", "--verbs=get"}, + expectedFile: "stdout", + }, + } + + // Iterate over the test cases + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmd := Command() + cmd.SetArgs(tc.args) + + // Prepare a buffer to capture stdout + var stdoutBuffer bytes.Buffer + cmd.SetOut(&stdoutBuffer) + + // Execute the command and handle errors + err = cmd.Execute() + if tc.errorMsg != "" { + assert.ErrorContains(t, err, tc.errorMsg) + return + } + assert.NoError(t, err) + + // Check the output based on expected result + if tc.expectedFile == "stdout" { + output := stdoutBuffer.String() + assert.Contains(t, output, fmt.Sprintf("name: kyverno-%s-permission", tc.args[0])) + } else { + expectedFilePath := filepath.Join(tempDir, tc.expectedFile) + _, err := os.Stat(expectedFilePath) + assert.NoError(t, err) + + // Clean up the created file + _ = os.Remove(expectedFilePath) + } + }) + } +} diff --git a/cmd/cli/kubectl-kyverno/commands/create/templates/aggregated-role.yaml b/cmd/cli/kubectl-kyverno/commands/create/templates/aggregated-role.yaml new file mode 100644 index 0000000000..3a9f8f0837 --- /dev/null +++ b/cmd/cli/kubectl-kyverno/commands/create/templates/aggregated-role.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kyverno-{{.Name}}-permission + labels: + {{- range .Controllers }} + rbac.kyverno.io/aggregate-to-{{ . }}: "true" + {{- end }} +rules: + - apiGroups: ["{{.ApiGroup}}"] + resources: ["{{.ResourceTypes | join ","}}"] + verbs: [{{- range $index, $verb := .Verbs}}{{if $index}}, {{end}}"{{$verb}}"{{end}}] \ No newline at end of file diff --git a/cmd/cli/kubectl-kyverno/commands/create/templates/templates.go b/cmd/cli/kubectl-kyverno/commands/create/templates/templates.go index 4dc1712cd5..7327d8de10 100644 --- a/cmd/cli/kubectl-kyverno/commands/create/templates/templates.go +++ b/cmd/cli/kubectl-kyverno/commands/create/templates/templates.go @@ -18,3 +18,6 @@ var ExceptionTemplate string //go:embed metrics-config.yaml var MetricsConfigTemplate string + +//go:embed aggregated-role.yaml +var AggregatedRoleTemplate string diff --git a/docs/user/cli/commands/kyverno_create.md b/docs/user/cli/commands/kyverno_create.md index 9a80d8d424..df0c81d749 100644 --- a/docs/user/cli/commands/kyverno_create.md +++ b/docs/user/cli/commands/kyverno_create.md @@ -53,6 +53,7 @@ kyverno create [flags] ### SEE ALSO * [kyverno](kyverno.md) - Kubernetes Native Policy Management. +* [kyverno create cluster-role](kyverno_create_cluster-role.md) - Create an aggregated role for given resource types * [kyverno create exception](kyverno_create_exception.md) - Create a Kyverno policy exception file. * [kyverno create metrics-config](kyverno_create_metrics-config.md) - Create a Kyverno metrics-config file. * [kyverno create test](kyverno_create_test.md) - Create a Kyverno test file. diff --git a/docs/user/cli/commands/kyverno_create_cluster-role.md b/docs/user/cli/commands/kyverno_create_cluster-role.md new file mode 100644 index 0000000000..c77a486766 --- /dev/null +++ b/docs/user/cli/commands/kyverno_create_cluster-role.md @@ -0,0 +1,47 @@ +## kyverno create cluster-role + +Create an aggregated role for given resource types + +### Synopsis + +This command generates a Kubernetes ClusterRole for specified resource types. +The output is printed to stdout by default or saved to a specified file. +Required flags include 'api-groups', 'verbs', and 'resources'. + +``` +kyverno create cluster-role [name] [flags] +``` + +### Options + +``` + -g, --api-groups string API group for the resource (required) + --controllers stringArray List of controllers for the ClusterRole (default = background-controller) (default [background-controller]) + -h, --help help for cluster-role + -o, --output string Output file path (prints to console if not set) + --resources stringArray A comma separated list of resources (required) + --verbs stringArray A comma separated list of verbs or 'all' for all verbs +``` + +### Options inherited from parent commands + +``` + --add_dir_header If true, adds the file directory to the header of the log messages + --alsologtostderr log to standard error as well as files (no effect when -logtostderr=true) + --log_backtrace_at traceLocation when logging hits line file:N, emit a stack trace (default :0) + --log_dir string If non-empty, write log files in this directory (no effect when -logtostderr=true) + --log_file string If non-empty, use this log file (no effect when -logtostderr=true) + --log_file_max_size uint Defines the maximum size a log file can grow to (no effect when -logtostderr=true). Unit is megabytes. If the value is 0, the maximum file size is unlimited. (default 1800) + --logtostderr log to standard error instead of files (default true) + --one_output If true, only write logs to their native severity level (vs also writing to each lower severity level; no effect when -logtostderr=true) + --skip_headers If true, avoid header prefixes in the log messages + --skip_log_headers If true, avoid headers when opening log files (no effect when -logtostderr=true) + --stderrthreshold severity logs at or above this threshold go to stderr when writing to files and stderr (no effect when -logtostderr=true or -alsologtostderr=true) (default 2) + -v, --v Level number for the log level verbosity + --vmodule moduleSpec comma-separated list of pattern=N settings for file-filtered logging +``` + +### SEE ALSO + +* [kyverno create](kyverno_create.md) - Helps with the creation of various Kyverno resources. +