From e54feaec21be2fbc80efc93e390888cb2f486efd Mon Sep 17 00:00:00 2001 From: Adam Charrett <73886859+adcharre@users.noreply.github.com> Date: Sat, 16 Dec 2023 16:19:23 +0000 Subject: [PATCH] Add command line tool to query / upload to terrarium (#62) * Initial commit of CLI * Delete cli * Unit tests * Add code to publish a module * Add better error handling and improve container deps output * Add module-deps command * Add publish command * Update module_publish.go * Delete LICENSE * Tidy up * Ensure cli is build on PR * Update pr.yml * Update pr.yml * Update module_publish.go * Remove copyright comment --- .github/workflows/pr.yml | 12 ++ tools/cli/.gitignore | 1 + tools/cli/cmd/module.go | 34 +++ tools/cli/cmd/module_containerDeps.go | 55 +++++ tools/cli/cmd/module_moduleDeps.go | 50 +++++ tools/cli/cmd/module_publish.go | 95 +++++++++ tools/cli/cmd/release.go | 27 +++ tools/cli/cmd/release_publish.go | 67 ++++++ tools/cli/cmd/root.go | 45 ++++ tools/cli/go.mod | 26 +++ tools/cli/go.sum | 41 ++++ tools/cli/main.go | 7 + tools/cli/pkg/module/publish.go | 99 +++++++++ tools/cli/pkg/module/publish_test.go | 289 ++++++++++++++++++++++++++ 14 files changed, 848 insertions(+) create mode 100644 tools/cli/.gitignore create mode 100644 tools/cli/cmd/module.go create mode 100644 tools/cli/cmd/module_containerDeps.go create mode 100644 tools/cli/cmd/module_moduleDeps.go create mode 100644 tools/cli/cmd/module_publish.go create mode 100644 tools/cli/cmd/release.go create mode 100644 tools/cli/cmd/release_publish.go create mode 100644 tools/cli/cmd/root.go create mode 100644 tools/cli/go.mod create mode 100644 tools/cli/go.sum create mode 100644 tools/cli/main.go create mode 100644 tools/cli/pkg/module/publish.go create mode 100644 tools/cli/pkg/module/publish_test.go diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0c3ed79..3d653e2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,4 +28,16 @@ jobs: - name: Build run: | + go build + + - name: Test CLI Code + run: | + export CI=true + cd tools/cli + go test './...' + echo "CLI Tests passed successfully." + + - name: Build CLI + run: | + cd tools/cli go build \ No newline at end of file diff --git a/tools/cli/.gitignore b/tools/cli/.gitignore new file mode 100644 index 0000000..573c0c4 --- /dev/null +++ b/tools/cli/.gitignore @@ -0,0 +1 @@ +cli diff --git a/tools/cli/cmd/module.go b/tools/cli/cmd/module.go new file mode 100644 index 0000000..ae46420 --- /dev/null +++ b/tools/cli/cmd/module.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/terrariumcloud/terrarium/pkg/terrarium/module" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// moduleCmd represents the module command +var moduleCmd = &cobra.Command{ + Use: "module", + Short: "Commands for managing modules", +} + +func init() { + rootCmd.AddCommand(moduleCmd) +} + +func getModulePublisherClient() (*grpc.ClientConn, module.PublisherClient, error) { + conn, err := grpc.Dial(terrariumEndpoint, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, err + } + return conn, module.NewPublisherClient(conn), nil +} + +func getModuleConsumerClient() (*grpc.ClientConn, module.ConsumerClient, error) { + conn, err := grpc.Dial(terrariumEndpoint, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, err + } + return conn, module.NewConsumerClient(conn), nil +} diff --git a/tools/cli/cmd/module_containerDeps.go b/tools/cli/cmd/module_containerDeps.go new file mode 100644 index 0000000..98b4243 --- /dev/null +++ b/tools/cli/cmd/module_containerDeps.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "context" + "fmt" + "github.com/spf13/cobra" + "github.com/terrariumcloud/terrarium/pkg/terrarium/module" + "io" +) + +// containerDepsCmd represents the containerDeps command +var containerDepsCmd = &cobra.Command{ + Use: "container-deps", + Short: "List the container dependencies for a module", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + conn, client, err := getModuleConsumerClient() + if err != nil { + printErrorAndExit("Failed to connect to terrarium", err, 1) + } + defer func() { _ = conn.Close() }() + req := module.RetrieveContainerDependenciesRequestV2{ + Module: &module.Module{ + Name: args[0], + Version: args[1], + }, + } + responseClient, err := client.RetrieveContainerDependenciesV2(context.Background(), &req) + if err != nil { + printErrorAndExit("Failed to retrieve container dependencies", err, 1) + } + for { + response, err := responseClient.Recv() + if err == io.EOF { + break + } + if err != nil { + printErrorAndExit("Retrieving dependencies failed", err, 1) + } + fmt.Printf("%s:%s:\n", response.Module.Name, response.Module.Version) + for name, containerDetails := range response.Dependencies { + fmt.Printf(" %s/%s:%s:\n", containerDetails.Namespace, name, containerDetails.Tag) + + for _, details := range containerDetails.Images { + fmt.Printf(" - arch: %s\n", details.Arch) + fmt.Printf(" image: %s\n", details.Image) + } + } + } + }, +} + +func init() { + moduleCmd.AddCommand(containerDepsCmd) +} diff --git a/tools/cli/cmd/module_moduleDeps.go b/tools/cli/cmd/module_moduleDeps.go new file mode 100644 index 0000000..4f1745d --- /dev/null +++ b/tools/cli/cmd/module_moduleDeps.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "context" + "fmt" + "github.com/spf13/cobra" + "github.com/terrariumcloud/terrarium/pkg/terrarium/module" + "io" +) + +// moduleDepsCmd represents the moduleDeps command +var moduleDepsCmd = &cobra.Command{ + Use: "module-deps", + Short: "List the module dependencies of the specified module.", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + conn, client, err := getModuleConsumerClient() + if err != nil { + printErrorAndExit("Failed to connect to terrarium", err, 1) + } + defer func() { _ = conn.Close() }() + req := module.RetrieveModuleDependenciesRequest{ + Module: &module.Module{ + Name: args[0], + Version: args[1], + }, + } + responseClient, err := client.RetrieveModuleDependencies(context.Background(), &req) + if err != nil { + printErrorAndExit("Failed to retrieve module dependencies", err, 1) + } + for { + response, err := responseClient.Recv() + if err == io.EOF { + break + } + if err != nil { + printErrorAndExit("Retrieving dependencies failed", err, 1) + } + fmt.Printf("%s:%s:\n", response.Module.Name, response.Module.Version) + for _, module := range response.Dependencies { + fmt.Printf(" - %s:%s\n", module.Name, module.Version) + } + } + }, +} + +func init() { + moduleCmd.AddCommand(moduleDepsCmd) +} diff --git a/tools/cli/cmd/module_publish.go b/tools/cli/cmd/module_publish.go new file mode 100644 index 0000000..86ae572 --- /dev/null +++ b/tools/cli/cmd/module_publish.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "fmt" + module2 "github.com/terrariumcloud/terrarium/pkg/terrarium/module" + "github.com/terrariumcloud/terrarium/tools/cli/pkg/module" + "os" + "strings" + + "github.com/spf13/cobra" +) + +type maturityValue struct { + maturity module2.Maturity +} + +var ( + moduleMetadata = module.Metadata{ + Name: "", + Version: "", + Description: "", + Source: "", + Maturity: module2.Maturity_STABLE, + } + maturityVar = maturityValue{maturity: module2.Maturity_STABLE} +) + +// modulePublishCmd represents the publish command +var modulePublishCmd = &cobra.Command{ + Use: "publish [module source.zip]", + Short: "Publishes a zip file as a module version in terrarium.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + conn, client, err := getModulePublisherClient() + if err != nil { + printErrorAndExit("Failed to connect to terrarium", err, 1) + } + defer func() { _ = conn.Close() }() + if source, err := os.Open(args[0]); err != nil { + printErrorAndExit(fmt.Sprintf("Failed to open %s", args[0]), err, 1) + } else { + moduleMetadata.Maturity = maturityVar.maturity + err := module.Publish(client, source, moduleMetadata) + if err != nil { + printErrorAndExit("Publishing module failed", err, 1) + } else { + fmt.Println("Module published.") + } + } + }, +} + +func init() { + moduleCmd.AddCommand(modulePublishCmd) + + modulePublishCmd.Flags().StringVar(&moduleMetadata.Name, "name", "", "Name of the module in the form \"//\".") + _ = modulePublishCmd.MarkFlagRequired("name") + modulePublishCmd.Flags().StringVar(&moduleMetadata.Version, "version", "", "Semantic version of the module to publish.") + _ = modulePublishCmd.MarkFlagRequired("version") + modulePublishCmd.Flags().StringVar(&moduleMetadata.Description, "description", "", "Description of the module.") + modulePublishCmd.Flags().StringVar(&moduleMetadata.Source, "source", "", "URL containing the module source.") + modulePublishCmd.Flags().Var(&maturityVar, "maturity", "The maturity of the module, one of IDEA, PLANNING, DEVELOPING, ALPHA, BETA, STABLE, DEPRECATED or END-OF-LIFE.") +} + +var maturityToString = map[module2.Maturity]string{ + module2.Maturity_IDEA: "IDEA", + module2.Maturity_PLANNING: "PLANNING", + module2.Maturity_DEVELOPING: "DEVELOPING", + module2.Maturity_ALPHA: "ALPHA", + module2.Maturity_BETA: "BETA", + module2.Maturity_STABLE: "STABLE", + module2.Maturity_DEPRECATED: "DEPRECATED", + module2.Maturity_END_OF_LIFE: "END-OF-LIFE", +} + +func (m maturityValue) String() string { + if s, ok := maturityToString[m.maturity]; ok { + return s + } + return "UNKNOWN" +} + +func (m *maturityValue) Set(s string) error { + for maturity, maturityStr := range maturityToString { + if strings.ToUpper(s) == maturityStr { + m.maturity = maturity + return nil + } + } + return fmt.Errorf("unknown maturity: %s", s) +} + +func (m maturityValue) Type() string { + return "maturity" +} diff --git a/tools/cli/cmd/release.go b/tools/cli/cmd/release.go new file mode 100644 index 0000000..69949b0 --- /dev/null +++ b/tools/cli/cmd/release.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "github.com/terrariumcloud/terrarium/pkg/terrarium/release" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/spf13/cobra" +) + +// releaseCmd represents the release command +var releaseCmd = &cobra.Command{ + Use: "release", + Short: "Commands for managing releases", +} + +func init() { + rootCmd.AddCommand(releaseCmd) +} + +func getReleasePublisherClient() (*grpc.ClientConn, release.ReleasePublisherClient, error) { + conn, err := grpc.Dial(terrariumEndpoint, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, err + } + return conn, release.NewReleasePublisherClient(conn), nil +} diff --git a/tools/cli/cmd/release_publish.go b/tools/cli/cmd/release_publish.go new file mode 100644 index 0000000..0c1f157 --- /dev/null +++ b/tools/cli/cmd/release_publish.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "context" + "fmt" + "github.com/spf13/cobra" + "github.com/terrariumcloud/terrarium/pkg/terrarium/release" + "strings" +) + +var ( + releaseToPublish = release.PublishRequest{ + Type: "", + Organization: "", + Name: "", + Version: "", + Description: "", + Links: nil, + } + releaseLinks []string +) + +// releasePublishCmd represents the publish command +var releasePublishCmd = &cobra.Command{ + Use: "publish", + Short: "Publish details of a new release.", + Run: func(cmd *cobra.Command, args []string) { + conn, client, err := getReleasePublisherClient() + if err != nil { + printErrorAndExit("Failed to connect to terrarium", err, 1) + } + defer func() { _ = conn.Close() }() + + for _, link := range releaseLinks { + if parts := strings.SplitN(link, "=", 2); len(parts) == 1 { + releaseToPublish.Links = append(releaseToPublish.Links, &release.Link{ + Title: "", + Url: parts[0], + }) + } else { + releaseToPublish.Links = append(releaseToPublish.Links, &release.Link{ + Title: parts[0], + Url: parts[1], + }) + } + } + + if _, err := client.Publish(context.Background(), &releaseToPublish); err != nil { + printErrorAndExit("Failed to publish release", err, 1) + } + fmt.Println("Release published.") + }, +} + +func init() { + releaseCmd.AddCommand(releasePublishCmd) + releasePublishCmd.Flags().StringVarP(&releaseToPublish.Name, "name", "n", "", "Name of the release.") + releasePublishCmd.MarkFlagRequired("name") + releasePublishCmd.Flags().StringVarP(&releaseToPublish.Version, "version", "v", "", "Version of the release.") + releasePublishCmd.MarkFlagRequired("version") + releasePublishCmd.Flags().StringVarP(&releaseToPublish.Organization, "org", "o", "", "Organization to which the release belongs.") + releasePublishCmd.MarkFlagRequired("org") + releasePublishCmd.Flags().StringVarP(&releaseToPublish.Type, "type", "t", "", "Type of release.") + releasePublishCmd.MarkFlagRequired("type") + releasePublishCmd.Flags().StringVarP(&releaseToPublish.Description, "description", "d", "", "Description of the release being published.") + releasePublishCmd.Flags().StringSliceVarP(&releaseLinks, "link", "l", []string{}, `Links and optional titles eg.: --link "title=url" or --link "url"`) +} diff --git a/tools/cli/cmd/root.go b/tools/cli/cmd/root.go new file mode 100644 index 0000000..8e0da7a --- /dev/null +++ b/tools/cli/cmd/root.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var terrariumEndpoint = "localhost:3001" + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "cli", + Short: "A brief description of your application", + Long: `A longer description that spans multiple lines and likely contains +examples and usage of using your application. For example: + +Cobra is a CLI library for Go that empowers applications. +This application is a tool to generate the needed files +to quickly create a Cobra application.`, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVar(&terrariumEndpoint, "endpoint", terrariumEndpoint, "GRPC Endpoint for Terrarium.") +} + +func printErrorAndExit(msg string, err error, exitCode int) { + fmt.Fprintf(os.Stderr, "ERROR: %s", msg) + if err != nil { + fmt.Fprintf(os.Stderr, ": %s\n", err) + } else { + fmt.Fprintln(os.Stderr, "") + } + os.Exit(exitCode) +} diff --git a/tools/cli/go.mod b/tools/cli/go.mod new file mode 100644 index 0000000..6b1212a --- /dev/null +++ b/tools/cli/go.mod @@ -0,0 +1,26 @@ +module github.com/terrariumcloud/terrarium/tools/cli + +go 1.21.4 + +require ( + github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.8.4 + github.com/terrariumcloud/terrarium v0.0.69 + google.golang.org/grpc v1.59.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/terrariumcloud/terrarium/pkg/terrarium/module v0.0.69 => ../../pkg/terrarium/module diff --git a/tools/cli/go.sum b/tools/cli/go.sum new file mode 100644 index 0000000..8698ce7 --- /dev/null +++ b/tools/cli/go.sum @@ -0,0 +1,41 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/terrariumcloud/terrarium v0.0.69 h1:xHTtt6yxIYCQtOhiZwPMNI1fbAt7ioCYOjmoc4zw3h0= +github.com/terrariumcloud/terrarium v0.0.69/go.mod h1:hTKdLtf/NbF7uAJEi1lV5YWonjRycqs3u+Dy/Srf4q4= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 h1:AB/lmRny7e2pLhFEYIbl5qkDAUt2h0ZRO4wGPhZf+ik= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/cli/main.go b/tools/cli/main.go new file mode 100644 index 0000000..af3a5e5 --- /dev/null +++ b/tools/cli/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/terrariumcloud/terrarium/tools/cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/tools/cli/pkg/module/publish.go b/tools/cli/pkg/module/publish.go new file mode 100644 index 0000000..00c0a78 --- /dev/null +++ b/tools/cli/pkg/module/publish.go @@ -0,0 +1,99 @@ +package module + +import ( + "context" + "github.com/terrariumcloud/terrarium/pkg/terrarium/module" + "io" +) + +const DefaultChunkSize = 64 * 1024 + +type Metadata struct { + Name string + Version string + Description string + Source string + Maturity module.Maturity +} + +func Publish(client module.PublisherClient, source io.Reader, metadata Metadata) error { + register := &module.RegisterModuleRequest{ + Name: metadata.Name, + Description: metadata.Description, + Source: metadata.Source, + Maturity: metadata.Maturity, + } + + if _, err := client.Register(context.Background(), register); err != nil { + return err + } + + moduleVersion := &module.Module{ + Name: metadata.Name, + Version: metadata.Version, + } + + begin := &module.BeginVersionRequest{ + Module: moduleVersion, + } + + if _, err := client.BeginVersion(context.Background(), begin); err != nil { + return err + } + + end := &module.EndVersionRequest{ + Module: moduleVersion, + Action: module.EndVersionRequest_PUBLISH, + } + + if err := upload(client, source, moduleVersion); err != nil { + end.Action = module.EndVersionRequest_DISCARD + _, _ = client.EndVersion(context.Background(), end) + return err + } + + if _, err := client.EndVersion(context.Background(), end); err != nil { + return err + } + + return nil +} + +func upload(client module.PublisherClient, source io.Reader, moduleVersion *module.Module) error { + stream, err := client.UploadSourceZip(context.Background()) + + if err != nil { + return err + } + + for { + chunk := make([]byte, DefaultChunkSize) + n, err := source.Read(chunk) + + if err == io.EOF { + break + } + + if err != nil { + return err + } + + if n < len(chunk) { + chunk = chunk[:n] + } + + req := &module.UploadSourceZipRequest{ + Module: moduleVersion, + ZipDataChunk: chunk, + } + + if err := stream.Send(req); err != nil { + return err + } + } + + if _, err := stream.CloseAndRecv(); err != nil { + return err + } + return nil +} diff --git a/tools/cli/pkg/module/publish_test.go b/tools/cli/pkg/module/publish_test.go new file mode 100644 index 0000000..0a0e3e8 --- /dev/null +++ b/tools/cli/pkg/module/publish_test.go @@ -0,0 +1,289 @@ +package module + +import ( + "context" + "errors" + "github.com/stretchr/testify/require" + "github.com/terrariumcloud/terrarium/pkg/terrarium/module" + "google.golang.org/grpc" + "io" + "strings" + "testing" +) + +func TestPublish(t *testing.T) { + type args struct { + client *MockPublisherClient + source io.Reader + metadata Metadata + } + type expected struct { + registerCalls int + beginVersionCalls int + registerContainerDependenciesCalls int + uploadSourceZipCalls int + uploadSourceZipSendCalls int + endVersionCalls int + endVersionRequest *module.EndVersionRequest + } + tests := []struct { + name string + args args + wantErr bool + expected expected + }{ + { + name: "Success", + args: args{ + client: &MockPublisherClient{ + uploadSourceZipResponse: &MockPublisher_UploadSourceZipClient{ + sendError: nil, + closeAndRecvError: nil, + closeAndRecvResponse: &module.Response{ + Message: "done", + }, + }, + }, + source: strings.NewReader("test"), + metadata: Metadata{ + Name: "org/test/provider", + Version: "1.2.4", + Description: "A test", + Source: "http://test.com/test", + Maturity: module.Maturity_PLANNING, + }, + }, + expected: expected{ + registerCalls: 1, + beginVersionCalls: 1, + registerContainerDependenciesCalls: 0, + uploadSourceZipCalls: 1, + uploadSourceZipSendCalls: 1, + endVersionCalls: 1, + endVersionRequest: &module.EndVersionRequest{ + Module: &module.Module{ + Name: "org/test/provider", + Version: "1.2.4", + }, + Action: module.EndVersionRequest_PUBLISH, + }, + }, + wantErr: false, + }, + { + name: "Fail during register()", + args: args{ + client: &MockPublisherClient{ + registerError: errors.New("failed"), + }, + source: strings.NewReader("test"), + metadata: Metadata{ + Name: "org/test/provider", + Version: "1.2.4", + Description: "A test", + Source: "http://test.com/test", + Maturity: module.Maturity_PLANNING, + }, + }, + expected: expected{ + registerCalls: 1, + beginVersionCalls: 0, + registerContainerDependenciesCalls: 0, + uploadSourceZipCalls: 0, + uploadSourceZipSendCalls: 0, + endVersionCalls: 0, + }, + wantErr: true, + }, + { + name: "Fail during register()", + args: args{ + client: &MockPublisherClient{ + beginVersionError: errors.New("failed"), + }, + source: strings.NewReader("test"), + metadata: Metadata{ + Name: "org/test/provider", + Version: "1.2.4", + Description: "A test", + Source: "http://test.com/test", + Maturity: module.Maturity_PLANNING, + }, + }, + expected: expected{ + registerCalls: 1, + beginVersionCalls: 1, + registerContainerDependenciesCalls: 0, + uploadSourceZipCalls: 0, + uploadSourceZipSendCalls: 0, + endVersionCalls: 0, + }, + wantErr: true, + }, + { + name: "Fail at upload", + args: args{ + client: &MockPublisherClient{ + uploadSourceZipError: errors.New("failed"), + }, + source: strings.NewReader("test"), + metadata: Metadata{ + Name: "org/test/provider", + Version: "1.2.4", + Description: "A test", + Source: "http://test.com/test", + Maturity: module.Maturity_PLANNING, + }, + }, + expected: expected{ + registerCalls: 1, + beginVersionCalls: 1, + registerContainerDependenciesCalls: 0, + uploadSourceZipCalls: 1, + uploadSourceZipSendCalls: 0, + endVersionCalls: 1, + endVersionRequest: &module.EndVersionRequest{ + Module: &module.Module{ + Name: "org/test/provider", + Version: "1.2.4", + }, + Action: module.EndVersionRequest_DISCARD, + }, + }, + wantErr: true, + }, + { + name: "Fail during upload send()", + args: args{ + client: &MockPublisherClient{ + uploadSourceZipResponse: &MockPublisher_UploadSourceZipClient{ + sendError: errors.New("failed"), + closeAndRecvError: nil, + closeAndRecvResponse: &module.Response{ + Message: "done", + }, + }, + }, + source: strings.NewReader("test"), + metadata: Metadata{ + Name: "org/test/provider", + Version: "1.2.4", + Description: "A test", + Source: "http://test.com/test", + Maturity: module.Maturity_PLANNING, + }, + }, + expected: expected{ + registerCalls: 1, + beginVersionCalls: 1, + registerContainerDependenciesCalls: 0, + uploadSourceZipCalls: 1, + uploadSourceZipSendCalls: 1, + endVersionCalls: 1, + endVersionRequest: &module.EndVersionRequest{ + Module: &module.Module{ + Name: "org/test/provider", + Version: "1.2.4", + }, + Action: module.EndVersionRequest_DISCARD, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := Publish(tt.args.client, tt.args.source, tt.args.metadata); (err != nil) != tt.wantErr { + t.Errorf("Publish() error = %v, wantErr %v", err, tt.wantErr) + } + require.Equal(t, tt.expected.registerCalls, tt.args.client.registerCalls) + require.Equal(t, tt.expected.beginVersionCalls, tt.args.client.beginVersionCalls) + require.Equal(t, tt.expected.uploadSourceZipCalls, tt.args.client.uploadSourceZipCalls) + require.Equal(t, tt.expected.endVersionCalls, tt.args.client.endVersionCalls) + if tt.expected.endVersionRequest != nil { + require.Equal(t, tt.expected.endVersionRequest, tt.args.client.endVersionRequest) + } + if tt.args.client.uploadSourceZipResponse != nil { + require.Equal(t, tt.expected.uploadSourceZipSendCalls, tt.args.client.uploadSourceZipResponse.sendCalls) + } + }) + } +} + +type MockPublisherClient struct { + registerCalls int + registerError error + registerResponse *module.Response + beginVersionCalls int + beginVersionError error + beginVersionResponse *module.Response + registerModuleDependenciesCalls int + registerModuleDependenciesError error + registerModuleDependenciesResponse *module.Response + registerContainerDependenciesCalls int + registerContainerDependenciesError error + registerContainerDependenciesResponse *module.Response + uploadSourceZipCalls int + uploadSourceZipError error + uploadSourceZipResponse *MockPublisher_UploadSourceZipClient + endVersionCalls int + endVersionRequest *module.EndVersionRequest + endVersionError error + endVersionResponse *module.Response + publishTagCalls int + publishTagError error + publishTagResponse *module.Response +} + +func (m *MockPublisherClient) Register(ctx context.Context, in *module.RegisterModuleRequest, opts ...grpc.CallOption) (*module.Response, error) { + m.registerCalls++ + return m.registerResponse, m.registerError +} + +func (m *MockPublisherClient) BeginVersion(ctx context.Context, in *module.BeginVersionRequest, opts ...grpc.CallOption) (*module.Response, error) { + m.beginVersionCalls++ + return m.beginVersionResponse, m.beginVersionError +} + +func (m *MockPublisherClient) RegisterModuleDependencies(ctx context.Context, in *module.RegisterModuleDependenciesRequest, opts ...grpc.CallOption) (*module.Response, error) { + m.registerModuleDependenciesCalls++ + return m.registerModuleDependenciesResponse, m.registerModuleDependenciesError +} + +func (m *MockPublisherClient) RegisterContainerDependencies(ctx context.Context, in *module.RegisterContainerDependenciesRequest, opts ...grpc.CallOption) (*module.Response, error) { + m.registerContainerDependenciesCalls++ + return m.registerContainerDependenciesResponse, m.registerContainerDependenciesError +} + +func (m *MockPublisherClient) UploadSourceZip(ctx context.Context, opts ...grpc.CallOption) (module.Publisher_UploadSourceZipClient, error) { + m.uploadSourceZipCalls++ + return m.uploadSourceZipResponse, m.uploadSourceZipError +} + +func (m *MockPublisherClient) EndVersion(ctx context.Context, in *module.EndVersionRequest, opts ...grpc.CallOption) (*module.Response, error) { + m.endVersionCalls++ + m.endVersionRequest = in + return m.endVersionResponse, m.endVersionError +} + +func (m *MockPublisherClient) PublishTag(ctx context.Context, in *module.PublishTagRequest, opts ...grpc.CallOption) (*module.Response, error) { + m.publishTagCalls++ + return m.publishTagResponse, m.publishTagError +} + +type MockPublisher_UploadSourceZipClient struct { + grpc.ClientStream + sendCalls int + sendError error + closeAndRecvError error + closeAndRecvResponse *module.Response +} + +func (m *MockPublisher_UploadSourceZipClient) Send(request *module.UploadSourceZipRequest) error { + m.sendCalls++ + return m.sendError +} + +func (m *MockPublisher_UploadSourceZipClient) CloseAndRecv() (*module.Response, error) { + return m.closeAndRecvResponse, m.closeAndRecvError +}