From 79168d0dab184937f5755271dd99bbef7952f118 Mon Sep 17 00:00:00 2001 From: plar Date: Thu, 15 Feb 2024 15:41:39 -0800 Subject: [PATCH] Improve examples and docs --- command/arg.go | 5 + examples/kopia/README.md | 107 +++++++++++++++ examples/kopia/main.go | 124 ++++++++++++++++-- examples/kopia/repository/location.go | 15 +++ examples/kopia/repository/opts.go | 51 +++++-- .../kopia/repository/repository_connect.go | 32 +++++ .../kopia/repository/repository_create.go | 14 +- 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 ++++++++ 11 files changed, 463 insertions(+), 26 deletions(-) create mode 100644 examples/kopia/README.md create mode 100644 examples/kopia/repository/location.go create mode 100644 examples/kopia/repository/repository_connect.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 diff --git a/command/arg.go b/command/arg.go index cf3ed0d..349f990 100644 --- a/command/arg.go +++ b/command/arg.go @@ -42,6 +42,11 @@ 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 { 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/main.go b/examples/kopia/main.go index 8a0664e..cf61b1e 100644 --- a/examples/kopia/main.go +++ b/examples/kopia/main.go @@ -27,21 +27,123 @@ func main() { ConfigFilePath: "/path/to/config", LogDirectory: "/path/to/log", LogLevel: "error", - RepoPassword: "password", + 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) - - fmt.Printf("exec=%#v\n", cmd.Build()) - fmt.Printf("log=%#v\n", cmd) // make sure that password is redacted - fmt.Printf("log=%v\n", cmd) // make sure that password is redacted - fmt.Printf("err=%#v\n", err) + if err != nil { + fmt.Println(err) + return + } + fmt.Println("exec=", cmd) + // 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 + fmt.Printf("exec=%#v", cmd.Build()) + // 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 } -// $ go run . -// exec=[]string{"kopia", "--config-file=/path/to/config", "--log-dir=/path/to/log", "--log-level=error", "--password=password", "repository", "create", "--override-hostname=localhost", "--override-username=user"} -// log=&safecli.Builder{Args:[]safecli.Argument{safecli.Argument{Key:"", Value:(*safecli.PlainValue)(0xc000096020)}, safecli.Argument{Key:"--config-file", Value:(*safecli.PlainValue)(0xc000096030)}, safecli.Argument{Key:"--log-dir", Value:(*safecli.PlainValue)(0xc000096040)}, safecli.Argument{Key:"--log-level", Value:(*safecli.PlainValue)(0xc000096050)}, safecli.Argument{Key:"--password", Value:<****>}, safecli.Argument{Key:"", Value:(*safecli.PlainValue)(0xc000096080)}, safecli.Argument{Key:"", Value:(*safecli.PlainValue)(0xc0000960a0)}, safecli.Argument{Key:"--override-hostname", Value:(*safecli.PlainValue)(0xc0000960b0)}, safecli.Argument{Key:"--override-username", Value:(*safecli.PlainValue)(0xc0000960c0)}}, Formatter:(safecli.ArgumentFormatter)(0x47cee0)} -// log=kopia --config-file=/path/to/config --log-dir=/path/to/log --log-level=error --password=<****> repository create --override-hostname=localhost --override-username=user -// err= +// 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 index c74fbb1..3db4186 100644 --- a/examples/kopia/repository/opts.go +++ b/examples/kopia/repository/opts.go @@ -14,20 +14,53 @@ package repository -import "github.com/kanisterio/safecli/command" +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" +) -// cmdXXX represents different `kopia repository“ commands. var ( cmdRepository = command.NewArgument("repository") - cmdCreate = command.NewArgument("create") + + subcmdCreate = command.NewArgument("create") + subcmdConnect = command.NewArgument("connect") ) -// optHostname returns a new optHostname flag with a given optHostname. -func optHostname(hostname string) command.Applier { - return command.NewOptionWithArgument("--override-hostname", hostname) +// 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)) } -// optUsername returns a new optUsername flag with a given optUsername. -func optUsername(username string) command.Applier { - return command.NewOptionWithArgument("--override-username", username) +// 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 index 130a8a0..b72e7bf 100644 --- a/examples/kopia/repository/repository_create.go +++ b/examples/kopia/repository/repository_create.go @@ -23,17 +23,19 @@ import ( // CreateArgs represents the arguments for the `kopia repository create` command. type CreateArgs struct { - args.Common - - Hostname string - Username string + 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, cmdCreate, + 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()), + ) +}