diff --git a/pkg/cli/cli_handle.go b/pkg/cli/cli_handle.go new file mode 100644 index 0000000..7e2bc65 --- /dev/null +++ b/pkg/cli/cli_handle.go @@ -0,0 +1,147 @@ +// Copyright 2023 The Authors (see AUTHORS file) +// +// 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 cli + +import ( + "context" + "fmt" + + "github.com/abcxyz/access-on-demand/apis/v1alpha1" + "github.com/abcxyz/access-on-demand/pkg/handler" + "github.com/abcxyz/access-on-demand/pkg/requestutil" + "github.com/abcxyz/pkg/cli" + "github.com/posener/complete/v2/predict" +) + +var _ cli.Command = (*CLIHandleCommand)(nil) + +// CLIHandleCommand handles CLI requests. +type CLIHandleCommand struct { + cli.BaseCommand + + flagPath string + + flagDebug bool + + // Run Cleanup instead of Do if true. + Cleanup bool + + // testCLI is used for testing only. + testCLI string +} + +func (c *CLIHandleCommand) Desc() string { + return `Handle the CLI request YAML file at the given path` +} + +func (c *CLIHandleCommand) Help() string { + return ` +Usage: {{ COMMAND }} [options] + +Run "do" commands in the CLI request YAML file at the given path: + + aod cli do -path "/path/to/file.yaml" + + +Run "do" commands in the CLI request YAML file at the given path in debug mode: + + aod cli do -path "/path/to/file.yaml" -debug + +Run "cleanup" commands in the CLI request YAML file at the given path: + + aod cli cleanup -path "/path/to/file.yaml" + + +Run "cleanup" commands in the CLI request YAML file at the given path in debug mode: + + aod cli cleanup -path "/path/to/file.yaml" -debug +` +} + +func (c *CLIHandleCommand) Flags() *cli.FlagSet { + set := cli.NewFlagSet() + + // Command options + f := set.NewSection("COMMAND OPTIONS") + + f.StringVar(&cli.StringVar{ + Name: "path", + Target: &c.flagPath, + Example: "/path/to/file.yaml", + Predict: predict.Files("*"), + Usage: `The path of CLI request file, in YAML format.`, + }) + + f.BoolVar(&cli.BoolVar{ + Name: "debug", + Target: &c.flagDebug, + Default: false, + Usage: `Turn on debug mode to print command outputs.`, + }) + + return set +} + +func (c *CLIHandleCommand) Run(ctx context.Context, args []string) error { + f := c.Flags() + if err := f.Parse(args); err != nil { + return fmt.Errorf("failed to parse flags: %w", err) + } + args = f.Args() + if len(args) > 0 { + return fmt.Errorf("unexpected arguments: %q", args) + } + + if c.flagPath == "" { + return fmt.Errorf("path is required") + } + + return c.do(ctx) +} + +func (c *CLIHandleCommand) do(ctx context.Context) error { + // Read request from file path. + var req v1alpha1.CLIRequest + if err := requestutil.ReadRequestFromPath(c.flagPath, &req); err != nil { + return fmt.Errorf("failed to read %T: %w", &req, err) + } + + if err := v1alpha1.ValidateCLIRequest(&req); err != nil { + return fmt.Errorf("failed to validate %T: %w", &req, err) + } + + opts := []handler.CLIHandlerOption{handler.WithStderr(c.Stderr())} + if c.flagDebug { + opts = append(opts, handler.WithDebugMode(c.Stdout())) + } + h := handler.NewCLIHandler(ctx, opts...) + + // Use testCLI if it is for testing. + if c.testCLI != "" { + req.CLI = c.testCLI + } + var err error + if c.Cleanup { + err = h.Cleanup(ctx, &req) + } else { + err = h.Do(ctx, &req) + } + if err != nil { + return fmt.Errorf(`failed to run commands: %w`, err) + } + c.Outf(`Successfully completed commands`) + + return nil +} diff --git a/pkg/cli/cli_handle_test.go b/pkg/cli/cli_handle_test.go new file mode 100644 index 0000000..ad6e141 --- /dev/null +++ b/pkg/cli/cli_handle_test.go @@ -0,0 +1,166 @@ +// Copyright 2023 The Authors (see AUTHORS file) +// +// 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 cli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/abcxyz/pkg/logging" + "github.com/abcxyz/pkg/testutil" + "github.com/google/go-cmp/cmp" +) + +func TestCLIHandleCommand(t *testing.T) { + t.Parallel() + + // Set up CLI request file. + requestFileContentByName := map[string]string{ + "valid.yaml": ` +cli: 'gcloud' +do: + - 'do1' + - 'do2' +cleanup: + - 'cleanup1' + - 'cleanup2' +`, + "invalid-request.yaml": ` +cli: 'cli_not_exist' +do: + - 'do' +cleanup: + - 'cleanup' +`, + "invalid.yaml": `bananas`, + } + dir := t.TempDir() + for name, content := range requestFileContentByName { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + + cases := []struct { + name string + args []string + testCLI string + cleanup bool + expOut string + expErr string + expStdErr string + }{ + { + name: "success_do", + args: []string{"-path", filepath.Join(dir, "valid.yaml")}, + testCLI: "echo", + expOut: `Successfully completed commands`, + }, + { + name: "success_do_with_debug", + args: []string{"-path", filepath.Join(dir, "valid.yaml"), "-debug"}, + testCLI: "echo", + expOut: ` +do1 +do2 +Successfully completed commands`, + }, + { + name: "success_cleanup", + args: []string{"-path", filepath.Join(dir, "valid.yaml")}, + testCLI: "echo", + cleanup: true, + expOut: `Successfully completed commands`, + }, + { + name: "success_cleanup_with_debug", + args: []string{"-path", filepath.Join(dir, "valid.yaml"), "-debug"}, + testCLI: "echo", + cleanup: true, + expOut: ` +cleanup1 +cleanup2 +Successfully completed commands`, + }, + { + name: "unexpected_args", + args: []string{"foo"}, + expErr: `unexpected arguments: ["foo"]`, + }, + { + name: "missing_path", + args: []string{}, + expErr: `path is required`, + }, + { + name: "invalid_yaml", + args: []string{"-path", filepath.Join(dir, "invalid.yaml")}, + expErr: "failed to read *v1alpha1.CLIRequest", + }, + { + name: "handler_do_failure", + args: []string{"-path", filepath.Join(dir, "valid.yaml")}, + testCLI: "ls", + expErr: `failed to run command "do1"`, + expStdErr: "ls: cannot access 'do1': No such file or directory", + }, + { + name: "handler_cleanup_failure", + args: []string{"-path", filepath.Join(dir, "valid.yaml")}, + testCLI: "ls", + cleanup: true, + expErr: `failed to run command "cleanup1"`, + expStdErr: "ls: cannot access 'cleanup1': No such file or directory", + }, + { + name: "invalid_request", + args: []string{"-path", filepath.Join(dir, "invalid-request.yaml")}, + expErr: "failed to validate *v1alpha1.CLIRequest", + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := logging.WithLogger(context.Background(), logging.TestLogger(t)) + + cmd := CLIHandleCommand{ + Cleanup: tc.cleanup, + testCLI: tc.testCLI, + } + _, stdout, stderr := cmd.Pipe() + + args := append([]string{}, tc.args...) + + err := cmd.Run(ctx, args) + if diff := testutil.DiffErrString(err, tc.expErr); diff != "" { + t.Errorf("Process(%+v) got error diff (-want, +got):\n%s", tc.name, diff) + } + if diff := cmp.Diff(strings.TrimSpace(tc.expOut), strings.TrimSpace(stdout.String())); diff != "" { + t.Errorf("Process(%+v) got output diff (-want, +got):\n%s", tc.name, diff) + } + if diff := cmp.Diff(strings.TrimSpace(tc.expStdErr), strings.TrimSpace(stderr.String())); diff != "" { + t.Errorf("Process(%+v) got command error diff (-want, +got):\n%s", tc.name, diff) + } + }) + } +} diff --git a/pkg/cli/iam_handle.go b/pkg/cli/iam_handle.go index 5f76929..87d4218 100644 --- a/pkg/cli/iam_handle.go +++ b/pkg/cli/iam_handle.go @@ -123,13 +123,13 @@ func (c *IAMHandleCommand) Run(ctx context.Context, args []string) error { func (c *IAMHandleCommand) handleIAM(ctx context.Context) error { // Read request from file path. - req, err := requestutil.ReadFromPath(c.flagPath) - if err != nil { - return fmt.Errorf("failed to read %T: %w", req, err) + var req v1alpha1.IAMRequest + if err := requestutil.ReadRequestFromPath(c.flagPath, &req); err != nil { + return fmt.Errorf("failed to read %T: %w", &req, err) } - if err := v1alpha1.ValidateIAMRequest(req); err != nil { - return fmt.Errorf("failed to validate %T: %w", req, err) + if err := v1alpha1.ValidateIAMRequest(&req); err != nil { + return fmt.Errorf("failed to validate %T: %w", &req, err) } var h iamHandler @@ -170,7 +170,7 @@ func (c *IAMHandleCommand) handleIAM(ctx context.Context) error { // Wrap IAMRequest to include Duration. reqWrapper := &v1alpha1.IAMRequestWrapper{ - IAMRequest: req, + IAMRequest: &req, Duration: c.flagDuration, StartTime: c.flagStartTime, } diff --git a/pkg/cli/iam_handle_test.go b/pkg/cli/iam_handle_test.go index 9dcff35..3611a2f 100644 --- a/pkg/cli/iam_handle_test.go +++ b/pkg/cli/iam_handle_test.go @@ -128,7 +128,6 @@ policies: cases := []struct { name string args []string - fileData []byte handler *fakeIAMHandler expReq *v1alpha1.IAMRequestWrapper expOut string diff --git a/pkg/cli/iam_validate.go b/pkg/cli/iam_validate.go index 3498927..abe9aff 100644 --- a/pkg/cli/iam_validate.go +++ b/pkg/cli/iam_validate.go @@ -83,13 +83,13 @@ func (c *IAMValidateCommand) Run(ctx context.Context, args []string) error { func (c *IAMValidateCommand) validate(ctx context.Context) error { // Read request from YAML file. - req, err := requestutil.ReadFromPath(c.flagPath) - if err != nil { - return fmt.Errorf("failed to read %T: %w", req, err) + var req v1alpha1.IAMRequest + if err := requestutil.ReadRequestFromPath(c.flagPath, &req); err != nil { + return fmt.Errorf("failed to read %T: %w", &req, err) } - if err := v1alpha1.ValidateIAMRequest(req); err != nil { - return fmt.Errorf("failed to validate %T: %w", req, err) + if err := v1alpha1.ValidateIAMRequest(&req); err != nil { + return fmt.Errorf("failed to validate %T: %w", &req, err) } c.Outf("Successfully validated IAM request") diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 9c24b01..7dcdbae 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -42,6 +42,20 @@ var rootCmd = func() cli.Command { }, } }, + "cli": func() cli.Command { + return &cli.RootCommand{ + Name: "cli", + Description: "Perform operations related to the CLI request", + Commands: map[string]cli.CommandFactory{ + "do": func() cli.Command { + return &CLIHandleCommand{} + }, + "cleanup": func() cli.Command { + return &CLIHandleCommand{Cleanup: true} + }, + }, + } + }, }, } } diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index 4cb6abb..6751d4c 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -25,6 +25,7 @@ func TestRootCommand_Help(t *testing.T) { exp := ` Usage: aod COMMAND + cli Perform operations related to the CLI request iam Perform operations related to the IAM request ` diff --git a/pkg/requestutil/request.go b/pkg/requestutil/request.go index 081463c..69d4583 100644 --- a/pkg/requestutil/request.go +++ b/pkg/requestutil/request.go @@ -20,28 +20,26 @@ import ( "io" "os" - "github.com/abcxyz/access-on-demand/apis/v1alpha1" "gopkg.in/yaml.v3" ) -// ReadFromPath reads a YAML file at the given path and unmarshal it to -// IAMRequest. -func ReadFromPath(path string) (*v1alpha1.IAMRequest, error) { +// ReadRequestFromPath reads a YAML file at the given path and unmarshal it to +// the given req. +func ReadRequestFromPath(path string, req any) error { f, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("failed to read file at %q, %w", path, err) + return fmt.Errorf("failed to read file at %q, %w", path, err) } defer f.Close() data, err := io.ReadAll(io.LimitReader(f, 64*1_000)) if err != nil { - return nil, fmt.Errorf("failed to read file content at %q, %w", path, err) + return fmt.Errorf("failed to read file content at %q, %w", path, err) } - var req v1alpha1.IAMRequest - if err := yaml.Unmarshal(data, &req); err != nil { - return nil, fmt.Errorf("failed to unmarshal yaml to %T: %w", req, err) + if err := yaml.Unmarshal(data, req); err != nil { + return fmt.Errorf("failed to unmarshal yaml to %T: %w", req, err) } - return &req, nil + return nil } diff --git a/pkg/requestutil/request_test.go b/pkg/requestutil/request_test.go index 0dde678..661c3a4 100644 --- a/pkg/requestutil/request_test.go +++ b/pkg/requestutil/request_test.go @@ -118,12 +118,14 @@ policies: { name: "invalid_path", path: "foo", + expReq: &v1alpha1.IAMRequest{}, expErr: `failed to read file at "foo"`, }, { name: "invalid_yaml", path: filepath.Join(dir, "invalid.yaml"), - expErr: "failed to unmarshal yaml to v1alpha1.IAMRequest", + expReq: &v1alpha1.IAMRequest{}, + expErr: "failed to unmarshal yaml to *v1alpha1.IAMRequest", }, } @@ -133,12 +135,13 @@ policies: t.Run(tc.name, func(t *testing.T) { t.Parallel() - req, err := ReadFromPath(tc.path) + var req v1alpha1.IAMRequest + err := ReadRequestFromPath(tc.path, &req) if diff := testutil.DiffErrString(err, tc.expErr); diff != "" { t.Errorf("Process(%+v) got error diff (-want, +got):\n%s", tc.name, diff) } - if diff := cmp.Diff(tc.expReq, req); diff != "" { + if diff := cmp.Diff(tc.expReq, &req); diff != "" { t.Errorf("Process(%+v) got request diff (-want, +got): %v", tc.name, diff) } })