From 4b371b622cd6270bed36cd7ce2a1d9ef233004c5 Mon Sep 17 00:00:00 2001 From: Pavel Larkin Date: Fri, 16 Feb 2024 13:16:47 -0800 Subject: [PATCH] Add a higher level API for CLI (#3) * Add a higher level API (see command package) and examples --- command/applier.go | 43 +++++ command/arg.go | 99 ++++++++++ command/arg_test.go | 92 ++++++++++ command/command.go | 30 +++ command/command_test.go | 68 +++++++ command/doc.go | 32 ++++ command/errors.go | 26 +++ command/option.go | 60 ++++++ command/option_arg.go | 62 +++++++ command/option_arg_test.go | 70 +++++++ command/option_test.go | 78 ++++++++ examples/kopia/README.md | 107 +++++++++++ examples/kopia/args/common_args.go | 28 +++ examples/kopia/internal/kopia.go | 29 +++ examples/kopia/internal/opts/common_opts.go | 58 ++++++ examples/kopia/main.go | 112 ++++++++++++ examples/kopia/repository/location.go | 15 ++ examples/kopia/repository/opts.go | 66 +++++++ .../kopia/repository/repository_connect.go | 32 ++++ .../kopia/repository/repository_create.go | 41 +++++ examples/kopia/repository/storage/fs/fs.go | 20 ++ examples/kopia/repository/storage/fs/opts.go | 19 ++ examples/kopia/repository/storage/s3/opts.go | 50 +++++ examples/kopia/repository/storage/s3/s3.go | 52 ++++++ go.mod | 5 +- go.sum | 2 + test/arg_suite.go | 129 +++++++++++++ test/arg_suite_test.go | 173 ++++++++++++++++++ test/redact.go | 47 +++++ test/redact_test.go | 51 ++++++ 30 files changed, 1695 insertions(+), 1 deletion(-) create mode 100644 command/applier.go create mode 100644 command/arg.go create mode 100644 command/arg_test.go create mode 100644 command/command.go create mode 100644 command/command_test.go create mode 100644 command/doc.go create mode 100644 command/errors.go create mode 100644 command/option.go create mode 100644 command/option_arg.go create mode 100644 command/option_arg_test.go create mode 100644 command/option_test.go create mode 100644 examples/kopia/README.md create mode 100644 examples/kopia/args/common_args.go create mode 100644 examples/kopia/internal/kopia.go create mode 100644 examples/kopia/internal/opts/common_opts.go create mode 100644 examples/kopia/main.go create mode 100644 examples/kopia/repository/location.go create mode 100644 examples/kopia/repository/opts.go create mode 100644 examples/kopia/repository/repository_connect.go create mode 100644 examples/kopia/repository/repository_create.go create mode 100644 examples/kopia/repository/storage/fs/fs.go create mode 100644 examples/kopia/repository/storage/fs/opts.go create mode 100644 examples/kopia/repository/storage/s3/opts.go create mode 100644 examples/kopia/repository/storage/s3/s3.go create mode 100644 test/arg_suite.go create mode 100644 test/arg_suite_test.go create mode 100644 test/redact.go create mode 100644 test/redact_test.go diff --git a/command/applier.go b/command/applier.go new file mode 100644 index 0000000..17431d7 --- /dev/null +++ b/command/applier.go @@ -0,0 +1,43 @@ +// Copyright 2024 The Kanister 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 command + +import "github.com/kanisterio/safecli" + +// Applier defines the interface for applying arguments to the command. +type Applier interface { + // Apply applies arguments to the command. + Apply(safecli.CommandAppender) error +} + +// apply appends multiple arguments to the command. +// If any of the arguments encounter an error during the apply process, +// the error is returned and no changes are made to the command. +// If no error, the arguments are appended to the command. +func apply(cmd safecli.CommandAppender, args ...Applier) error { + // create a new subcmd builder which will be used to apply the arguments + // to avoid mutating the command if an error is encountered. + subcmd := safecli.NewBuilder() + for _, arg := range args { + if arg == nil { // if the param is nil, skip it + continue + } + if err := arg.Apply(subcmd); err != nil { + return err + } + } + cmd.Append(subcmd) + return nil +} diff --git a/command/arg.go b/command/arg.go new file mode 100644 index 0000000..349f990 --- /dev/null +++ b/command/arg.go @@ -0,0 +1,99 @@ +// Copyright 2024 The Kanister 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 command + +import ( + "github.com/kanisterio/safecli" +) + +// errorArgument is a simple implementation of the Applier interface +// that always returns an error when applied. +type errorArgument struct { + err error // error to return when applied +} + +// Apply does nothing except return an error if one is set. +func (e errorArgument) Apply(cmd safecli.CommandAppender) error { + return e.err +} + +// NewErrorArgument creates a new argument with a given error. +// It is useful for creating an argument that always fails when applied. +func NewErrorArgument(err error) Applier { + return errorArgument{err: err} +} + +// noopArgument is a simple implementation of the Applier interface that does nothing. +type noopArgument struct{} + +func (noopArgument) Apply(safecli.CommandAppender) error { + return nil +} + +// NewNoopArgument creates a new argument that does nothing when applied. +func NewNoopArgument() Applier { + return noopArgument{} +} + +// argument defines an argument with the given name. +// If the argument is redacted, it is appended as redacted. +type argument struct { + name string + isRedacted bool +} + +// Apply appends the argument to the command. +func (a argument) Apply(cmd safecli.CommandAppender) error { + append := cmd.AppendLoggable + if a.isRedacted { + append = cmd.AppendRedacted + } + append(a.name) + return nil +} + +// newArgument creates a new argument with a given name and . +func newArgument(name string, isRedacted bool) Applier { + if name == "" { + return NewErrorArgument(ErrInvalidArgumentName) + } + return argument{ + name: name, + isRedacted: isRedacted, + } +} + +// NewArgument creates a new argument with a given name. +func NewArgument(name string) Applier { + return newArgument(name, false) +} + +// NewRedactedArgument creates a new redacted argument with a given name. +func NewRedactedArgument(name string) Applier { + return newArgument(name, true) +} + +// Arguments defines a collection of command arguments. +type Arguments []Applier + +// Apply applies the flags to the CLI. +func (args Arguments) Apply(cli safecli.CommandAppender) error { + return apply(cli, args...) +} + +// NewArguments creates a new collection of arguments. +func NewArguments(args ...Applier) Applier { + return Arguments(args) +} diff --git a/command/arg_test.go b/command/arg_test.go new file mode 100644 index 0000000..e99d1a5 --- /dev/null +++ b/command/arg_test.go @@ -0,0 +1,92 @@ +// Copyright 2024 The Kanister 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 command_test + +import ( + "testing" + + "gopkg.in/check.v1" + + "github.com/pkg/errors" + + "github.com/kanisterio/safecli" + "github.com/kanisterio/safecli/command" + "github.com/kanisterio/safecli/test" +) + +var ( + ErrArgument = errors.New("arg error") +) + +// MockArg is a mock implementation of the Applier interface. +type MockArg struct { + name string + err error +} + +func (m *MockArg) Apply(cli safecli.CommandAppender) error { + cli.AppendLoggable(m.name) + return m.err +} + +func TestArguments(t *testing.T) { check.TestingT(t) } + +var _ = check.Suite(&test.ArgumentSuite{Cmd: "cmd", Arguments: []test.ArgumentTest{ + { + Name: "NewErrorArgument without error", + Argument: command.NewErrorArgument(nil), + ExpectedCLI: []string{"cmd"}, + }, + { + Name: "NewErrorArgument with error", + Argument: command.NewErrorArgument(ErrArgument), + ExpectedErr: ErrArgument, + }, + { + Name: "NewArgument", + Argument: command.NewArgument("arg1"), + ExpectedCLI: []string{"cmd", "arg1"}, + }, + { + Name: "NewArgument with empty name", + Argument: command.NewArgument(""), + ExpectedErr: command.ErrInvalidArgumentName, + }, + { + Name: "NewRedactedArgument", + Argument: command.NewRedactedArgument("arg1"), + ExpectedCLI: []string{"cmd", "arg1"}, + ExpectedLog: "cmd <****>", + }, + { + Name: "NewRedactedArgument with empty name", + Argument: command.NewRedactedArgument(""), + ExpectedErr: command.ErrInvalidArgumentName, + }, + { + Name: "NewArguments", + Argument: command.NewArguments( + command.NewArgument("arg1"), + nil, // should be skipped + command.NewRedactedArgument("arg2"), + ), + ExpectedCLI: []string{"cmd", "arg1", "arg2"}, + ExpectedLog: "cmd arg1 <****>", + }, + { + Name: "NewArguments without args", + ExpectedCLI: []string{"cmd"}, + }, +}}) diff --git a/command/command.go b/command/command.go new file mode 100644 index 0000000..6a14569 --- /dev/null +++ b/command/command.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Kanister 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 command + +import "github.com/kanisterio/safecli" + +// New creates a new safecli.Builder with the given command name and arguments. +// If the command name is empty, it will be omitted from the output. +func New(cmdName string, args ...Applier) (*safecli.Builder, error) { + cmd := safecli.NewBuilder() + if cmdName != "" { + cmd.AppendLoggable(cmdName) + } + if err := apply(cmd, args...); err != nil { + return nil, err + } + return cmd, nil +} diff --git a/command/command_test.go b/command/command_test.go new file mode 100644 index 0000000..ce084cc --- /dev/null +++ b/command/command_test.go @@ -0,0 +1,68 @@ +// Copyright 2024 The Kanister 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 command_test + +import ( + "strings" + "testing" + + "github.com/kanisterio/safecli/command" + "gopkg.in/check.v1" +) + +func TestCommand(t *testing.T) { check.TestingT(t) } + +type CommandSuite struct{} + +var _ = check.Suite(&CommandSuite{}) + +func (s *CommandSuite) TestCommandNewOK(c *check.C) { + cli := []string{ + "cmd", + "--log-level=info", + "--password=secret", + "arg", + "--dest=/tmp/dir", + "--read-only", + } + log := []string{ + "cmd", + "--log-level=info", + "--password=<****>", + "arg", + "--dest=/tmp/dir", + "--read-only", + } + cmd, err := command.New("cmd", []command.Applier{ + command.NewOptionWithArgument("--log-level", "info"), + command.NewOptionWithRedactedArgument("--password", "secret"), + command.NewArgument("arg"), + command.NewOptionWithArgument("--dest", "/tmp/dir"), + command.NewOption("--read-only", true), + }...) + c.Assert(err, check.IsNil) + c.Assert(cmd.Build(), check.DeepEquals, cli) + c.Assert(cmd.String(), check.Equals, strings.Join(log, " ")) +} + +func (s *CommandSuite) TestCommandNewError(c *check.C) { + cmd, err := command.New("cmd", []command.Applier{ + command.NewOptionWithArgument("--log-level", "info"), + command.NewOptionWithRedactedArgument("--password", "secret"), + command.NewArgument(""), // error argument + }...) + c.Assert(cmd, check.IsNil) + c.Assert(err, check.Equals, command.ErrInvalidArgumentName) +} diff --git a/command/doc.go b/command/doc.go new file mode 100644 index 0000000..e33a026 --- /dev/null +++ b/command/doc.go @@ -0,0 +1,32 @@ +package command + +// Copyright 2024 The Kanister 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. +// + +// +// The command package is used to define CLI (Command Line Interface) commands along with their arguments. +// +// Command line arguments are the whitespace-separated tokens given in the shell command used to invoke the program. +// +// A token prefixed with a hyphen delimiter (`-`) is known as an *option*. For example, `-o` or `--option`. +// +// An option may or may not have an associated argument. For example, `--option=value`. +// +// A token without a hyphen delimiter (`-`) is considered an *argument*. For example, `arg1` or `arg2`. +// +// The command package provides a set of interfaces and types for defining and applying arguments to commands. +// +// Check safecli/examples/kopia package for usage of the command package. +// diff --git a/command/errors.go b/command/errors.go new file mode 100644 index 0000000..f703add --- /dev/null +++ b/command/errors.go @@ -0,0 +1,26 @@ +// Copyright 2024 The Kanister 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 command + +import ( + "github.com/pkg/errors" +) + +var ( + // ErrInvalidArgumentName is returned when the argument name is empty. + ErrInvalidArgumentName = errors.New("argument name is empty") + // ErrInvalidOptionName is returned when the option name is empty or has no hyphen prefix. + ErrInvalidOptionName = errors.New("option name is empty or has no hyphen prefix") +) diff --git a/command/option.go b/command/option.go new file mode 100644 index 0000000..a1242f8 --- /dev/null +++ b/command/option.go @@ -0,0 +1,60 @@ +// Copyright 2024 The Kanister 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 command + +import ( + "strings" + + "github.com/kanisterio/safecli" +) + +// option defines an option with a given name. +// option name, ie: --option, -o +type option string + +// Apply appends the option to the command. +func (o option) Apply(cmd safecli.CommandAppender) error { + cmd.AppendLoggable(string(o)) + return nil +} + +// NewOption creates a new option with a given name and enabled state. +func NewOption(name string, isEnabled bool) Applier { + if err := validateOptionName(name); err != nil { + return err + } else if !isEnabled { + return noopArgument{} + } + return option(name) +} + +// NewToggleOption creates a new toggle option with a given enabled and disabled option name. +func NewToggleOption(enabledOpt, disabledOpt string, isEnabled bool) Applier { + optName := disabledOpt + if isEnabled { + optName = enabledOpt + } + return NewOption(optName, true) +} + +// validateOptionName validates the option name. +// It returns an error applier if the option name is empty +// or does not start with a hyphen prefix. +func validateOptionName(name string) Applier { + if !strings.HasPrefix(name, "-") { + return NewErrorArgument(ErrInvalidOptionName) + } + return nil +} diff --git a/command/option_arg.go b/command/option_arg.go new file mode 100644 index 0000000..35dbf60 --- /dev/null +++ b/command/option_arg.go @@ -0,0 +1,62 @@ +// Copyright 2024 The Kanister 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 command + +import ( + "github.com/kanisterio/safecli" +) + +// optionArg defines an option with the argument. +type optionArg struct { + name string + arg string + isRedacted bool +} + +// Apply appends the option to the command. +// If the option argument is redacted, it is appended as redacted. +func (o optionArg) Apply(cmd safecli.CommandAppender) error { + // append the option and the argument to the command + if o.isRedacted { + cmd.AppendRedactedKV(o.name, o.arg) + } else { + cmd.AppendLoggableKV(o.name, o.arg) + } + return nil +} + +// newOptionArg creates a new option with a given option name and redacted/non-redacted argument. +func newOptionArg(name, arg string, isArgRedacted bool) Applier { + if err := validateOptionName(name); err != nil { + return err + } else if arg == "" { + return noopArgument{} + } + return optionArg{ + name: name, + arg: arg, + isRedacted: isArgRedacted, + } +} + +// NewOptionWithArgument creates a new option with a given option name and argument. +func NewOptionWithArgument(name, arg string) Applier { + return newOptionArg(name, arg, false) +} + +// NewOptionWithRedactedArgument creates a new option with a given option name and redacted argument. +func NewOptionWithRedactedArgument(name, arg string) Applier { + return newOptionArg(name, arg, true) +} diff --git a/command/option_arg_test.go b/command/option_arg_test.go new file mode 100644 index 0000000..6835ca6 --- /dev/null +++ b/command/option_arg_test.go @@ -0,0 +1,70 @@ +// Copyright 2024 The Kanister 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 command_test + +import ( + "testing" + + "github.com/kanisterio/safecli/command" + "github.com/kanisterio/safecli/test" + "gopkg.in/check.v1" +) + +func TestOptionArgs(t *testing.T) { check.TestingT(t) } + +var _ = check.Suite(&test.ArgumentSuite{Cmd: "cmd", Arguments: []test.ArgumentTest{ + { + Name: "NewOptionWithArgument", + Argument: command.NewOptionWithArgument("--option", "optArg1"), + ExpectedCLI: []string{"cmd", "--option=optArg1"}, + }, + { + Name: "NewOptionWithArgument with empty name", + Argument: command.NewOptionWithArgument("", "optArg1"), + ExpectedErr: command.ErrInvalidOptionName, + }, + { + Name: "NewOptionWithArgument with invalid name", + Argument: command.NewOptionWithArgument("option", "optArg1"), + ExpectedErr: command.ErrInvalidOptionName, + }, + { + Name: "NewOptionWithArgument with empty argument", + Argument: command.NewOptionWithArgument("--option", ""), + ExpectedCLI: []string{"cmd"}, + }, + + { + Name: "NewOptionWithRedactedArgument", + Argument: command.NewOptionWithRedactedArgument("--option", "optArg1"), + ExpectedCLI: []string{"cmd", "--option=optArg1"}, + ExpectedLog: "cmd --option=<****>", + }, + { + Name: "NewOptionWithRedactedArgument with empty name", + Argument: command.NewOptionWithRedactedArgument("", "optArg1"), + ExpectedErr: command.ErrInvalidOptionName, + }, + { + Name: "NewOptionWithRedactedArgument with invalid name", + Argument: command.NewOptionWithRedactedArgument("option", "optArg1"), + ExpectedErr: command.ErrInvalidOptionName, + }, + { + Name: "NewOptionWithRedactedArgument with empty argument", + Argument: command.NewOptionWithRedactedArgument("--option", ""), + ExpectedCLI: []string{"cmd"}, + }, +}}) diff --git a/command/option_test.go b/command/option_test.go new file mode 100644 index 0000000..fd9d17e --- /dev/null +++ b/command/option_test.go @@ -0,0 +1,78 @@ +// Copyright 2024 The Kanister 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 command_test + +import ( + "testing" + + "github.com/kanisterio/safecli/command" + "github.com/kanisterio/safecli/test" + "gopkg.in/check.v1" +) + +func TestOptions(t *testing.T) { check.TestingT(t) } + +var _ = check.Suite(&test.ArgumentSuite{Cmd: "cmd", Arguments: []test.ArgumentTest{ + { + Name: "NewOption", + Argument: command.NewOption("--option", true), + ExpectedCLI: []string{"cmd", "--option"}, + }, + { + Name: "NewOption disabled", + Argument: command.NewOption("--option", false), + ExpectedCLI: []string{"cmd"}, + }, + { + Name: "NewOption with empty name", + Argument: command.NewOption("", false), + ExpectedErr: command.ErrInvalidOptionName, + }, + { + Name: "NewOption with invalid name", + Argument: command.NewOption("arg1", true), + ExpectedErr: command.ErrInvalidOptionName, + }, + { + Name: "NewToggleOption", + Argument: command.NewToggleOption("--option", "--no-option", true), + ExpectedCLI: []string{"cmd", "--option"}, + }, + { + Name: "NewToggleOption with empty name", + Argument: command.NewToggleOption("", "--no-option", true), + ExpectedErr: command.ErrInvalidOptionName, + }, + { + Name: "NewToggleOption with invalid name", + Argument: command.NewToggleOption("option", "--no-option", true), + ExpectedErr: command.ErrInvalidOptionName, + }, + { + Name: "NewToggleOption", + Argument: command.NewToggleOption("--option", "--no-option", false), + ExpectedCLI: []string{"cmd", "--no-option"}, + }, + { + Name: "NewToggleOption with empty name", + Argument: command.NewToggleOption("--option", "", false), + ExpectedErr: command.ErrInvalidOptionName, + }, + { + Name: "NewToggleOption with invalid name", + Argument: command.NewToggleOption("--option", "no-option", false), + ExpectedErr: command.ErrInvalidOptionName, + }, +}}) diff --git a/examples/kopia/README.md b/examples/kopia/README.md new file mode 100644 index 0000000..747ef29 --- /dev/null +++ b/examples/kopia/README.md @@ -0,0 +1,107 @@ +# Kopia Command Line Interface(CLI) Builder + +This example demonstrates how to use [safecli](https://github.com/kanisterio/safecli) for programmatically building a CLI for [Kopia](https://github.com/kopia/kopia), focusing on `kopia repository create` and `kopia repository connect` commands. + +## Building the CLI + +### Common Arguments + +Kopia repository commands share a set of common arguments, such as `--config-file`, `--log-dir`, `--log-level`, and `--password`. These common arguments are defined in the [args](args/common_args.go) package and are utilized in both `create` and `connect` commands. + +### Repository Commands + +The commands related to repositories are contained within the [examples/kopia/repository](repository/) package. + + +#### Define Repository Creation Arguments + +First, define the arguments for the `kopia repository create` command as [repository.CreateArgs](repository/repository_create.go) structure, which embeds the common arguments and adds the `Location`, `Hostname`, and `Username` fields: + +```go +// CreateArgs represents the arguments for the `kopia repository create` command. +type CreateArgs struct { + args.Common // Embeds common arguments + Location Location // Filesystem, S3, etc. + Hostname string // The hostname of the repository + Username string // The username of the repository +} +``` + +#### Define the Repository Creation Function + +Next, define the [repository.Create](repository/repository_create.go) function to create a new [safecli.Builder](https://github.com/kanisterio/safecli/blob/main/safecli.go) for the command using arguments from `CreateArgs` structure: + +```go +// Create creates a new safecli.Builder for the `kopia repository create` command. +func Create(args CreateArgs) (*safecli.Builder, error) { + return internal.NewKopiaCommand( + opts.Common(args.Common), + cmdRepository, subcmdCreate, + optHostname(args.Hostname), + optUsername(args.Username), + optStorage(args.Location), + ) +} +``` + +This function calls `internal.NewKopiaCommand` from the [examples/kopia/internal](internal/kopia.go) package to create a `safecli.Builder`, converting `CreateArgs` to CLI options through `opts.Common`, `optHostname`, `optUsername`, and `optStorage`. + +Common options are defined in [examples/kopia/internal/opts/common_opts.go](internal/opts/common_opts.go) and repository options are defined in [examples/kopia/repository/opts.go](repository/opts.go) files. + +#### Example Usage + +To build the `kopia repository create ...` command from your Go code, you must use the `Create` function: + +```go +package main + +import ( + "fmt" + + "github.com/kanisterio/safecli/examples/kopia/args" + "github.com/kanisterio/safecli/examples/kopia/repository" +) + +func main() { + args := repository.CreateArgs{ + Common: args.Common{ + ConfigFilePath: "/path/to/config", + LogDirectory: "/path/to/log", + LogLevel: "error", + RepoPassword: "123456", + }, + Location: repository.Location{ + Provider: "filesystem", + MetaData: map[string][]byte{ + "repoPath": []byte("/tmp/my-repository"), + }, + }, + Hostname: "localhost", + Username: "user", + } + cmd, err := repository.Create(args) + if err != nil { + fmt.Println(err) + return + } + fmt.Printf("exec=%#v\n", cmd.Build()) +} +``` + +This code will print the command that you can run to create a new Kopia repository. + +```bash +$ go run main.go +exec=[]string{"kopia", "--config-file=/path/to/config", "--log-dir=/path/to/log", "--log-level=error", "--password=123456", "repository", "create", "--override-hostname=localhost", "--override-username=user", "filesystem", "--path=/tmp/my-repository"} +``` + +#### Repository Connect command + +The `repository connect` command is implemented in a similar way. You can find the complete example in the [examples/kopia/repository_connect.go](repository/repository_connect.go). + +Usage example can be found in [examples/kopia/main.go](main.go). + + +## Bottom Line + +This example demonstrates how to use `safecli` to programmatically build a CLI for Kopia, focusing on the `kopia repository create` and `kopia repository connect` commands. The same approach can be applied to construct other Kopia commands or any other CLI tool. diff --git a/examples/kopia/args/common_args.go b/examples/kopia/args/common_args.go new file mode 100644 index 0000000..48905ea --- /dev/null +++ b/examples/kopia/args/common_args.go @@ -0,0 +1,28 @@ +// Copyright 2024 The Kanister 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 args + +// Common represents the common arguments for Kopia commands. +type Common struct { + ConfigFilePath string // the path to the config file. + LogDirectory string // the directory where logs are stored. + LogLevel string // the level of logging. Default is "error". + RepoPassword string // the password for the repository. +} + +// Cache represents the cache arguments for Kopia commands. +type Cache struct { + // ... +} diff --git a/examples/kopia/internal/kopia.go b/examples/kopia/internal/kopia.go new file mode 100644 index 0000000..5af2df4 --- /dev/null +++ b/examples/kopia/internal/kopia.go @@ -0,0 +1,29 @@ +// Copyright 2024 The Kanister 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 internal + +import ( + "github.com/kanisterio/safecli" + "github.com/kanisterio/safecli/command" +) + +const ( + kopiaBinName = "kopia" +) + +// NewKopiaCommand creates a new safecli.Builder for the kopia command. +func NewKopiaCommand(args ...command.Applier) (*safecli.Builder, error) { + return command.New(kopiaBinName, args...) +} diff --git a/examples/kopia/internal/opts/common_opts.go b/examples/kopia/internal/opts/common_opts.go new file mode 100644 index 0000000..0376ff1 --- /dev/null +++ b/examples/kopia/internal/opts/common_opts.go @@ -0,0 +1,58 @@ +// Copyright 2024 The Kanister 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 opts + +import ( + "github.com/kanisterio/safecli/command" + "github.com/kanisterio/safecli/examples/kopia/args" +) + +// defaults +var ( + defaultLogLevel = "error" +) + +// ConfigFilePath returns a new config file path flag with a given path. +func ConfigFilePath(path string) command.Applier { + return command.NewOptionWithArgument("--config-file", path) +} + +// LogDirectory returns a new log directory flag with a given directory. +func LogDirectory(dir string) command.Applier { + return command.NewOptionWithArgument("--log-dir", dir) +} + +// LogLevel returns a new log level flag with a given level. +func LogLevel(level string) command.Applier { + if level == "" { + level = defaultLogLevel + } + return command.NewOptionWithArgument("--log-level", level) +} + +// RepoPassword returns a new repository password flag with a given password. +func RepoPassword(password string) command.Applier { + return command.NewOptionWithRedactedArgument("--password", password) +} + +// Common maps the common arguments to the CLI command arguments. +func Common(args args.Common) command.Applier { + return command.NewArguments( + ConfigFilePath(args.ConfigFilePath), + LogDirectory(args.LogDirectory), + LogLevel(args.LogLevel), + RepoPassword(args.RepoPassword), + ) +} diff --git a/examples/kopia/main.go b/examples/kopia/main.go new file mode 100644 index 0000000..34ffca0 --- /dev/null +++ b/examples/kopia/main.go @@ -0,0 +1,112 @@ +// Copyright 2024 The Kanister 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 main + +import ( + "fmt" + "time" + + "github.com/kanisterio/safecli/examples/kopia/args" + "github.com/kanisterio/safecli/examples/kopia/repository" +) + +func fsCreateArgs() repository.CreateArgs { + return repository.CreateArgs{ + Common: args.Common{ + ConfigFilePath: "/path/to/config", + LogDirectory: "/path/to/log", + LogLevel: "error", + RepoPassword: "password", + }, + Location: repository.Location{ + Provider: repository.ProviderFilesystem, + MetaData: map[string][]byte{ + "repoPath": []byte("/tmp/my-repository"), + }, + }, + Hostname: "localhost", + Username: "user", + } +} + +func fsConnectArgs() repository.ConnectArgs { + return repository.ConnectArgs{ + Common: args.Common{ + ConfigFilePath: "/path/to/config", + LogDirectory: "/path/to/log", + LogLevel: "error", + RepoPassword: "password", + }, + Location: repository.Location{ + Provider: repository.ProviderFilesystem, + MetaData: map[string][]byte{ + "repoPath": []byte("/tmp/my-repository"), + }, + }, + Hostname: "localhost", + Username: "user", + ReadOnly: true, + PointInTime: time.Date(2024, 2, 15, 14, 30, 0, 0, time.FixedZone("PST", -8*60*60)), + } +} + +func s3CreateArgs() repository.CreateArgs { + return repository.CreateArgs{ + Common: args.Common{ + ConfigFilePath: "/path/to/config", + LogDirectory: "/path/to/log", + LogLevel: "error", + RepoPassword: "password", + }, + Location: repository.Location{ + Provider: repository.ProviderS3, + MetaData: map[string][]byte{ + "region": []byte("us-west-1"), + "bucket": []byte("my-bucket"), + "prefix": []byte("my-repository"), + "endpoint": []byte("http://localhost:9000"), + "skipSSLVerify": []byte("true"), + }, + }, + Hostname: "localhost", + Username: "user", + } +} + +func RepoCreate(args repository.CreateArgs) { + cmd, err := repository.Create(args) + fmt.Println("exec=", cmd) + fmt.Println("err=", err) +} + +func RepoConnect(args repository.ConnectArgs) { + cmd, err := repository.Connect(args) + fmt.Println("exec=", cmd) + fmt.Println("err=", err) +} + +func main() { + RepoCreate(fsCreateArgs()) + RepoCreate(s3CreateArgs()) + RepoConnect(fsConnectArgs()) +} + +// $ go run main.go +// exec= kopia --config-file=/path/to/config --log-dir=/path/to/log --log-level=error --password=<****> repository create --override-hostname=localhost --override-username=user filesystem --path=/tmp/my-repository +// err= +// exec= kopia --config-file=/path/to/config --log-dir=/path/to/log --log-level=error --password=<****> repository create --override-hostname=localhost --override-username=user s3 --region=us-west-1 --bucket=my-bucket --endpoint=http://localhost:9000 --prefix=my-repository --disable-tls-verify +// err= +// exec= kopia --config-file=/path/to/config --log-dir=/path/to/log --log-level=error --password=<****> repository connect --override-hostname=localhost --override-username=user --read-only --point-in-time=2024-02-15T14:30:00-08:00 filesystem --path=/tmp/my-repository +// err= diff --git a/examples/kopia/repository/location.go b/examples/kopia/repository/location.go new file mode 100644 index 0000000..bce20da --- /dev/null +++ b/examples/kopia/repository/location.go @@ -0,0 +1,15 @@ +package repository + +// Provider represents the storage provider for the repository. +type Provider string + +const ( + ProviderFilesystem Provider = "filesystem" + ProviderS3 Provider = "s3" +) + +// Location represents the location of the repository. +type Location struct { + Provider Provider + MetaData map[string][]byte +} diff --git a/examples/kopia/repository/opts.go b/examples/kopia/repository/opts.go new file mode 100644 index 0000000..3db4186 --- /dev/null +++ b/examples/kopia/repository/opts.go @@ -0,0 +1,66 @@ +// Copyright 2024 The Kanister 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 repository + +import ( + "fmt" + "time" + + "github.com/kanisterio/safecli/command" + "github.com/kanisterio/safecli/examples/kopia/repository/storage/fs" + "github.com/kanisterio/safecli/examples/kopia/repository/storage/s3" +) + +var ( + cmdRepository = command.NewArgument("repository") + + subcmdCreate = command.NewArgument("create") + subcmdConnect = command.NewArgument("connect") +) + +// optHostname creates a new option for the hostname of the repository. +func optHostname(h string) command.Applier { + return command.NewOptionWithArgument("--override-hostname", h) +} + +// optUsername creates a new option for the username of the repository. +func optUsername(u string) command.Applier { + return command.NewOptionWithArgument("--override-username", u) +} + +// optReadOnly creates a new option for the read-only mode of the repository. +func optReadOnly(readOnly bool) command.Applier { + return command.NewOption("--read-only", readOnly) +} + +// optPointInTime creates a new option for the point-in-time of the repository. +func optPointInTime(pit time.Time) command.Applier { + if pit.IsZero() { + return command.NewNoopArgument() + } + return command.NewOptionWithArgument("--point-in-time", pit.Format(time.RFC3339)) +} + +// optStorage creates a list of options for the specified storage location. +func optStorage(l Location) command.Applier { + switch l.Provider { + case ProviderFilesystem: + return fs.New(l.MetaData) + case ProviderS3: + return s3.New(l.MetaData) + default: + return command.NewErrorArgument(fmt.Errorf("unsupported storage provider: %s", l.Provider)) + } +} diff --git a/examples/kopia/repository/repository_connect.go b/examples/kopia/repository/repository_connect.go new file mode 100644 index 0000000..cbc9b2c --- /dev/null +++ b/examples/kopia/repository/repository_connect.go @@ -0,0 +1,32 @@ +package repository + +import ( + "time" + + "github.com/kanisterio/safecli" + "github.com/kanisterio/safecli/examples/kopia/args" + "github.com/kanisterio/safecli/examples/kopia/internal" + "github.com/kanisterio/safecli/examples/kopia/internal/opts" +) + +// ConnectArgs defines the arguments for the `kopia repository connect` command. +type ConnectArgs struct { + args.Common // common arguments + Location Location // filesystem, s3, etc + Hostname string // the hostname of the repository + Username string // the username of the repository + ReadOnly bool // connect to a repository in read-only mode + PointInTime time.Time // connect to a repository as it was at a specific point in time +} + +// Connect creates a new safecli.Builder for the `kopia repository connect` command. +func Connect(args ConnectArgs) (*safecli.Builder, error) { + return internal.NewKopiaCommand(opts.Common(args.Common), + cmdRepository, subcmdConnect, + optHostname(args.Hostname), + optUsername(args.Username), + optReadOnly(args.ReadOnly), + optPointInTime(args.PointInTime), + optStorage(args.Location), + ) +} diff --git a/examples/kopia/repository/repository_create.go b/examples/kopia/repository/repository_create.go new file mode 100644 index 0000000..b72e7bf --- /dev/null +++ b/examples/kopia/repository/repository_create.go @@ -0,0 +1,41 @@ +// Copyright 2024 The Kanister 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 repository + +import ( + "github.com/kanisterio/safecli" + "github.com/kanisterio/safecli/examples/kopia/args" + "github.com/kanisterio/safecli/examples/kopia/internal" + "github.com/kanisterio/safecli/examples/kopia/internal/opts" +) + +// CreateArgs represents the arguments for the `kopia repository create` command. +type CreateArgs struct { + args.Common // common arguments + Location Location // filesystem, s3, etc + Hostname string // the hostname of the repository + Username string // the username of the repository +} + +// Create creates a new safecli.Builder for the `kopia repository create` command. +func Create(args CreateArgs) (*safecli.Builder, error) { + return internal.NewKopiaCommand( + opts.Common(args.Common), + cmdRepository, subcmdCreate, + optHostname(args.Hostname), + optUsername(args.Username), + optStorage(args.Location), + ) +} diff --git a/examples/kopia/repository/storage/fs/fs.go b/examples/kopia/repository/storage/fs/fs.go new file mode 100644 index 0000000..cf52875 --- /dev/null +++ b/examples/kopia/repository/storage/fs/fs.go @@ -0,0 +1,20 @@ +package fs + +import ( + "github.com/kanisterio/safecli/command" +) + +// metadata is the metadata for the filesystem storage. +type metadata map[string][]byte + +func (f metadata) RepoPath() string { + return string(f["repoPath"]) +} + +// New creates a new subcommand for the filesystem storage. +func New(data map[string][]byte) command.Applier { + m := metadata(data) + return command.NewArguments(subcmdFilesystem, + optRepoPath(m.RepoPath()), + ) +} diff --git a/examples/kopia/repository/storage/fs/opts.go b/examples/kopia/repository/storage/fs/opts.go new file mode 100644 index 0000000..5f624bb --- /dev/null +++ b/examples/kopia/repository/storage/fs/opts.go @@ -0,0 +1,19 @@ +package fs + +import ( + "fmt" + + "github.com/kanisterio/safecli/command" +) + +var ( + subcmdFilesystem = command.NewArgument("filesystem") +) + +// optRepoPath creates a new path option with a given repoPath. +func optRepoPath(repoPath string) command.Applier { + if repoPath == "" { + return command.NewErrorArgument(fmt.Errorf("repoPath cannot be empty")) + } + return command.NewOptionWithArgument("--path", repoPath) +} diff --git a/examples/kopia/repository/storage/s3/opts.go b/examples/kopia/repository/storage/s3/opts.go new file mode 100644 index 0000000..0b7e328 --- /dev/null +++ b/examples/kopia/repository/storage/s3/opts.go @@ -0,0 +1,50 @@ +package s3 + +import ( + "fmt" + + "github.com/kanisterio/safecli/command" +) + +var ( + subcmdS3 = command.NewArgument("s3") +) + +// optRegion creates a new region option with a given region. +// if the region is empty, it will do nothing. +func optRegion(region string) command.Applier { + return command.NewOptionWithArgument("--region", region) +} + +// optBucket creates a new bucket option with a given name. +// It returns an error if the name is empty. +func optBucket(name string) command.Applier { + if name == "" { + return command.NewErrorArgument(fmt.Errorf("bucket name cannot be empty")) + } + return command.NewOptionWithArgument("--bucket", name) +} + +// optEndpoint creates a new endpoint option with a given endpoint. +// if the endpoint is empty, it will do nothing. +func optEndpoint(endpoint string) command.Applier { + return command.NewOptionWithArgument("--endpoint", endpoint) +} + +// optPrefix creates a new prefix option with a given prefix. +// if the prefix is empty, it will do nothing. +func optPrefix(prefix string) command.Applier { + return command.NewOptionWithArgument("--prefix", prefix) +} + +// optDisableTLS creates a new disable-tls option with a given value. +// if the disable is false, it will do nothing. +func optDisableTLS(disable bool) command.Applier { + return command.NewOption("--disable-tls", disable) +} + +// optDisableTLSVerify creates a new disable-tls-verify option with a given value. +// if the disable is false, it will do nothing. +func optDisableTLSVerify(disable bool) command.Applier { + return command.NewOption("--disable-tls-verify", disable) +} diff --git a/examples/kopia/repository/storage/s3/s3.go b/examples/kopia/repository/storage/s3/s3.go new file mode 100644 index 0000000..7037302 --- /dev/null +++ b/examples/kopia/repository/storage/s3/s3.go @@ -0,0 +1,52 @@ +package s3 + +import ( + "strconv" + + "github.com/kanisterio/safecli/command" +) + +// metadata is the metadata for the S3 storage. +type metadata map[string][]byte + +func (f metadata) get(key string) string { + return string(f[key]) +} + +func (f metadata) Region() string { + return f.get("region") +} + +func (f metadata) BucketName() string { + return f.get("bucket") +} + +func (f metadata) Endpoint() string { + return f.get("endpoint") +} + +func (f metadata) Prefix() string { + return f.get("prefix") +} + +func (f metadata) IsInsecureEndpoint() bool { + return f.get("endpoint") == "http" +} + +func (f metadata) HasSkipSSLVerify() bool { + v, _ := strconv.ParseBool(f.get("skipSSLVerify")) + return v +} + +// New creates a new subcommand for the S3 storage. +func New(data map[string][]byte) command.Applier { + m := metadata(data) + return command.NewArguments(subcmdS3, + optRegion(m.Region()), + optBucket(m.BucketName()), + optEndpoint(m.Endpoint()), + optPrefix(m.Prefix()), + optDisableTLS(m.IsInsecureEndpoint()), + optDisableTLSVerify(m.HasSkipSSLVerify()), + ) +} diff --git a/go.mod b/go.mod index cf63b84..d2fc0dc 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/kanisterio/safecli go 1.20 -require gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c +require ( + github.com/pkg/errors v0.9.1 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c +) require ( github.com/kr/pretty v0.2.1 // indirect diff --git a/go.sum b/go.sum index 0c3aa11..411a1f5 100644 --- a/go.sum +++ b/go.sum @@ -3,5 +3,7 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/test/arg_suite.go b/test/arg_suite.go new file mode 100644 index 0000000..9e14709 --- /dev/null +++ b/test/arg_suite.go @@ -0,0 +1,129 @@ +// Copyright 2024 The Kanister 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 test + +import ( + "gopkg.in/check.v1" + + "github.com/pkg/errors" + + "github.com/kanisterio/safecli" + + "github.com/kanisterio/safecli/command" +) + +// ArgumentTest defines a test for a single argument. +type ArgumentTest struct { + // Name of the test. (required) + Name string + + // Argument to test. (required) + Argument command.Applier + + // Expected CLI arguments. (optional) + ExpectedCLI []string + + // Expected log output. (optional) + // if empty, it will be set to ExpectedCLI joined with space. + // if empty and ExpectedCLI is empty, it will be ignored. + ExpectedLog string + + // Expected error. (optional) + // If nil, no error is expected. + ExpectedErr error + + // Expected error message. (optional) + // If empty, it will be ignored. + ExpectedErrMsg string +} + +// CheckCommentString implements check.CommentInterface +func (t *ArgumentTest) CheckCommentString() string { + return t.Name +} + +// setDefaultExpectedLog sets the default value for ExpectedLog based on ExpectedCLI. +func (t *ArgumentTest) setDefaultExpectedLog() { + if len(t.ExpectedLog) == 0 && len(t.ExpectedCLI) > 0 { + t.ExpectedLog = RedactCLI(t.ExpectedCLI) + } +} + +// assertNoError makes sure there is no error. +func (t *ArgumentTest) assertNoError(c *check.C, err error) { + c.Assert(err, check.IsNil, t) +} + +// assertError checks the error against ExpectedErr. +func (t *ArgumentTest) assertError(c *check.C, err error) { + actualErr := errors.Cause(err) + c.Assert(actualErr, check.Equals, t.ExpectedErr, t) +} + +// assertErrorMsg checks the error message against ExpectedErrMsg. +func (t *ArgumentTest) assertErrorMsg(c *check.C, err error) { + if t.ExpectedErrMsg != "" { + c.Assert(err.Error(), check.Equals, t.ExpectedErrMsg, t) + } +} + +// assertCLI asserts the builder's CLI output against ExpectedCLI. +func (t *ArgumentTest) assertCLI(c *check.C, b *safecli.Builder) { + if t.ExpectedCLI != nil { + c.Check(b.Build(), check.DeepEquals, t.ExpectedCLI, t) + } +} + +// assertLog asserts the builder's log output against ExpectedLog. +func (t *ArgumentTest) assertLog(c *check.C, b *safecli.Builder) { + if t.ExpectedCLI != nil { + t.setDefaultExpectedLog() + c.Check(b.String(), check.Equals, t.ExpectedLog, t) + } +} + +// Test runs the argument test. +func (t *ArgumentTest) Test(c *check.C, cmdName string) { + if t.Name == "" { + c.Fatal("Name is required") + } + cmd, err := command.New(cmdName, t.Argument) + if err == nil { + t.assertNoError(c, err) + } else { + t.assertError(c, err) + t.assertErrorMsg(c, err) + } + t.assertCLI(c, cmd) + t.assertLog(c, cmd) +} + +// ArgumentSuite defines a test suite for arguments. +type ArgumentSuite struct { + Cmd string // Cmd appends to the safecli.Builder before test if not empty. + Arguments []ArgumentTest // Tests to run. +} + +// TestArguments runs all tests in the argument suite. +func (s *ArgumentSuite) TestArguments(c *check.C) { + for _, arg := range s.Arguments { + arg.Test(c, s.Cmd) + } +} + +// NewArgumentSuite creates a new ArgumentSuite. +func NewArgumentSuite(args []ArgumentTest) *ArgumentSuite { + return &ArgumentSuite{Arguments: args} +} diff --git a/test/arg_suite_test.go b/test/arg_suite_test.go new file mode 100644 index 0000000..b2cf94d --- /dev/null +++ b/test/arg_suite_test.go @@ -0,0 +1,173 @@ +// Copyright 2024 The Kanister 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 test_test + +import ( + "strings" + "testing" + + "github.com/pkg/errors" + + "gopkg.in/check.v1" + + "github.com/kanisterio/safecli" + "github.com/kanisterio/safecli/test" +) + +func TestCustomArgument(t *testing.T) { check.TestingT(t) } + +// CustomArgumentTest is a test for ArgumentTest. +// it has a custom argument that can be used to test the argument. +// and implements command.Applier. +type CustomArgumentTest struct { + name string + arg string + argErr error + expectedErr error + expectedErrMsg string +} + +func (t *CustomArgumentTest) Apply(cmd safecli.CommandAppender) error { + if t.argErr == nil { + cmd.AppendLoggable(t.arg) + } + return t.argErr +} + +func (t *CustomArgumentTest) Test(c *check.C) { + argTest := test.ArgumentTest{ + Name: t.name, + Argument: t, + ExpectedErr: t.expectedErr, + ExpectedErrMsg: t.expectedErrMsg, + } + if t.arg != "" { + argTest.ExpectedCLI = []string{t.arg} + } + argTest.Test(c, "") +} + +type CustomArgumentSuite struct { + cmd string + tests []test.ArgumentTest +} + +func (s *CustomArgumentSuite) Test(c *check.C) { + suite := test.NewArgumentSuite(s.tests) + suite.Cmd = s.cmd + suite.TestArguments(c) +} + +// TestRunnerWithConfig is a test suite for CustomArgumentTest. +type TestRunnerWithConfig struct { + out strings.Builder // output buffer for the test results + cfg *check.RunConf // custom test configuration +} + +// register the test suite +var _ = check.Suite(&TestRunnerWithConfig{}) + +// SetUpTest sets up the test suite for running. +// it initializes the output buffer and the test configuration. +func (s *TestRunnerWithConfig) SetUpTest(c *check.C) { + s.out = strings.Builder{} + s.cfg = &check.RunConf{ + Output: &s.out, + Verbose: true, + } +} + +// TestArgumentTestOK tests the ArgumentTest with no errors. +func (s *TestRunnerWithConfig) TestArgumentTestOK(c *check.C) { + cat := CustomArgumentTest{ + name: "TestArgumentOK", + arg: "--test", + } + res := check.Run(&cat, s.cfg) + c.Assert(s.out.String(), check.Matches, "PASS: .*CustomArgumentTest\\.Test.*\n") + c.Assert(res.Passed(), check.Equals, true) +} + +// TestArgumentTestErr tests the ArgumentTest with an error. +func (s *TestRunnerWithConfig) TestArgumentTestEmptyName(c *check.C) { + cat := CustomArgumentTest{ + name: "", + } + res := check.Run(&cat, s.cfg) + out := strings.ReplaceAll(s.out.String(), "\n", "") + c.Assert(out, check.Matches, ".*FAIL:.*CustomArgumentTest\\.Test.*Error: Name is required.*") + c.Assert(res.Passed(), check.Equals, false) +} + +// TestArgumentTestErr tests the ArgumentTest with an error. +func (s *TestRunnerWithConfig) TestArgumentTestErr(c *check.C) { + err := errors.New("test error") + cat := CustomArgumentTest{ + name: "TestArgumentErr", + argErr: err, + expectedErr: err, + expectedErrMsg: "test error", + } + res := check.Run(&cat, s.cfg) + c.Assert(s.out.String(), check.Matches, "PASS: .*CustomArgumentTest\\.Test.*\n") + c.Assert(res.Passed(), check.Equals, true) +} + +// TestArgumentTestWrapperErr tests the ArgumentTest with a wrapped error. +func (s *TestRunnerWithConfig) TestArgumentTestWrapperErr(c *check.C) { + err := errors.New("test error") + werr := errors.Wrap(err, "wrapper error") + cat := CustomArgumentTest{ + name: "TestArgumentTestWrapperErr", + argErr: werr, + expectedErr: err, + } + res := check.Run(&cat, s.cfg) + c.Assert(s.out.String(), check.Matches, "PASS: .*CustomArgumentTest\\.Test.*\n") + c.Assert(res.Passed(), check.Equals, true) +} + +// TestArgumentTestUnexpectedErr tests the ArgumentTest with an unexpected error. +func (s *TestRunnerWithConfig) TestArgumentTestUnexpectedErr(c *check.C) { + err := errors.New("test error") + cat := CustomArgumentTest{ + name: "TestArgumentUnexpectedErr", + arg: "--test", + argErr: err, + expectedErr: nil, + } + res := check.Run(&cat, s.cfg) + ss := s.out.String() + c.Assert(strings.Contains(ss, "TestArgumentUnexpectedErr"), check.Equals, true) + c.Assert(strings.Contains(ss, "test error"), check.Equals, true) + c.Assert(res.Passed(), check.Equals, false) +} + +// TestArgumentSuiteOK tests the ArgumentSuite with no errors. +func (s *TestRunnerWithConfig) TestArgumentSuiteOK(c *check.C) { + cfs := CustomArgumentSuite{ + cmd: "cmd", + tests: []test.ArgumentTest{ + { + Name: "TestArgumentOK", + Argument: &CustomArgumentTest{name: "TestArgumentOK", arg: "--test"}, + ExpectedCLI: []string{"cmd", "--test"}, + }, + }, + } + res := check.Run(&cfs, s.cfg) + c.Assert(s.out.String(), check.Matches, "PASS: .*CustomArgumentSuite\\.Test.*\n") + c.Assert(res.Passed(), check.Equals, true) +} diff --git a/test/redact.go b/test/redact.go new file mode 100644 index 0000000..36d2409 --- /dev/null +++ b/test/redact.go @@ -0,0 +1,47 @@ +// Copyright 2024 The Kanister 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 test + +import ( + "fmt" + "strings" +) + +const ( + redactField = "<****>" +) + +var redactedFlags = []string{ + "--password", + "--user-password", + "--server-password", + "--server-control-password", + "--server-cert-fingerprint", +} + +// RedactCLI redacts sensitive information from the CLI command for tests. +func RedactCLI(cli []string) string { + redactedCLI := make([]string, len(cli)) + for i, arg := range cli { + redactedCLI[i] = arg + for _, flag := range redactedFlags { + if strings.HasPrefix(arg, flag+"=") { + redactedCLI[i] = fmt.Sprintf("%s=%s", flag, redactField) + break // redacted flag found, no need to check further + } + } + } + return strings.Join(redactedCLI, " ") +} diff --git a/test/redact_test.go b/test/redact_test.go new file mode 100644 index 0000000..4bce54b --- /dev/null +++ b/test/redact_test.go @@ -0,0 +1,51 @@ +// Copyright 2024 The Kanister 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 test + +import ( + "strings" + "testing" + + "gopkg.in/check.v1" +) + +func TestRedactCLI(t *testing.T) { check.TestingT(t) } + +type RedactSuite struct{} + +var _ = check.Suite(&RedactSuite{}) + +func (s *RedactSuite) TestRedactCLI(c *check.C) { + cli := []string{ + "--password=secret", + "--user-password=123456", + "--server-password=pass123", + "--server-control-password=abc123", + "--server-cert-fingerprint=abcd1234", + "--other-flag=value", + "argument", + } + expected := []string{ + "--password=<****>", + "--user-password=<****>", + "--server-password=<****>", + "--server-control-password=<****>", + "--server-cert-fingerprint=<****>", + "--other-flag=value", + "argument", + } + result := RedactCLI(cli) + c.Assert(result, check.Equals, strings.Join(expected, " ")) +}